0%

Linux系统编程-高级文件I/O


专注于用户空间的系统级编程–即内核之上的所有内容。

分散/聚集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()优缺点

优点:

  1. 从内存映像文件中读写,避免了read、write多余的拷贝。
  2. 从内存映像文件中读写,避免了多余的系统调用和用户-内核模式的切换
  3. 可以多个进程共享内存映像文件。
  4. seeking内存映像只需要指针操作,避免系统调用lseek。

缺点:

  1. 内存映像需要时整数倍页大小,如果文件较小,会浪费内存。
  2. 内存映像需要在放在进程地址空间,大的内存映像可能导致地址空间碎片,找不到足够大的空余连续区域供其它用。
  3. 内核需要维护更多的和内存映像相关的数据结构。

调整映射大小

#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()搭配起来使用,在使用数据前告诉内核这一段数据我要用,将其一次读入内存。

  1. epoll详解
  2. LT自动挡,ET手动挡(epoll)
  3. EPOLL LT和ET区别
  4. epoll使用详解(精髓)
  5. How to use epoll? A complete example in C
  6. Linux的内存映射