专注于用户空间的系统级编程–即内核之上的所有内容。
分散/聚集IO
分散/聚集IO是一种在单次系统调用中对多个缓冲区输入输出的方法,
可以把多个缓冲区的数据写到单个数据流,
也可以把单个数据流读到多个缓冲区。
与线性IO(标准读写系统调用)相比有以下优势:
- 编码模式更自然,尤其针对分段的数据
- 效率更高,单个向量IO操作可以取代多个线性IO操作
- 性能更好
- 支持原子操作
Linux实现了POSIX 1003.1-2001中定义的一组实现分散/聚集IO机制的系统调用:
#include <sys/uio.h>
struct iovec {
void *iov_base;
size_t iov_len;
};
ssize_t readv(int fd, const struct iovec *iov, int count);
ssize_t writev(int fd, const struct iovec *iov, int count);
readv()从fd中读取count个段到参数iov所指定的缓冲区中;
writev()从参数iov指定的缓冲区读取count个段,并写入到fd中。
每个iovec结构体描述一个独立的、物理不连续的缓冲区,称之为段:
writev()示例:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <string.h>
#include <sys/uio.h>
int main(void)
{
struct iovec iov[3];
ssize_t nr;
int fd, i;
char *buf = {
"AAAAAAAAAAAAAAAAAA\n",
"BBBBBBBBBBBBBBBBBB\n",
"CCCCCCCCCCCCCCCCCC\n"
};
fd = open("test.txt", O_WRONLY | O_CREAT | O_TRUNC);
if(-1 == fd) {
perror("open");
return 1;
}
/* fill out three iovec structures */
for(i = 0; i < 3; i++) {
iov[i].iov_base = buf[i];
iov[i].iov_len = strlen(buf(i)) + 1;
}
nr = writev(fd, iov, 3);
if(-1 == nr) {
perror("writev");
return 1;
}
printf("wrote %d bytes\n", nr);
if(close(fd)) {
perror("close");
return 1;
}
return 0;
}
Event Poll
由于poll()和select()的局限,Linux2.6内核引入了event poll机制。
虽然epoll的实现比poll()和select()要复杂的多,epoll解决了前两个都存在的基本性能问题,并增加了一些新的特性。
对于poll()和select(),每次调用时都需要被监听的文件描述符列表。
内核必须遍历所有被监听的文件描述符。
当这个文件描述符列表变得很大时,每次调用都要遍历列表就变成规模上的瓶颈。
epoll把监听注册从实际监听中分离出来,从而解决这个问题。
一个系统调用会初始化epoll上下文,
另一个从上下文中加入或删除监听的文件描述符,
第三个执行真正的时间等待(event wait)。
创建新的epoll实例
#include <sys/epoll.h>
int epoll_create(int size);
int epoll_create1(int flags);
epoll_create()创建一个epoll的实例,当创建成功后,会占用一个fd,所以记得在使用完之后调用close(),否则fd可能会被耗尽。
自从Linux2.6.8版本以后,size值被忽略,内核可以动态的分配大小,只需要size参数大于0即可。
epoll_create()是老版本的epoll_create1()实现,接受参数flags支持修改epoll行为,当前,只有EPOLL_CLOEXEC是合法flag。
当flag = EPOLL_CLOEXEC,创建的epfd会设置FD_CLOEXEC。
epoll的标准调用方式如下:
int epfd;
epfd = epoll_create1(0);
if(epfd < 0)
perror("epoll_create1");
epoll控制函数
#include <sys/epoll.h>
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
epoll的事件注册函数,它不同与select()是在监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。
第一个参数是epoll_create1()的返回值,
第二个参数表示动作,用三个宏来表示:
- EPOLL_CTL_ADD:注册新的fd到epfd中
- EPOLL_CTL_MOD:修改已经注册的fd的监听事件
- EPOLL_CTL_DEL:从epfd中删除一个fd
第三个参数是需要监听的fd,第四个参数是告诉内核需要监听什么事,events可以是以下几个宏的集合:
- EPOLLIN :表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
- EPOLLOUT:表示对应的文件描述符可以写
- EPOLLPRI:表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
- EPOLLERR:表示对应的文件描述符发生错误
- EPOLLHUP:表示对应的文件描述符被挂起
- EPOLLET: 将EPOLL设为边缘触发(Edge Triggered)模式,默认是条件触发(Level Triggered)
- EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
event_poll中的data变量是由用户私有使用。
当接收到请求的事件后,data会被返回给用户。
通常的用法是把event.data.fd设置为fd,这样可以很容易查看那个文件描述符触发了事件。
等待epoll事件
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
当调用epoll_wait()时,等待epoll实例epfd中的文件fd上的事件,时限为timeout毫秒。
成功时,events指向描述每个事件的epoll_event结构体的内存,且最多可以有maxevents个事件,返回值是事件数。
当调用返回时,epoll_event结构体中的events变量描述了发生的事件。
data变量保留用户在调用epoll_ctl()前的所有内容。
边缘触发(ET)和条件触发(LT)
如果epoll_ctl()的参数event中的events设置为EPOLLET,fd上监听方式为边缘触发(ET),否则为条件触发(LT)。
以下面的生产者和消费者在通过UNIX管道通信时举例:
1.生产者向管道写入1KB数据
2.消费者在管道上调用epoll_wait(),等待管道上有数据并可读
通过LT,在步骤2中epoll_wait()调用会立即返回,表示管道可读。
通过ET,需要步骤1发生后,步骤2的epoll_wait()才会返回。
也就是说对于ET,在调用epoll_wait()时,即使管道已经可读,也只有在数据写入之后,调用才会返回。
- LT:效率会低于ET触发,尤其在大并发,大流量的情况下。
但是LT对代码编写要求比较低,不容易出现问题。
LT模式服务编写上的表现是:只要有数据没有被获取,内核就不断通知你,因此不用担心事件丢失的情况。 - ET:效率非常高,在并发,大流量的情况下,会比LT少很多epoll的系统调用,因此效率高。
但是对编程要求高,需要细致的处理每个请求,否则容易发生丢失事件的情况。
epoll使用例子:
epoll_wait范围之后应该是一个循环,遍利所有的事件。
几乎所有的epoll程序都使用下面的框架:
for( ; ; )
{
nfds = epoll_wait(epfd,events,20,500);
for(i=0;i<nfds;++i)
{
if(events[i].data.fd==listenfd) //有新的连接
{
connfd = accept(listenfd,(sockaddr *)&clientaddr, &clilen); //accept这个连接
ev.data.fd=connfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_ADD,connfd,&ev); //将新的fd添加到epoll的监听队列中
}
else if( events[i].events&EPOLLIN ) //接收到数据,读socket
{
n = read(sockfd, line, MAXLINE); //读
ev.data.ptr = md; //md为自定义类型,添加数据
ev.events=EPOLLOUT|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev);//修改标识符,等待下一个循环时发送数据,异步处理的精髓
}
else if(events[i].events&EPOLLOUT) //有数据待发送,写socket
{
struct myepoll_data* md = (myepoll_data*)events[i].data.ptr; //取数据
sockfd = md->fd;
send( sockfd, md->ptr, strlen((char*)md->ptr), 0 ); //发送数据
ev.data.fd=sockfd;
ev.events=EPOLLIN|EPOLLET;
epoll_ctl(epfd,EPOLL_CTL_MOD,sockfd,&ev); //修改标识符,等待下一个循环时接收数据
}
else
{
//其他的处理
}
}
}
存储映射
内存映射支持应用程序将文件映射到内存中,即内存地址和文件数据一一对应。
开发人员可以通过内存来访问文件,就像操作内存中的数据块一样,甚至可以写入内存数据区,
然后通过透明的映射机制将文件写入磁盘。
mmap()和munmap()
#include <sys/mmap.h>
void *mmap(void *addr,size_t len,int prot, int flags,int fd,off_t offset);
int mummap(void *addr, size_t len);
- addr:建议内核将文件映射到的地址,这只是一个hint,为了可移植性,一般设置为0,调用返回mapping开始的实际地址。
- fd:要映像文件的描述符。
- prot描述了内存映像的保护权限,可以使用OR连接以下选项:
- PROT_READ:区域可读。
- PROT_WRITE:区域可写
- PROT_EXEC:区域可执行
- PROT_NONE:区域不可访问,很少有用。
- prot不能和打开文件的模式冲突。比如打开了一个只读文件,prot不可以指定为PROT_WRITE。
- flags:描述了内存映像的方式,可以使用OR连接以下选项:
- MAP_FIXED: addr是必须的,并且不是一个hint,如果内核无法在该地址映像,则失败。具有不可移植性,不推荐使用。
- MAP_PRIVATE:映像不共享,文件被映像为copy-on-write,任何在内存中的改变,都不会反映在文件或者其他进程的mapping中。
- MAP_SHARED:映像和其他进程共享映射的同一个文件,写入buffer等效于写入文件,读取映像写操作同时反映在其他进程中。
- MAP_SHARED和MAP_PRIVATE其中之一必须被指定。
addr和off的值需要被指定为系统虚拟页的整数倍,可以通过使用_SC_PAGESIZE或_SC_PAGE_SIZE作为sysconf参数获得页的大小。
如果文件的大小12字节,但系统页的大小是512字节,那么系统调用会映射512字节,其他的部分被填充为0,
我们可以修改另外的500个字节,但是并不反应在文件中,所有我们不能使用mmap来append文件,我们需要首先增大文件到指定的值。
mummap删除了从addr地址开始,是page对齐的,连续的len字节的映像,一旦被删除,
内存中的区域就不在有效,访问会产生SIGSEGV信号。
munmap通常传入由mmap返回的addr以及设置的len长度。
内存映射实例
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>
int main(int argc,char *argv[])
{
struct stat sb;
off_t i;
char *p;
int fd;
if(argc < 2){
fprintf(stderr,"usage: %s <file>\n",argv[0]);
return 1;
}
fd = open(argv[1],O_RDONLY);
if(fd == -1){
perror("open");
return 1;
}
if(fstat(fd,&sb) == -1){
perror("fstat");
return 1;
}
if(! S_ISREG(sb.st_mode)){
fprintf(stderr,"%s is not a regular file\n",argv[1]);
return 1;
}
p = mmap(0,sb.st_size, PROT_READ,MAP_SHARED,fd,0);
if(p == MAP_FAILED){
perror("mmap");
return 1;
}
if(close(fd) == -1){
perror("close");
return 1;
}
for(i = 0;i < sb.st_size; i++){
putchar(p[len]);
}
if(munmap(p,sb.st_size) == -1){
perror("mummap");
return 1;
}
return 0;
}
mmap()优缺点
优点:
- 从内存映像文件中读写,避免了read、write多余的拷贝。
- 从内存映像文件中读写,避免了多余的系统调用和用户-内核模式的切换
- 可以多个进程共享内存映像文件。
- seeking内存映像只需要指针操作,避免系统调用lseek。
缺点:
- 内存映像需要时整数倍页大小,如果文件较小,会浪费内存。
- 内存映像需要在放在进程地址空间,大的内存映像可能导致地址空间碎片,找不到足够大的空余连续区域供其它用。
- 内核需要维护更多的和内存映像相关的数据结构。
调整映射大小
#define _GNU_SOURCE
#include <unistd.h>
#include <sys/mman.h>
void *mremap(void *addr, size_t old_size, size_t new_size, unsigned long flags);
mremap扩展或者缩小内存映像,从区域[addr,add+old_size]编程一个新的大小new_size。
flags:
- 0:不可以移动来改变内存映像大小。
- MREMAP_MAYMOVE:如果需要可以移动地址改变内存映像大小。
返回值:成功返回重新设置大小之后的内存映像的大小,失败返回MAP_FAILED,并设置errno:
- EAGAIN:内存区域被锁定,无法改变大小。
- EFAULT:给定区域的一些也有不合法的页或者重新映像存在问题。
- EINAL:参数不合法。
- ENOMEM:不移动则无法扩展,如果MREMAP_MAYMOVE没有被设置。
glibc经常使用mremap来实现高效的realloc():
void * realloc(void *addr, size_t len)
{
size_t old_size = look_up_mapping_size(addr);
void *p;
p = mremp(addr,old_size, len,MREMAP_MAYMOVE);
if(p == MAP_FAILED)
return NULL;
return p;
}
改变映射区域权限
mprotect可以允许程序改变已经存在内存区域的permissions:
#include <sys/mman.h>
int mprotect(const void *addr, size_t len, int prot);
//mprotect (memory, alloc_size, PROT_READ | PROT_WRITE);
一些系统只能改变由mmap得到的内存映像的protection,Linux可以操作任何一个内存区域。
成功返回0,失败返回-1,并设置errno为:
- EACCESS:不可以被设置成prot的permissions,可能是文件打开时只读的,设置成可写
- EINVAL:参数不合法。
- ENOMEM:内核内存不足或者所给的内存区域不是进程的合法地址空间。
通过映射同步文件
POSIX提供了和文件操作中fsync类似的将文件和内存映像同步的操作:
#include <sys/mman.h>
int msync(void *addr, size_t len, int flags);
将内存映像flush到磁盘。没有msync,没有能够保证将mapping的脏数据写回磁盘,除非被unmapped。
当修改内存映像,进程直接修改在内核页cache中的文件页,
内核可能不会很快将内核的页cache同步到磁盘。
flags使用OR链接下面选项:
MS_ASYNC:异步的执行同步操作,msync立即返回,更新操作被调度。
MS_INVALIDATE:指定所有其他的内存映像cache副本无效。任何以后的操作都同步到磁盘中。
MS_SYNC:同步的执行同步操作,等将内容写入磁盘在返回。
if(msync(addr,len,MS_ASYNC) == -1)
perror("msync");
成功返回0,失败返回-1,并设置errno:
- EINVAL:MS_SYNC和MS_ASYNC同时被设置或者addr没有页对齐。
- ENOMEM:所给的内存区域(或者部分)没有被映射。
给出映射提示
Linux提供了madvice来让进程建议内核或者给内核提供线索来使用mapping,这样可以优化mapping的使用。
#include <sys/mman.h>
int madvice(void *addr, size_t len, int advice);
madvise() 函数建议内核,在从 addr 指定的地址开始,
长度等于 len 参数值的范围内,该区域的用户虚拟内存应遵循特定的使用模式。
len如果为0,内核将建议施用于从addr开始的整个映像。advice:可以是以下之一:
- MADV_NORMAL:没有特别的建议。建议使用中等程度的预读。
- MADV_RANDOM:以随机访问的方式访问指定的区域。建议较少的预读。
- MADV_SEQUENTIAL:顺序访问指定区域。建议大量预读
- MADV_WILLNEED:将来要访问指定区域。初始化预读,将指定的页读到内存。
- MADV_DONTNEED:将来不再访问指定区域。内核释放与指定页关联的资源。后续的读会导致从文件中再度调入。
调用mmap()时内核只是建立了逻辑地址到物理地址的映射表,并没有映射任何数据到内存。
在你要访问数据时内核会检查数据所在分页是否在内存,如果不在,则发出一次缺页中断。
将madvise()和mmap()搭配起来使用,在使用数据前告诉内核这一段数据我要用,将其一次读入内存。