终于把I/O多路复用的几篇总结写完了,也算是下了不少功夫吧。期间收到私信,说希望我能持久更新。很开心自己写的东西,能够帮到别人。好了,废话不多说了,通过这篇博客总结一下三种多路复用I/O的异同。
前面我们讨论了select,poll和epoll三组I/O复用系统调用,这3组系统调用都能同时监听多个文件描述符。它们将等待由timeout参数指定的超时时间,直到一个或者多个文件描述符上有事件发生时返回,返回值是就绪的文件描述符的数量。
select, poll, epoll 都是I/O多路复用的具体的实现,他们出现是有先后顺序的。根据我们的日常经验,一般后出现的都会比早出现的更好些。事实上也确实是这样。
I/O多路复用这个概念被提出来以后, select是第一个实现 (1983 左右在BSD里面实现的)。
select 被实现以后,很快就暴露出了很多问题。比如:
- select 会修改传入的参数数组,这个对于一个需要调用很多次的函数,是非常不友好的。
- select 如果任何一个sock(I/O stream)出现了数据,select 仅仅会返回,但是并不会告诉你是那个sock上有数据,于是你只能自己一个一个的找。
- select 只能监视1024个链接(32位机器下),linux 定义在头文件中的,参见FD_SETSIZE。
于是14年以后(1997年)一帮人又实现了poll, poll 修复了select的很多问题,比如:
- poll 去掉了1024个链接的限制。
- 不用再修改传入数组。
其实拖14年那么久也不是效率问题, 而是那个时代的硬件实在太弱,一台服务器处理1千多个链接简直就是神一样的存在了,select很长段时间已经满足需求。
虽然poll在select基础上改进了很多,方便了用户的使用。但是还是存在一些问题没有改进,poll的实现机制跟select是一样的,其实两者的差别不是很大。
后来就有了epoll。
从实现原理上来说,select和poll采用的都是轮询的方式,即每次调用都要扫描整个注册文件描述符集合,并将其中就绪的文件描述符返回给用户程序,因此它们检测就绪事件的算法的时间复杂度是O(n)。epoll_wait()则不同,它采用的是回调的方式。内核检测到就绪的文件描述符时,将触发回调函数,回调函数就将该文件描述符上对应的事件插入内核就绪事件队列。内核最后在适当的时机将该就绪事件队列中的内容拷贝到用户空间。因此epoll_wait无须轮询整个文件描述符集合,所以其时间复杂度为O(1)。
同时,由于每次select和poll调用都返回整个用户注册的事件集合,所以应用程序索引就绪文件描述符的时间复杂度为O(n)。epoll_wait系统调用的events参数仅用来返回就绪的事件,这使得应用程序索引就绪文件描述符的时间复杂度为O(1)。
为了简洁,清晰的比较三者的异同,接下来都以表格的形式列出。
系统调用 | select | poll | epoll |
---|---|---|---|
事件集合 | 用户通过3个参数分别传入感兴趣的可读,可写及异常等事件,内核通过对这些参数的在线修改来反馈其中的就绪事件。这使得用户每次调用select都要重置这3个参数 | 统一处理所有事件类型,因此只需要一个事件集参数。用户通过pollfd.events传入感兴趣的事件,内核通过修改pollfd.revents反馈其中就绪的事件 | 内核通过一个事件表直接管理用户感兴趣的所有事件。因此每次调用epoll_wait时,无须反复传入用户感兴趣的事件。epoll_wait系统调用参数events仅用来反馈就绪的事件 |
应用程序索引就绪文件描述符的时间复杂度 | O(n) |
O(n) |
O(1) |
最大支持文件描述符数 | 由FD_SETSIZE决定 | /proc/sys/fs/file-max规定,最大打开的文件描述符数 | /proc/sys/fs/file-max规定,最大打开的文件描述符数 |
工作模式 | LT | LT | 默认是LT,支持ET |
内核实现和工作效率 | 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) | 采用轮询方式来检测就绪事件,算法时间复杂度为O(n) | 采用回调方式来检测就绪事件,算法时间复杂度为O(1) |
I/O多路复用 | 原理 |
---|---|
select | select本质上是通过设置或者检查存放fd标志位的数据结构来进行下一步处理。这样所带来的缺点是: 1.单个进程可监视的fd数量被限制 2.需要维护一个用来存放大量fd的数据结构,这样会使得用户空间和内核空间在传递该结构时复制开销大 3.对socket进行扫描时是线性扫描 |
poll | poll本质上和select没有区别,它将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或主动超时,唤醒后它又要在此遍历fd。这个过程经历了多次无谓的遍历。和select一样,存在大量的fd的数组被整体复制于用户态和内核地址空间之间的缺点。 |
epoll | 在前面说的复制问题上,epoll使用mmap减少复制开销。还有一个特点是epoll采用事件就绪通知方式。 |
I/O多路复用 | FD剧增后带来的IO效率问题 |
---|---|
select | 因为每次调用时都会对连接进行线性遍历,所以随着FD的增加会造成遍历速度慢的“线性下降性能问题” |
poll | 同上 |
epoll | 因为epoll内核中实现是根据每个fd上的callback函数来实现的,只有活跃的socket才会主动调用callback,所以在活跃socket较少的情况下,使用epoll没有前面两者的线性下降问题,但是所有socket都很活跃的情况下,性能会下降。 |
关于三者随监听文件描述符增加的IO效率,可以参考下面的一些数据,就很容易看出:
ps:基于测试目的,已将select的FD_SETSIZE修改为16384,以此允许测试程序在使用select()时能监听大量文件描述符。
被监听的文件描述符数量 | select()所占用cpu时间(秒) | poll()所占用cpu时间(秒) | epoll()所占用cpu时间(秒) |
---|---|---|---|
10 | 0.73 | 0.61 | 0.41 |
100 | 3.0 | 2.9 | 0.42 |
1000 | 35 | 35 | 0.53 |
10000 | 930 | 990 | 0.66 |
很容易看出,当随着监听文件描述符的剧增,select,poll的效率跟epoll相差甚远。