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 IOnonblocking IOIO multiplexingsignal driven IOasynchronous IO
阻塞在哪recvfromreadselect/poll/epollread-
交互形式串行,逐条处理轮询,通过状态判断是否有连接到达事件驱动,连接到达时 select 释放阻塞回调,由 SIGIO 信号通知数据拷贝回调,数据已经拷贝完毕

虽然相比于异步 IO,仍然需要通过同步的形式将内核态数据拷贝到用户态,性能上仍然不如异步 IO 高,但是,却有着更简便的实现方式,因此,仍然是当前实现高并发 IO 系统的一种主要途径。如 PostgreSQL、redis 等数据库,再如 Netty 等都是通过 IO 多路复用实现的。(截止目前)

IO 多路复用的系统调用

IO 多路复用的系统调用主要有三个,分别是 select, poll 与 epoll,当然,这是在 Linux 上的实现,在其他操作系统上还有不同的实现,例如 kqueue,我们此处只论 Linux.
从发展进程上看,首先是 select 诞生,然后是 poll,最后才是 epoll. 因此,根据常识,后诞生的系统调用肯定是有改进之处的,所以,我们先从 select 开始介绍起。

select

诞生于 2000 年左右,系统调用的原型函数为:

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

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

详情地址:

http://man7.org/linux/man-pages/man2/select.2.html

我们前面说过,IO 多路复用这种 IO 模式,它所复用的是处理线程,也就是复用了 “哪个 IO 就绪了,可以处理哪个 IO 了” 这个过程。它是通过监控文件描述符(file descriptor, fd)来实现,如果有一个或以上的文件描述符处于“就绪”(ready)状态,就返回其中一个即可。
那么,这些 fd 在系统内核中是通过何种数据结构存储的呢?是通过 bitmap 存放的(数据结构为 fd_set),它默认大小是 1024(对于 64bit 有 2048 个)。因此,对于大于 1024 的 fd,基本效果就不可控了,这客观限制了并发量的上限。
因此,总结一下 select 的问题:

  1. fd 存储数据结构的存储上限对高并发非常不友好;
  2. 轮询的时间复杂度是 O(n)
  3. 涉及较多用户态和内核态拷贝

poll

poll 对 select 有了些许改进,如修正了 fd 数量的上限等等,但其他改进的幅度不大,此处不详谈。

epoll

epoll 最初的版本是在 2.5.44,后来在 2.6 的版本后陆续稳定。epoll 的诞生就是为了解决历史遗留问题,因此,epoll 的优势主要有:

  1. 修正了 fd 数量的限制
  2. 放弃使用 bitmap 数据结构,底层改用了链表、红黑树.
  3. 采用事件驱动
  4. 无需重复拷贝 fd

epoll 的系统调用函数原型:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <sys/epoll.h>

/* open an epoll file descriptor */
int epoll_create(int size);
int epoll_create1(int flags);

/* control interface for an epoll file descriptor */
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

/* wait for an I/O event on an epoll file descriptor */
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
int epoll_pwait(int epfd, struct epoll_event *events,
int maxevents, int timeout,
const sigset_t *sigmask);

上述详情参考此处:

http://www.man7.org/linux/man-pages/man7/epoll.7.html

对上述三个 epoll 的 API 进行简单理解,就是:

  • epoll_create: 初始化数据结构,开辟空间,返回 epfd 句柄
  • epoll_ctl:注册 IO 监听事件
  • epoll_wait:等待注册的 IO 事件就绪的通知

上述 API 中,有一个关键的数据结构 epoll_event,它的定义是:

1
2
3
4
5
6
7
8
9
10
11
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;

struct epoll_event {
uint32_t events; /* Epoll events */
epoll_data_t data; /* User data variable */
};

来看一下官方给出的 example:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
/*
* While the usage of epoll when employed as a level-triggered interface
* does have the same semantics as poll(2), the edge-triggered usage
* requires more clarification to avoid stalls in the application event
* loop. In this example, listener is a nonblocking socket on which
* listen(2) has been called. The function do_use_fd() uses the new
* ready file descriptor until EAGAIN is returned by either read(2) or
* write(2). An event-driven state machine application should, after
* having received EAGAIN, record its current state so that at the next
* call to do_use_fd() it will continue to read(2) or write(2) from
* where it stopped before.
*/
#define MAX_EVENTS 10
struct epoll_event ev, events[MAX_EVENTS];
int listen_sock, conn_sock, nfds, epollfd;

/* Code to set up listening socket, 'listen_sock',
(socket(), bind(), listen()) omitted */

epollfd = epoll_create1(0);
if (epollfd == -1) {
perror("epoll_create1");
exit(EXIT_FAILURE);
}

ev.events = EPOLLIN;
ev.data.fd = listen_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, listen_sock, &ev) == -1) {
perror("epoll_ctl: listen_sock");
exit(EXIT_FAILURE);
}

for (;;) {
nfds = epoll_wait(epollfd, events, MAX_EVENTS, -1);
if (nfds == -1) {
perror("epoll_wait");
exit(EXIT_FAILURE);
}

for (n = 0; n < nfds; ++n) {
if (events[n].data.fd == listen_sock) {
conn_sock = accept(listen_sock,
(struct sockaddr *) &addr, &addrlen);
if (conn_sock == -1) {
perror("accept");
exit(EXIT_FAILURE);
}
setnonblocking(conn_sock);
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = conn_sock;
if (epoll_ctl(epollfd, EPOLL_CTL_ADD, conn_sock,
&ev) == -1) {
perror("epoll_ctl: conn_sock");
exit(EXIT_FAILURE);
}
} else {
do_use_fd(events[n].data.fd);
}
}
}

代码的总体注释上面已经给出了,翻译一下,就是说这里的 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 的事件的监听和处理。