几种 I/O Multiplexing 方式的比较

select 和 poll 的缺陷

  I/O多路复用允许进程同时监听多个文件描述符,这样进程就可以知道哪些文件描述符存在I/O就绪了。select()是最早出现的I/O多路复用方式,然而由于早期select()的API设计不当,导致了select()存在不少缺陷。

1
2
int select(int nfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);

  从API层面来讲,select()使用fd_set来表示文件描述符的集合,而fd_set其实就是一个固定长度的位向量(bit vector),在Linux上,这个固定长度是FD_SETSIZE,其数值是1024。这就带来了一个限制:凡是select()监听的文件描述符,它的大小必须小于1024。
  poll()在某些程度上改进了select()存在的一些问题,例如poll()要求用户传递一个pollfd数组,但它不会限定这个数组的长度,所以理论上poll()可以监听任意数量的文件描述符(但实际上仍受限于进程最多能打开的文件描述符数量或系统的内存)。

1
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

  使用select()poll()监听大量的文件描述符时,往往会遭遇到性能问题。当用户每次调用select()poll()时,内核会对传入的所有文件描述符都检查一遍,并记录其中有哪些文件描述符存在I/O就绪,这个操作的耗时将随着文件描述符数量的增加而线性增长。
  另一个重要因素也会影响select()poll()的性能,例如用户每次调用poll()时,都需要传递一个pollfd数组,而poll()会将这个数组从用户空间拷贝到内核空间,当内核对这个数组作了修改之后,poll()又会将这个数组从内核空间拷贝到用户空间。随着pollfd数组长度的增加,每次拷贝的时间也会线性增长,一旦poll()需要监听大量的文件描述符,每次调用poll()时,这个拷贝操作将带来不小的开销。这个问题的根源在于select()poll()的API设计不当,例如,对于应用程序来说,它每次调用poll()所监听的文件描述符集合应该是差不多的,所以我们不禁这样想,如果内核愿意提供一个数据结构,记录程序所要监听的文件描述符集合,这样每次用户调用poll()时,poll()就不需要将pollfd数组拷贝来拷贝去了(没错,epoll 就是这样解决的)。

epoll 的救赎

  epoll 很好地解决了select()poll()中存在的问题,从API层面来讲,epoll 使用 3 个调用来完成原本由select()poll()所做的事,首先epoll_create()负责在内核中创建一个eventpoll类型的数据结构:

1
2
3
4
5
6
7
8
9
struct eventpoll
{
// 红黑树,用来记录进程所要监听的文件描述符(及其要监听的事件)
struct rb_root rbr;
// 双向链表,用来记录有哪些文件描述符已经就绪了
struct list_head rdllist;
// ...
};

  epoll_ctl()负责增加、删除或修改红黑树上的节点,而epoll_wait()则负责返回双向链表中就绪的文件描述符(及其事件)。说到这里,我们不禁要问,epoll是怎么知道有哪些文件描述符已经就绪的呢?难道是遍历一次红黑树,逐个检查文件描述符?不可能,这效率太低了。
  与select()poll()不同,epoll 是事件驱动的,简单来说,当网卡收到一个 packet 的时候,会触发一个硬件中断,这导致内核调用相应的中断 handler,从网卡中读入数据放到协议栈,当数据量满足一定条件时,内核将回调ep_poll_callback()这个方法,它负责把这个就绪的文件描述符添加到双向链表中。这样当用户调用epoll_wait()时,epoll_wait()所做的就只是检查双向链表是否为空,如果不为空,就把文件描述符和数量返回给用户即可。

参考资料