select 和 poll 的缺陷
I/O多路复用允许进程同时监听多个文件描述符,这样进程就可以知道哪些文件描述符存在I/O就绪了。select()
是最早出现的I/O多路复用方式,然而由于早期select()
的API设计不当,导致了select()
存在不少缺陷。
从API层面来讲,select()
使用fd_set
来表示文件描述符的集合,而fd_set
其实就是一个固定长度的位向量(bit vector),在Linux上,这个固定长度是FD_SETSIZE
,其数值是1024。这就带来了一个限制:凡是select()
监听的文件描述符,它的大小必须小于1024。
poll()
在某些程度上改进了select()
存在的一些问题,例如poll()
要求用户传递一个pollfd
数组,但它不会限定这个数组的长度,所以理论上poll()
可以监听任意数量的文件描述符(但实际上仍受限于进程最多能打开的文件描述符数量或系统的内存)。
使用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
类型的数据结构:
epoll_ctl()
负责增加、删除或修改红黑树上的节点,而epoll_wait()
则负责返回双向链表中就绪的文件描述符(及其事件)。说到这里,我们不禁要问,epoll是怎么知道有哪些文件描述符已经就绪的呢?难道是遍历一次红黑树,逐个检查文件描述符?不可能,这效率太低了。
与select()
或poll()
不同,epoll 是事件驱动的,简单来说,当网卡收到一个 packet 的时候,会触发一个硬件中断,这导致内核调用相应的中断 handler,从网卡中读入数据放到协议栈,当数据量满足一定条件时,内核将回调ep_poll_callback()
这个方法,它负责把这个就绪的文件描述符添加到双向链表中。这样当用户调用epoll_wait()
时,epoll_wait()
所做的就只是检查双向链表是否为空,如果不为空,就把文件描述符和数量返回给用户即可。