IO多路复用不同实现及对比
select、poll、epoll 都是用来实现 IO 多路复用的,而多路复用发展的背景就是 socket 网络通信下提升系统的并发处理能力,单纯依靠多进程或多线程来实现对硬件要求很高,而且上限比较低,采用 IO 多路复用,可以很轻易的处理上千的并发。
虽然多路复用的机制有多种,但每一种都有一些通用的我们需要关注的问题:
- 该机制监听套接字上的什么事件
- 该机制能监听多少套接字
- 当有套接字就绪时,该机制是如何找到就绪套接字的
Linux 针对每一个套接字都会有一个文件描述符(fd),也就是一个非负整数,用来唯一标识该套接字。所以,在多路复用机制的函数中,Linux 通常会用文件描述符作为参数。有了文件描述符,函数也就能找到对应的套接字,进而进行监听、读写等操作。
select
select 机制的核心函数就是 select 函数
1 2 3 4 5 6 7 8 9 10
|
int select (int __nfds, fd_set *__readfds, fd_set *__writefds, fd_set *__exceptfds, struct timeval *__timeout) 调用后 select 函数会阻塞,直到有描述符就绪(有数据 可读、可写、或者有except),或者超时(timeout 指定等待时间),函数返回。当 select 函数返回后,可以通过遍历 fdset,来找到就绪的描述符。
|
fd_set 的结构体定义是这样的:
1 2 3 4 5 6 7 8
| # define __FD_SETSIZE 1024 # define __NFDBITS 32 # define (long int) __fd_mask typedef struct { … __fd_mask __fds_bits[__FD_SETSIZE / __NFDBITS]; … } fd_set
|
所以 __fds_bits 字段其实就是 long int 的一个数组,数组中每个元素 32 位,共有 (1024/32 = 32) 个元素,每一位用来标识一个 fd,因此最多标识 1024 个 fd。
使用时,系统需要首先创建好需要监听的读事件、写事件、异常事件的描述符集合,接着调用 select 函数,阻塞等待返回就绪的描述符数目。

示例代码:
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
| int sock_fd,conn_fd; sock_fd = socket() bind(sock_fd) listen(sock_fd)
fd_set rset; int max_fd = sock_fd
FD_ZERO(&rset);
FD_SET(sock_fd,&rset);
struct timeval timeout; timeout.tv_sec = 3; timeout.tv_usec = 0; while(1) { n = select(max_fd+1, &rset, NULL, NULL, &timeout); if (FD_ISSET(sock_fd, &rset)) { conn_fd = accept(); FD_SET(conn_fd, &rset); }
for (i = 0; i < maxfd; i++) { if (FD_ISSET(i, &rset)) { } } }
|
虽然 select 函数可以一次性监听多个 socket,但是当有 socket 就绪时,需要遍历来查找就绪套接字。
poll
Poll 机制的核心函数是 poll
1 2 3 4 5 6
|
int poll (struct pollfd *__fds, nfds_t __nfds, int __timeout);
|
pollfd 的定义是这样的:
1 2 3 4 5
| struct pollfd { int fd; short int events; short int revents; };
|
Poll 机制的主要流程如下:
- 创建 pollfd 数组和监听套接字,并进行绑定
- 将监听套接字加入 pollfd 数组,并设置其监听读事件,也就是客户端的连接请求
- 循环调用 poll 函数,检测 pollfd 数组中是否有就绪的文件描述符。
- 如果是连接套接字就绪,这表明是有客户端连接,我们可以调用 accept 接受连接,并创建已连接套接字,并将其加入 pollfd 数组,并监听读事件;
- 如果是已连接套接字就绪,这表明客户端有读写请求,我们可以调用 recv/send 函数处理读写请求。
流程图:

示例代码:
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
| int sock_fd,conn_fd; sock_fd = socket() bind(sock_fd) listen(sock_fd)
#define MAX_OPEN = 2048
struct pollfd client[MAX_OPEN];
client[0].fd = sock_fd; client[0].events = POLLRDNORM; maxfd = 0;
for (i = 1; i < MAX_OPEN; i++) client[i].fd = -1;
while(1) { n = poll(client, maxfd+1, &timeout); if (client[0].revents & POLLRDNORM) { conn_fd = accept();
for (i = 1; i < MAX_OPEN; i++){ if (client[i].fd < 0) { client[i].fd = conn_fd; client[i].events = POLLRDNORM; break; } } maxfd = i; } for (i = 1; i < MAX_OPEN; i++) { if (client[i].revents & (POLLRDNORM | POLLERR)) { } } }
|
和 select 函数相比,poll 函数的改进之处主要就在于,它允许一次监听超过 1024 个文件描述符。但是当调用了 poll 函数后,我们仍然需要遍历每个文件描述符,检测该描述符是否就绪,然后再进行处理。
epoll
epoll 机制就稍微复杂些了
1 2 3 4 5 6 7 8 9 10 11 12 13
| typedef union epoll_data { ... int fd; ... } epoll_data_t;
struct epoll_event { uint32_t events; epoll_data_t data; };
|
对于 epoll 机制来说,我们则需要先调用 epoll_create 函数,创建一个 epoll 实例。这个 epoll 实例内部维护了两个结构,分别是记录要监听的文件描述符和已经就绪的文件描述符,而对于已经就绪的文件描述符来说,它们会被返回给用户程序进行处理,而不需要用户遍历查找。
epoll 机制中有以下几个重要函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
|
epoll_create
1 2 3 4 5 6 7 8 9 10 11 12
| 1. int epoll_create(int size); 创建一个 epoll 的句柄,size 用来告诉内核这个监听的数目一共有多大,在2.6.8之后这个参数就没有实际价值了,因为内核维护一个动态的队列了。
当创建好 epoll 句柄后,它就会占用一个 fd 值,在 linux下 如果查看/proc/进程id/fd/,是能够看到这个 fd 的,所以在使用完 epoll 后,必须调用 close() 关闭,否则可能导致 fd 被耗尽。当某一进程调用 epoll_create 方法时,Linux内核会创建一个 eventpoll 结构体,这个结构体中有两个成员与 epoll 的使用方式密切相关。 struct eventpoll { struct rb_root rbr; struct list_head rdlist; ... }
|
- 在使用完 epoll 后,必须调用close()关闭,否则可能导致 fd 被耗尽。
epoll_ctl
1 2 3 4 5 6 7 8 9 10 11 12
| 2. int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); 用于向内核注册新的描述符或者是改变某个文件描述符的状态。已注册的描述符在内核中会被维护在一棵红黑树上
struct eventpoll { struct rb_root rbr; struct list_head rdlist; ... }
每一个 epoll 对象都有一个独立的 eventpoll 结构体,用于存放通过 epoll_ctl 方法向 epoll 对象中添加进来的事件。这些事件都会挂载在红黑树中,如此,重复添加的事件就可以通过红黑树而高效的识别出来(红黑树的插入时间效率是lgn,其中n为树的高度)。
|
所有添加到 epoll 中的事件都会与设备(网卡)驱动程序建立回调关系,也就是说,当相应的事件发生时会调用这个回调方法。这个回调方法在内核中叫 **ep_poll_callback**,它会将发生的事件添加到 rdlist 双链表中。
epoll_wait
1 2 3 4 5 6
| 3. int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
等待 epfd 上的 io 事件,最多返回 maxevents 个事件。 通过回调函数内核会将 I/O 准备好的描述符添加到 rdlist 双链表管理,进程调用 epoll_wait() 便可以得到事件完成的描述符。参数 events 用来从内核得到事件的集合,maxevents 告之内核这个 events 有多大,参数 timeout 是超时时间(毫秒,正整数时间,0 是非阻塞,-1 永久阻塞直到事件发生)。该函数返回需要处理的事件数目,如返回 0 表示已超时。
当然epoll对文件描述符的操作有两种模式:LT(level trigger)(默认)和ET (edge trigger)。LT模式是默认模式。
|
流程图:

示例代码:
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
| int sock_fd,conn_fd; sock_fd = socket() bind(sock_fd) listen(sock_fd) epfd = epoll_create(EPOLL_SIZE);
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE);
struct epoll_event ee //监听读事件 ee.events = EPOLLIN;
ee.data.fd = sock_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ee); while (1) { n = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1); for (int i = 0; i < n; i++) { if (ep_events[i].data.fd == sock_fd) { conn_fd = accept(sock_fd); ee.events = EPOLLIN; ee.data.fd = conn_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ee); } else { ... } } }
|
JDK 的 epoll 实现可以看另一篇文章:Linux-JDK-NIO 源码分析。
对比 & 小结
select
优点
- select 目前几乎在所有的平台(POSIX)上支持,具有良好的跨平台支持。
缺点
- 单个进程能够监视的文件描述符的数量存在最大限制,它由 FD_SETSIZE 设置,默认值是1024。虽然能够修改宏定义或重新编译内核等进行修改,但可能造成效率的降低。
- fd 集合在内核被置位过,与传入的 fd 集合不同,不可重用。(内核在发现对应的 fd 有事件后,可能会直接修改 fd 的一些标志,导致 fd 与传入的时候不同,再次调用 select 前需要恢复)。
- 调用时 fd 集合需要从用户态拷贝到内核态,当 fd 很多时开销会很大。
- 需要遍历寻找发生事件的 fd 集合,当 fd 很多时开销会很大。
poll
- 优点
- 利用 pollfd 数组可以监视的文件描述符无上限,解决了 select 的缺点1。
- 每次在扫描完成后,只需要重置 pollfd 结构体中的 revents 即可复原,解决了 select 的缺点2。
- 缺点
- 调用时 pollfd 数组需要从用户态拷贝到内核态,当 fd 很多时开销会很大。
- 需要遍历 pollfd 数组寻找发生事件的 fd 集合,当 fd 很多时开销会很大。
epoll
- 优点
- 灵活,没有描述符限制。epoll 使用一个文件描述符管理多个描述符,将用户关系的文件描述符的事件存放到内核的一个事件表中,这样在用户空间和内核空间的 copy 只需一次。
- 无需遍历,当调用 epoll_wait 检查是否有事件发生时,只需要检查 eventpoll 对象中的 rdlist 双链表中是否有 epitem 元素即可。如果 rdlist 不为空,则把发生的事件复制到用户态,同时将事件数量返回给用户 。
- 缺点
- 每次只遍历活跃的 fd (如果是 LT,也会遍历先前活跃的 fd),在活跃 fd 较少的情况下就会很有优势,如果大部分 fd 都是活跃的,epoll 的效率可能还不如 select/poll(红黑树结构维护,就绪链表的复杂性)。

关于 epoll 触发模式 (LT(level trigger) 和 ET (edge trigger)。LT模式是默认模式),可以参考这篇文章:水平触发(LT)和边缘触发模式(ET)详解。