IO多路复用不同实现及对比

IO多路复用不同实现及对比

select、poll、epoll 都是用来实现 IO 多路复用的,而多路复用发展的背景就是 socket 网络通信下提升系统的并发处理能力,单纯依靠多进程或多线程来实现对硬件要求很高,而且上限比较低,采用 IO 多路复用,可以很轻易的处理上千的并发。

虽然多路复用的机制有多种,但每一种都有一些通用的我们需要关注的问题:

  • 该机制监听套接字上的什么事件
  • 该机制能监听多少套接字
  • 当有套接字就绪时,该机制是如何找到就绪套接字的

Linux 针对每一个套接字都会有一个文件描述符(fd),也就是一个非负整数,用来唯一标识该套接字。所以,在多路复用机制的函数中,Linux 通常会用文件描述符作为参数。有了文件描述符,函数也就能找到对应的套接字,进而进行监听、读写等操作。

select

select 机制的核心函数就是 select 函数

1
2
3
4
5
6
7
8
9
10
/*
@Param __nfds 监听的套接字个数
@Param __readfds 监听读事件的套接字数组
@Param __writefds 监听写事件的套接字数组
@Param __exceptfds 监听异常事件的套接字数组
@Param __timeout 本次调用的阻塞时间
*/
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 函数,阻塞等待返回就绪的描述符数目。

image-20260428102712549

示例代码:

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

//初始化rset数组,使用FD_ZERO宏设置每个元素为0
FD_ZERO(&rset);
//使用FD_SET宏设置rset数组中位置为sock_fd的文件描述符为1,表示需要监听该文件描述符
FD_SET(sock_fd,&rset);

//设置超时时间
struct timeval timeout;
timeout.tv_sec = 3;
timeout.tv_usec = 0;

while(1) {
//调用select函数,检测rset数组保存的文件描述符是否已有读事件就绪,返回就绪的文件描述符个数
n = select(max_fd+1, &rset, NULL, NULL, &timeout);

//调用FD_ISSET宏,在rset数组中检测sock_fd对应的文件描述符是否就绪
if (FD_ISSET(sock_fd, &rset)) {
//如果sock_fd已经就绪,表明已有客户端连接;调用accept函数建立连接
conn_fd = accept();
//设置rset数组中位置为conn_fd的文件描述符为1,表示需要监听该文件描述符
FD_SET(conn_fd, &rset);
}

//依次检查已连接套接字的文件描述符
for (i = 0; i < maxfd; i++) {
//调用FD_ISSET宏,在rset数组中检测文件描述符是否就绪
if (FD_ISSET(i, &rset)) {
//有数据可读,进行读数据处理
}
}
}

虽然 select 函数可以一次性监听多个 socket,但是当有 socket 就绪时,需要遍历来查找就绪套接字。

poll

Poll 机制的核心函数是 poll

1
2
3
4
5
6
/*
@Param __fds 包括套接字fd的一个结构体数组
@Param __nfds __fds 数组的元素个数
@Param __timeout 阻塞等待的超时时间
*/
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 函数处理读写请求。

流程图:

image-20260428102727020

示例代码:

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) //在套接字上进行监听,将套接字转为监听套接字

//poll函数可以监听的文件描述符数量,可以大于1024
#define MAX_OPEN = 2048

//pollfd结构体数组,对应文件描述符
struct pollfd client[MAX_OPEN];

//将创建的监听套接字加入pollfd数组,并监听其可读事件
client[0].fd = sock_fd;
client[0].events = POLLRDNORM;
maxfd = 0;

//初始化client数组其他元素为-1
for (i = 1; i < MAX_OPEN; i++)
client[i].fd = -1;

while(1) {
//调用poll函数,检测client数组里的文件描述符是否有就绪的,返回就绪的文件描述符个数
n = poll(client, maxfd+1, &timeout);
//如果监听套件字的文件描述符有可读事件,则进行处理
if (client[0].revents & POLLRDNORM) {
//有客户端连接;调用accept函数建立连接
conn_fd = accept();

//保存已建立连接套接字
for (i = 1; i < MAX_OPEN; i++){
if (client[i].fd < 0) {
client[i].fd = conn_fd; //将已建立连接的文件描述符保存到client数组
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 监听的事件类型
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
// 创建一个 epoll 的句柄(通过它操作 epoll 实例对象),size 参数已经没有实际意义了
int epoll_create(int size);

/*
对指定描述符 fd 执行 op 操作
@Param epfd epoll_create(int size)函数返回的 epoll 实例句柄
@Param op 操作标识(添加、删除、修改)
@Param fd 需要监听的那个描述符
@Param event 需要监听的描述符对应的事件
*/
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

/*
等待 epfd 上的 io 事件,最多返回 maxevents 个事件。
@Param epfd epoll_create(int size)函数返回的 epoll 实例句柄
@Param events 存放返回的就绪事件
@Param maxevents 最多返回的事件数,也是标识 events 的容量
@Param timeout 超时时间(毫秒,正整数时间,0是非阻塞,-1 永久阻塞直到事件发生)
*/
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 {
// 红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监控的事件
struct rb_root rbr;
// 双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件
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 {
// 红黑树的根节点,这棵树中存储着所有添加到epoll中的需要监控的事件
struct rb_root rbr;
// 双链表中则存放着将要通过epoll_wait返回给用户的满足条件的事件
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模式是默认模式。

流程图:

image-20260428102848504

示例代码:

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); //创建epoll实例,
//创建epoll_event结构体数组,保存套接字对应文件描述符和监听事件类型
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE);

//创建epoll_event变量
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); //调用accept()建立连接
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(红黑树结构维护,就绪链表的复杂性)。

image-20260428102947947

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


IO多路复用不同实现及对比
https://zhuwenjie0716.github.io/2026/04/28/IO多路复用不同实现及对比/
作者
Wenjie Zhu
发布于
2026年4月28日
许可协议