IO 多路复用的 select, poll, epoll 之间的区别和联系总结
IO 多路复用
select, poll 和 epoll 都是 IO 多路复用的模型,所以在深入了解这三个系统调用之前,需要先简单介绍一下 IO 多路复用。
IO 多路复用是一种复用技术,复用 (multiplexing) 技术很普遍,例如通信中有多路时分复用(OFDM)、频分复用、码分复用。再例如我们一个办公室的人可以共用办公室的一台打印机,大家在不同的时段使用即可实现复用,这就是时分复用。
“IO 多路复用” 是指复用了同一个处理线程(在 Java 中被抽象为选择器 selector),由操作系统进行托管,当与选择器绑定的 socket 满足就绪的条件后,可以直接以事件驱动的形式(即释放 select 调用处的阻塞)获取到该 socket。
对比于同步阻塞 IO:服务端(server)通过监听(listen)绑定的端口,不得不通过 recvfrom 系统调用阻塞在此等待客户端的接入;因此 IO 多路复用比阻塞式 IO 并发处理性能明显提高了许多,因为阻塞式的 IO 不得不处理完该 IO 连接再处理下一个 IO 连接,监听线程无法得到复用。相比于非阻塞 IO,又免去了多次轮训之苦(轮询意味着始终占用 CPU)。各种 IO 模型的对比如图 1.
我们将 5 种不同的 IO 模型简单梳理一下:
对比类别 | blocking IO | nonblocking IO | IO multiplexing | signal driven IO | asynchronous IO |
---|---|---|---|---|---|
阻塞在哪 | recvfrom | read | select/poll/epoll | read | - |
交互形式 | 串行,逐条处理 | 轮询,通过状态判断是否有连接到达 | 事件驱动,连接到达时 select 释放阻塞 | 回调,由 SIGIO 信号通知数据拷贝 | 回调,数据已经拷贝完毕 |
虽然相比于异步 IO,仍然需要通过同步的形式将内核态数据拷贝到用户态,性能上仍然不如异步 IO 高,但是,却有着更简便的实现方式,因此,仍然是当前实现高并发 IO 系统的一种主要途径。如 PostgreSQL、redis 等数据库,再如 Netty 等都是通过 IO 多路复用实现的。(截止目前)
IO 多路复用的系统调用
IO 多路复用的系统调用主要有三个,分别是 select, poll 与 epoll,当然,这是在 Linux 上的实现,在其他操作系统上还有不同的实现,例如 kqueue,我们此处只论 Linux.
从发展进程上看,首先是 select 诞生,然后是 poll,最后才是 epoll. 因此,根据常识,后诞生的系统调用肯定是有改进之处的,所以,我们先从 select 开始介绍起。
select
诞生于 2000 年左右,系统调用的原型函数为:
1 | #include <sys/select.h> |
详情地址:
我们前面说过,IO 多路复用这种 IO 模式,它所复用的是处理线程,也就是复用了 “哪个 IO 就绪了,可以处理哪个 IO 了” 这个过程。它是通过监控文件描述符(file descriptor, fd)来实现,如果有一个或以上的文件描述符处于“就绪”(ready)状态,就返回其中一个即可。
那么,这些 fd 在系统内核中是通过何种数据结构存储的呢?是通过 bitmap 存放的(数据结构为 fd_set),它默认大小是 1024(对于 64bit 有 2048 个)。因此,对于大于 1024 的 fd,基本效果就不可控了,这客观限制了并发量的上限。
因此,总结一下 select 的问题:
- fd 存储数据结构的存储上限对高并发非常不友好;
- 轮询的时间复杂度是 O(n)
- 涉及较多用户态和内核态拷贝
poll
poll 对 select 有了些许改进,如修正了 fd 数量的上限等等,但其他改进的幅度不大,此处不详谈。
epoll
epoll 最初的版本是在 2.5.44,后来在 2.6 的版本后陆续稳定。epoll 的诞生就是为了解决历史遗留问题,因此,epoll 的优势主要有:
- 修正了 fd 数量的限制
- 放弃使用 bitmap 数据结构,底层改用了链表、红黑树.
- 采用事件驱动
- 无需重复拷贝 fd
epoll 的系统调用函数原型:
1 | #include <sys/epoll.h> |
上述详情参考此处:
对上述三个 epoll 的 API 进行简单理解,就是:
- epoll_create: 初始化数据结构,开辟空间,返回 epfd 句柄
- epoll_ctl:注册 IO 监听事件
- epoll_wait:等待注册的 IO 事件就绪的通知
上述 API 中,有一个关键的数据结构 epoll_event,它的定义是:
1 | typedef union epoll_data { |
来看一下官方给出的 example:
1 | /* |
代码的总体注释上面已经给出了,翻译一下,就是说这里的 epoll 不仅监听了 listen 获取到的新连接的 socket,当新连接的 socket 接入后,又将新连接的 socket 的读写作为事件进行监听,当监测到新连接可以进行读写(因为 ev.events = EPOLLIN | EPOLLET)时,则调用 do_use_fd() 函数. 当然,在具体的实现上,你可以只监听新到达的 socket,然后创建一个新的线程去消费它。
epoll 有两种模式提供给用户,分别是 LT 与 ET 模式,这两种模式在此处不便展开谈,篇幅会比较长,简单总结一下:
epoll 默认的模式是 LT. 考虑这样一个场景,通过 epoll_wait() 函数获取事件后,如果事件没被处理或者没被处理完,那么这个事件还应不应该继续通知?在 LT 下是接着通知,在 ET 下是直接忽略。另外 ET 仅支持非阻塞的 socket. 总之,关于这两个模式,读取数据还好,但是写数据时就比较麻烦了,可以单独再开一篇文章来讨论了。
总结
以上就是对 select、epoll 的简单剖析,当前的服务器的 IO 模型主要都是 IO 多路复用,即有一个主线程用于轮询,获取连接到的 socket,当获取到新的接入 socket 后,通过线程或子进程来消费这个 socket. 例如,PostgreSQL 的主线程 ServerLoop 函数,就是阻塞在 select() 函数处,当一个新的客户端接入时,创建一个新的子进程,消费该 socket. 对于这种数据库场景,很多时候是 CPU 密集的,因此必须要通过一个新进程来消费 socket, 否则巨大的 CPU 开销会严重影响系统的并发。但是,对于某些重 IO 轻 CPU 的场景,其实也可以不必采用这种方式,可以采用类似上述 demo 代码的形式,在一个线程中完成 IO 的事件的监听和处理。