I/O 多路复用

I/O 多路复用实际上就是用 select, poll, epoll 监听多个 I/O 对象,当 I/O 对象有变化(有数据)的时候就通知用户进程。好处就是单个进程可以处理多个 Socket。I/O 多路复用的流程:

当用户进程调用了 select,那么整个进程会被阻塞,而同时,内核会“监视” select 负责的文件描述符集合,当集合中任何一个的数据准备好了,select 就会返回。这个时候用户进程再调用 read 操作,将数据从内核空间拷贝到用户进程。

所以,I/O 多路复用的特点是通过一种机制使得一个进程或一个线程能同时等待多个文件描述符的 I/O 操作,而这些文件描述符其中的任意一个进入读就绪状态,select() 函数就可以返回。这样,多个描述符的 I/O 操作都能在一个进程或线程内并发交替地顺序完成,这就叫 I/O 多路复用,这里的“复用”指的是复用同一个进程或线程。

select、poll 和 epoll 概述

select,poll,epoll 都是 I/O 多路复用的机制。I/O 多路复用就是通过一种机制,可以监视多个描述符,一旦某个文件描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作,也就是说它们的职责不是进行 I/O 操作,而是帮助调用者寻找当前就绪的设备。但 select,poll,epoll 本质上都是同步 I/O,因为他们都需要在读写事件就绪后自己负责进行读写,也就是说这个读写过程是阻塞的,而异步 I/O 则无需自己负责进行读写,异步 I/O 的实现会负责把数据从内核拷贝到用户空间。

select

select 介绍

select 函数在一段指定时间内,监听用户感兴趣的一组支持非阻塞的 I/O 文件描述符上的可读、可写和异常事件,直至某一个文件描述符触发了事件或者超过了指定的等待时间。select 能够监视的文件描述符的数量存在最大限制,通常是 1024。

select 接口

函数原型如下:

1
2
3
4
#include <sys/select.h>

// 返回值:正常工作返回就绪描述符的数目,超时返回0,出错返回-1
int select(int maxfdp1, fd_set *readset, fd_set *writeset, fd_set *exceptset, const struct timeval *timeout)

函数参数介绍如下:

  • 第一个参数 maxfdp1 指定待测试的文件描述符个数,它的值是待测试的最大文件描述符加 1(因此把该参数命名为 maxfdp1),文件描述字 0、1、2 … maxfdp1-2、maxfdp1-1 均将被测试(因为文件描述符是从0开始的)。

  • 中间的三个参数 readset、writeset 和 exceptset 是指定我们要让内核测试读、写和异常条件的文件描述符的集合。如果并不需要监听某一个,就可以把它设为空指针。

    struct fd_set 结构体是文件描述符集,该结构体实际上是一个整型数组,数组中的每个元素的每一位标记一个文件描述符。fd_set 能容纳的文件描述符数量由 FD_SETSIZE 指定,一般情况下,FD_SETSIZ E等于1024,这就限制了 select 能同时处理的文件描述符的总量。struct fd_set 可通过以下四个函数进行操作:

    • void FD_ZERO(fd_set *fdset); // 清空集合
    • void FD_SET(int fd, fd_set *fdset); // 将一个给定的文件描述符加入集合之中
    • void FD_CLR(int fd, fd_set *fdset); // 将一个给定的文件描述符从集合中删除
    • int FD_ISSET(int fd, fd_set *fdset); // 检查集合中指定的文件描述符是否可以读写
  • timeout 则是告知内核等待所指定描述字中的任何一个就绪可花多少时间。struct timeval 结构用于指定这段时间的秒数和微秒数。

    1
    2
    3
    4
    struct timeval {
    long tv_sec; //seconds
    long tv_usec; //microseconds
    };

    这个参数有三种可能:

    • 永远等待下去:仅在有一个文件描述符准备好 I/O 时才返回。只要将该参数设置为空指针 NULL。
    • 等待一段固定时间:在有一个描述字准备好 I/O 时返回,但是不超过由该参数所指向的 struct timeval 结构中指定的秒数和微秒数。
    • 根本不等待:检查文件描述符后立即返回,这称为轮询。为此,该参数必须指向一个 struct timeval 结构,而且其中的定时器值必须为 0。

select 总结

select 创建了 3 个文件描述符集合(fd_set)并拷贝到内核中,分别监听读、写、异常事件。select 可以监听的文件描述符数量受到单个进程所能打开的文件描述符的限制,默认为 1024,采用轮询方式遍历所有的文件描述符,最后返回一个文件描述符是否就绪的 mask 掩码,并根据 mask 掩码给 fd_set 赋值。将之前的 fd_set 拷贝传出到用户态并返回就绪的文件描述符的总个数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判断。应用程序索引就绪文件描述符的时间复杂度是 O(n)。再次调用 select 时,需要将新的 fd_set 监听文件描述符集合再次拷贝传入内核同时 select 只能工作在效率相对较低的 LT 模式下。

poll

poll 介绍

poll 的机制与 select 类似,与 select 在本质上没有太大差别,同时管理多个文件描述符也是进行轮询,根据描述符的状态进行处理,但是 poll 相比 select 没有最大文件描述符数量的限制。但 poll 和 select 同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核态的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。

poll 接口

函数格式如下所示:

1
2
3
4
# include <poll.h>

// 返回值:正常工作返回就绪描述符的数目,超时返回0,出错返回-1
int poll(struct pollfd *fds, unsigned int nfds, int timeout);

函数参数介绍如下:

  • 第一个参数 fds 是指向一个结构体数组的第一个元素的指针,每个元素都是一个 struct pollfd 结构,用于指定测试某个给定描述符的条件。

    每一个 struct pollfd 结构体指定一个被监视的文件描述符,每个结构体的 events 域是监视该文件描述符的事件类型,由用户来设置这个域。revents 域是文件描述符的操作结果事件类型,内核在调用返回时设置这个域。events 域中请求的任何事件类型都可能在 revents 域中返回。

    1
    2
    3
    4
    5
    struct pollfd {
    int fd; /* 文件描述符 */
    short events; /* 等待的事件 */
    short revents; /* 实际发生了的事件 */
    };

    poll 的事件类型:

  • 第二个参数 nfds 是要监听的文件描述符的个数,也就是数组 fds 的元素个数。

  • 第三个参数 timeout 意义与 select 相同。timeout 参数指定等待的毫秒数,无论 I/O 是否准备好,poll 都会返回。timeout 指定为负数值表示无限等待,使 poll 一直挂起直到一个指定事件发生;timeout 为 0 指示 poll 调用立即返回并列出准备好 I/O 的文件描述符,但并不等待其它的事件。这种情况下,poll 就像它的名字那样,一旦选举出来,立即返回。

poll 总结

使用 poll 和 select 不一样,你不需要显式地请求异常情况报告。POLLIN | POLLPRI 等价于 select 的读事件,POLLOUT |POLLWRBAND 等价于 select 的写事件。POLLIN 等价于 POLLRDNORM |POLLRDBAND,而POLLOUT 则等价于 POLLWRNORM。例如,要同时监视一个文件描述符是否可读和可写,我们可以设置 events 为 POLLIN |POLLOUT。在 poll 返回时,我们可以检查 revents 中的标志,对应于文件描述符请求的 events 结构体。如果 POLLIN 事件被设置,则文件描述符可以被读取而不阻塞。如果 POLLOUT 被设置,则文件描述符可以写入而不导致阻塞。这些标志并不是互斥的:它们可能被同时设置,表示这个文件描述符的读取和写入操作都会正常返回而不阻塞。

poll 将 struct pollfd 结构体数组拷贝到内核中进行监听。poll 采用链表 poll_list 来进行文件描述符的存储,因此poll 可以监听的文件描述符数量为系统可以打开的最大文件描述符数(65535)。与 select 一样采用轮询方式,查询每个文件描述符上的事件状态,如果就绪,内核就修改文件描述符对应的 revents 的值,而 events 成员保持不变,因此下次调用 poll 时,应用程序无需重置 pollfd 类型的事件集参数。将之前传入的 struct pollfd 结构体数组拷贝传出到用户态,并返回就绪文件描述符的总个数。用户态并不知道是哪些文件描述符处于就绪态,需要遍历来判读。应用程序索引就绪文件描述符的时间复杂度是 O(n)。同样,poll 也只能工作在效率相对较低的 LT 模式下。

epoll

epoll 介绍

epoll 是在 Linux 2.6 内核中提出的,是之前的 select 和 poll 的增强版本。相对于 select 和 poll 来说,epoll 在使用和实现上区别很大。首先,epoll 使用一组函数来完成,而不是单独的一个函数;其次,epoll 把用户关心的文件描述符上的事件放在内核里的一个事件表中,无须像 select 和 poll 那样每次调用都要重复传入文件描述符集合事件集,这样在用户空间和内核空间的复制只需一次。

epoll 接口

epoll 操作过程需要三个接口,分别如下:

1
2
3
4
5
6
7
8
9
10
#include <sys/epoll.h>

// 调用成功返回一个文件描述符,失败返回-1并设置errno。
int epoll_create(int size);

// 调用成功返回0,调用失败返回-1并设置errno。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

// 函数调用成功返回就绪文件描述符个数,失败返回-1并设置errno。
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);

三个函数介绍如下:

  • int epoll_create(int size):创建一个 epoll 的文件描述符,size 用来告诉内核需要监听的数目一共有多少。这个参数不同于 select 中的第一个参数,给出最大监听的 fd+1 的值。需要注意的是,当创建好 epoll 文件描述符后,它就是会占用一个 fd 值,在 Linux 下如果查看 /proc/进程id/fd/,是能够看到这个 fd 的,所以在使用完 epoll 后,必须调用 close() 关闭,否则可能导致文件描述符被耗尽。

  • int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event):epoll 的事件注册函数,在使用epoll_ctl 时,是把 fd 添加、修改到内核事件表中,或从内核事件表中删除 fd 的事件。它与 select 不同是在并非监听事件时告诉内核要监听什么类型的事件,而是在这里先注册要监听的事件类型。第一个参数是epoll_create() 的返回值,第二个参数表示需要进行的动作,用三个宏来表示:

    • EPOLL_CTL_ADD:往事件表中注册新的 fd 事件到 epfd 中;
    • EPOLL_CTL_MOD:修改已经注册的 fd 上的监听事件;
    • EPOLL_CTL_DEL:从 epfd 中删除一个 fd 注册的事件;

    第三个参数是需要监听的 fd,第四个参数是告诉内核需要监听什么事件类型,struct epoll_event 结构如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 感兴趣的事件和被触发的事件
    struct epoll_event {
    __uint32_t events; /* Epoll events */
    epoll_data_t data; /* User data variable */
    };


    // 保存触发事件的某个文件描述符相关的数据(与具体使用方式有关)
    typedef union epoll_data {
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
    } epoll_data;

    events 可以是以下几个宏的集合:

    • EPOLLIN :表示对应的文件描述符可以读;
    • EPOLLOUT:表示对应的文件描述符可以写;
    • EPOLLPRI:表示对应的文件描述符有紧急的数据可读;
    • EPOLLERR:表示对应的文件描述符发生错误;
    • EPOLLHUP:表示对应的文件描述符被挂断;
    • EPOLLET: 将 epoll 设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)来说的;
    • EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个文件描述符的话,需要再次把这个文件描述符加入到 epoll 队列里。
  • int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout):在一段时间内等待一组文件描述符上的事件发生,类似于 select 调用。参数 events 是一个传出型参数,epoll_wait 函数如果检测到事件就绪,就将所有就绪的事件从内核事件表(epfd 所指的文件)中复制到 events 指定的数组中,这个数组用来输出 epoll_wait 检测到的就绪事件,而不像 select 与 poll 那样,这也是 epoll 与前者最大的区别。maxevents 告诉内核这个需要监听的事件表 events 有多大,这个 maxevents 的值不能大于创建 epoll_create() 时的 size,参数 timeout 是超时时间(单位毫秒,0 会立即返回,-1 时调用将永远阻塞,直到某个事件发生)。该函数返回需要处理的事件数目,如返回 0 表示已超时。

LT 和 ET 模式

epoll 对文件描述符的操作有两种模式:LT(level trigger)和ET(edge trigger)。LT 模式是默认模式,LT 模式与 ET 模式的区别如下:

  • LT 模式:当 epoll_wait 检测到文件描述符事件发生并将此事件通知应用程序,应用程序可以不立即处理该事件。下次调用 epoll_wait 时,会再次响应应用程序并通知此事件。
  • ET 模式:当 epoll_wait 检测到文件描述符事件发生并将此事件通知应用程序,应用程序必须立即处理该事件。如果不处理,下次调用 epoll_wait 时,不会再次响应应用程序并通知此事件。

LT(level triggered)是缺省的工作方式,并且同时支持阻塞和非阻塞。在这种做法中,内核通知进程一个文件描述符是否就绪了,然后进程可以对这个就绪的文件描述符进行 I/O 操作。如果不作任何操作,下一次内核还是会继续通知你的。

ET(edge triggered)是高速工作方式,只支持非阻塞。在这种模式下,当描述符从未就绪变为就绪时,内核通过 epoll 通知进程。然后它会假设进程知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知,直到进程进行了某些操作导致那个文件描述符不再为就绪状态了。但是请注意,如果一直不对这个文件描述符进行 I/O 操作从而导致它再次变成未就绪,内核是不会发送更多的通知(only once)。

ET 模式在很大程度上减少了使用 epoll 时文件描述符事件被重复触发的次数,因此效率要比 LT 模式高。在网络编程中,epoll 工作在 ET 模式的时候,必须使用非阻塞套接口,以避免由于一个文件描述符的阻塞读或阻塞写操作把处理多个文件描述符的任务饿死。

epoll 总结

epoll 执行 epoll_create() 函数时会在内核创建一颗红黑树 rb_node 以及就绪链表 rdllist (存放已经就绪的文件描述符),可以监听的文件描述符数量可以达到系统可以打开的最大文件描述符数(65535)。接着用户执行的epoll_ctl() 函数将 epoll_event 结构体拷贝传入内核,内核会在红黑树上添加相应的结点,并注册回调函数 ep_poll_callback(),内核在检测到某文件描述符可读或可写时就调用回调函数 callback,该回调函数将文件描述符放入就绪链表 rdllist 中。epoll_wait() 函数只需要观察 rdllist 中有无就绪的文件描述符即可,内核将就绪的文件描述符事件复制到传入的 epoll_event 结构体数组中返回给用户空间,所以用户只用直接遍历数组依次处理即可,即应用程序索引就绪文件描述符的时间复杂度是 O(1)。这里返回的文件描述符是通过内存映射(mmap)技术让内核和用户空间共享同一块内存传递的,减少了不必要的拷贝。再次调用 epoll 系统调用,不用重建红黑树,直接沿用已经存在的即可。epoll 支持ET模式,当内核将该事件通知给用户后,用户必须立即处理,这样就减少了可读、可写和异常事件被触发的次数。

详细的关于 select、poll 和 epoll 的比较会在下次分析。

参考