从 select 到 io_uring:网络 IO 多路复用到底在复用什么?
在学习网络编程时,我们经常会听到几个高频词:select、poll、epoll、io_uring。它们经常出现在"高并发""服务器性能""Redis/Nginx 为什么快"这类话题里,也经常出现在面试题里。
很多文章会直接比较它们的区别,比如 select 有 1024 限制,poll 没有这个限制,epoll 性能更好,io_uring 更现代。但如果只记这些结论,很容易停留在八股层面。真正值得理解的是:服务器到底遇到了什么问题?这些机制分别解决了什么?它们背后的编程模型有什么变化?
本文会按照网络服务器处理大量连接的演进路线,依次讲清楚阻塞 IO、IO 多路复用、select、poll、epoll 以及 io_uring。
一、服务器为什么需要 IO 多路复用
1.1 从一个简单的 TCP 服务器说起
一个最简单的 TCP 服务器大概会做几件事:
- 创建 socket。
- 绑定 IP 和端口。
- 监听客户端连接。
- 接收客户端连接。
- 读取客户端数据。
- 处理请求。
- 返回响应。
伪代码大概是这样:
c
int listen_fd = socket(...);
bind(listen_fd, ...);
listen(listen_fd, ...);
while (true) {
int client_fd = accept(listen_fd, ...);
read(client_fd, buffer, sizeof(buffer));
handle_request(buffer);
write(client_fd, response, response_len);
close(client_fd);
}
这个模型很直观,但它有一个明显问题:accept、read、write 都可能阻塞。
如果服务器正在 read 某个客户端的数据,而这个客户端迟迟不发送数据,那么服务器就会停在那里。此时即使别的客户端已经连接上来,服务器也没有机会处理。
1.2 阻塞 IO 的工作方式
默认情况下,socket 通常是阻塞的。
所谓阻塞,就是当应用程序发起一个 IO 操作时,如果这个操作暂时不能完成,线程会被挂起,直到条件满足。
比如:
c
read(fd, buf, size);
如果 fd 对应的 socket 当前没有数据可读,那么这个线程就会睡眠。等到网卡收到数据,内核协议栈处理完数据,并把数据放到 socket 接收缓冲区后,线程才会被唤醒,然后继续执行。
阻塞 IO 的好处是代码简单,坏处是线程会被 IO 等待拖住。
1.3 一个连接一个线程的问题
为了避免一个客户端阻塞整个服务器,一个常见办法是:每来一个连接,就创建一个线程处理。
伪代码如下:
c
while (true) {
int client_fd = accept(listen_fd, ...);
create_thread(handle_client, client_fd);
}
这样每个连接都有自己的线程。一个线程阻塞在 read 上,不会影响其他线程。
这个模型在连接数不多时很好理解,也很好实现。但当连接数上升到几千、几万甚至更多时,问题就出现了:
- 线程本身需要占用内存。
- 线程越多,调度成本越高。
- 线程切换会带来上下文切换开销。
- 大量线程其实并没有在计算,而是在等待网络 IO。
所以,高并发网络服务器的瓶颈往往不是 CPU 不会算,而是有太多连接处于"等数据"的状态。
1.4 高并发场景下真正稀缺的是什么
在网络服务里,一个连接的大部分生命周期可能都在等待。
客户端可能过一会儿才发送请求;服务器发送响应后,也可能继续保持长连接;某些连接可能很久才活跃一次。
如果我们给每个连接都分配一个线程,就相当于给大量"暂时没事做"的连接占用了昂贵的执行资源。
更合理的想法是:
能不能用一个线程,或者少量线程,同时管理大量连接?哪个连接真正有 IO 事件了,再去处理哪个连接。
这就是 IO 多路复用要解决的问题。
1.5 IO 多路复用要解决的核心问题
IO 多路复用的核心不是让一个线程真的在同一瞬间执行多个 IO,而是让一个线程可以等待多个 IO 对象。
它解决的问题可以概括为:
应用程序把一批 socket 交给内核监控,内核告诉应用程序哪些 socket 已经可以读、可以写,应用程序再去处理这些真正就绪的 socket。
这样,线程不需要傻傻阻塞在某一个连接上,而是可以统一等待一批连接的事件。
二、什么是 IO 多路复用
2.1 "多路"和"复用"分别指什么
"多路"指的是多个 IO 对象。在 Linux 里,常见的 IO 对象会被抽象成文件描述符,也就是 fd。网络 socket 是 fd,普通文件也是 fd,管道、eventfd、timerfd 等也可以是 fd。
"复用"指的是复用一个或少量线程去等待和处理这些 fd 上的 IO 事件。
所以 IO 多路复用可以理解为:
一个线程同时等待多个 fd,只处理已经就绪的 fd。
它的基本结构通常是这样:
c
while (true) {
ready_events = wait_for_events(fds);
for (event in ready_events) {
handle_event(event.fd);
}
}
这里的 wait_for_events 可以是 select、poll、epoll_wait 等。
2.2 文件描述符、socket 与内核等待队列
在 Linux 中,应用程序操作 socket 时,拿到的是一个整数 fd。
这个 fd 背后对应的是内核里的文件对象,而 socket 文件对象又关联着协议栈、接收缓冲区、发送缓冲区等结构。
当应用程序调用 read(fd) 时,如果当前没有数据可读,线程会进入睡眠状态。内核会把这个线程挂到相应的等待队列上。等数据到达后,内核再唤醒等待的线程。
IO 多路复用的关键就在这里:
它不是让应用程序自己不断尝试每个 fd,而是让内核帮忙管理这些等待关系。
2.3 就绪通知:可读、可写、异常
IO 多路复用关注的是"就绪事件"。
常见事件包括:
- 可读:socket 接收缓冲区里有数据,或者对端关闭连接。
- 可写:socket 发送缓冲区有空间,可以继续写入数据。
- 异常:出现错误、连接异常等特殊情况。
这里要注意,"可读"不一定代表能读到正常业务数据。比如对端关闭连接时,fd 也会被认为可读,此时 read 可能返回 0。
"可写"也不代表一定要一直监听。大多数情况下,socket 通常是可写的。如果所有连接都一直监听可写事件,可能会导致大量无意义的事件通知。实际工程中通常是在发送缓冲区写不完时,才关注可写事件。
2.4 IO 多路复用的基本编程模型
一个典型的 IO 多路复用服务器流程如下:
c
while (true) {
events = wait();
for (event in events) {
if (event.fd == listen_fd) {
client_fd = accept(listen_fd);
add_to_event_loop(client_fd);
} else if (event.readable) {
n = read(event.fd, buffer, sizeof(buffer));
if (n > 0) {
handle_request(event.fd, buffer);
} else {
close(event.fd);
}
} else if (event.writable) {
write_response(event.fd);
}
}
}
这个模型有两个重点:
- 监听 socket 也可以放进事件循环。
- 客户端 socket 的读写事件也可以放进事件循环。
这样,一个线程就可以同时处理新连接和已有连接的数据收发。
2.5 它和多线程、异步 IO 的区别
IO 多路复用不是多线程。
多线程关注的是多个执行流并发运行。IO 多路复用关注的是一个执行流等待多个 IO 事件。
IO 多路复用也不完全等同于异步 IO。
以 epoll 为例,它告诉应用程序:"这个 fd 已经可以读了。"但真正的 read 仍然是应用程序自己调用的。也就是说,epoll 是就绪通知,而不是完成通知。
真正的异步 IO 更接近于:"应用提交一个读请求,内核完成读取后通知应用结果。"后面要讲的 io_uring 就更接近这种思路。
三、select:最早的通用 IO 多路复用方案
3.1 select 的基本使用方式
select 的核心接口大概如下:
c
int select(
int nfds,
fd_set *readfds,
fd_set *writefds,
fd_set *exceptfds,
struct timeval *timeout
);
它允许应用程序传入三类 fd 集合:
- 关注可读事件的 fd。
- 关注可写事件的 fd。
- 关注异常事件的 fd。
select 返回后,传入的集合会被修改,只保留已经就绪的 fd。
3.2 fd_set 是如何表达多个 fd 的
fd_set 本质上可以理解为一个位图。
如果要监听 fd 3,就把第 3 位设置为 1;如果要监听 fd 10,就把第 10 位设置为 1。
常见操作包括:
c
FD_ZERO(&readfds);
FD_SET(fd, &readfds);
FD_ISSET(fd, &readfds);
FD_CLR(fd, &readfds);
这种设计比较简单,但也带来了一个问题:位图大小是有限的。在很多系统中,FD_SETSIZE 默认是 1024。这就是我们常说的 select 有 fd 数量限制。
3.3 select 的执行流程
一次 select 调用大致会经历这些步骤:
- 应用程序准备 fd 集合。
- 调用
select,从用户态进入内核态。 - 内核检查这些 fd 是否已经就绪。
- 如果没有 fd 就绪,当前线程睡眠。
- 当某些 fd 就绪,内核唤醒线程。
select返回,并修改 fd 集合。- 应用程序遍历 fd 集合,找出哪些 fd 就绪。
- 应用程序对就绪 fd 执行读写。
这里有一个重要点:select 返回后,应用程序还需要自己遍历 fd 集合。
3.4 select 解决了什么问题
select 最大的价值是:它让一个线程可以等待多个 fd。
在没有 IO 多路复用时,一个线程如果阻塞在某个 fd 的 read 上,就无法同时等待别的 fd。select 让应用可以把多个 fd 放进一个集合,由内核统一等待。
所以 select 解决的是"能不能同时等多个 IO 对象"的问题。
3.5 select 的局限
select 的主要局限有几个。
第一,fd 数量有限。
由于 fd_set 通常有固定大小,select 能处理的 fd 数量会受到限制。
第二,每次调用都要重新传递 fd 集合。
因为 select 会修改传入的 fd 集合,所以应用程序通常需要保留一份原始集合,每次调用前重新拷贝。
第三,内核需要线性扫描。
内核需要检查传入的 fd 集合,判断哪些 fd 就绪。如果 fd 很多,这个扫描成本就会变高。
第四,应用程序也需要线性扫描。
select 返回后,应用程序并不知道具体哪些 fd 就绪,只能通过 FD_ISSET 一个个检查。
因此,select 在连接数量很大、活跃连接很少的场景下效率并不好。
3.6 select 适合什么场景
select 适合 fd 数量较少、对跨平台兼容性要求较高、性能压力不大的场景。
如果只是写一个简单工具、小型服务、教学示例,select 仍然很直观。但如果要写高并发 Linux 网络服务,就很少会优先选择它。
四、poll:对 select 的一次改进
4.1 poll 的基本使用方式
poll 的接口大致如下:
c
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
其中 pollfd 结构通常包含:
c
struct pollfd {
int fd;
short events;
short revents;
};
events 表示应用程序关心的事件,revents 表示返回时实际发生的事件。
4.2 pollfd 数组相比 fd_set 的变化
相比 select 的位图,poll 使用数组。
应用程序把所有要监听的 fd 放到一个 pollfd 数组里:
c
struct pollfd fds[MAX_CLIENTS];
fds[0].fd = listen_fd;
fds[0].events = POLLIN;
这种方式比 fd_set 更灵活,因为它不再依赖固定大小的位图,也没有 FD_SETSIZE 那种默认限制。
4.3 poll 解决了 select 的哪些问题
poll 主要解决了两个问题。
第一,突破了 select 的固定 fd 数量限制。
只要内存允许,pollfd 数组可以更大。
第二,事件表达更清晰。
每个 fd 关注什么事件、实际发生什么事件,都在同一个结构里表达,比 select 的多个 fd 集合更直观。
4.4 poll 没有解决的核心问题
虽然 poll 改进了接口,但它没有改变核心机制。
每次调用 poll 时,应用程序仍然要把整个 fd 数组传给内核。内核仍然要遍历这个数组,检查每个 fd 是否就绪。poll 返回后,应用程序仍然要遍历数组,查看每个元素的 revents。
所以 poll 的问题可以概括为:
它解决了
select的 fd 数量限制,但没有解决大量 fd 下的线性扫描问题。
4.5 为什么 poll 仍然不适合超大规模连接
假设服务器维护了 10 万个连接,但某一时刻只有 100 个连接真正有数据。
使用 poll 时,每次等待都可能要扫描这 10 万个连接。返回后,应用程序还要再扫描一遍,找出那 100 个活跃连接。
这就很浪费。
高并发网络服务常见的特点是:连接很多,但同一时刻活跃的连接只占一小部分。poll 的线性扫描成本会随着总连接数增长,而不是随着活跃连接数增长。
这正是 epoll 要重点解决的问题。
五、epoll:Linux 高并发网络服务的关键机制
5.1 epoll 的三个核心接口
epoll 是 Linux 提供的高性能 IO 多路复用机制。它常用的三个接口是:
c
int epoll_create1(int flags);
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_create1:创建一个 epoll 实例。epoll_ctl:向 epoll 实例中添加、修改、删除 fd。epoll_wait:等待已经就绪的事件。
5.2 fd 集合常驻内核:为什么这是关键变化
select 和 poll 每次调用时,都要把要监听的 fd 集合传给内核。
epoll 的思路不同。应用程序先通过 epoll_ctl 把 fd 注册到内核里的 epoll 实例中。之后每次调用 epoll_wait 时,不需要重复传递完整 fd 集合。
这带来了一个重要变化:
fd 集合从"每次调用都传一遍",变成了"注册一次,长期由内核维护"。
对于大量长连接来说,这一点非常关键。
5.3 就绪队列:epoll 为什么不用每次全量扫描
epoll 的另一个关键点是就绪队列。
当某个 fd 上发生事件时,内核会把这个 fd 对应的事件放入 epoll 的就绪队列。应用程序调用 epoll_wait 时,可以直接从这个就绪队列中拿到已经发生的事件。
这意味着,应用程序拿到的是"已经就绪的 fd",而不是一个需要自己重新筛选的大集合。
所以,在大量连接但少量活跃的场景下,epoll 的效率会明显优于 select 和 poll。
5.4 水平触发 LT:更安全的默认模式
epoll 支持两种常见触发模式:水平触发和边缘触发。
水平触发的英文是 Level Triggered,简称 LT。
它的特点是:只要 fd 上的事件条件仍然满足,epoll_wait 就会持续通知。
比如 socket 接收缓冲区里有 100 字节数据,应用程序这次只读了 50 字节,那么下次 epoll_wait 仍然会通知这个 fd 可读。
LT 模式更接近 select、poll 的行为,也更安全,因为即使一次没有把数据读完,后面仍然会继续收到通知。
5.5 边缘触发 ET:更高效但更容易写错
边缘触发的英文是 Edge Triggered,简称 ET。
它的特点是:只有状态发生变化时才通知。
比如一个 socket 从"没有数据可读"变成"有数据可读",内核通知一次。如果应用程序没有把数据读完,只要没有新的状态变化,可能就不会再次通知。
因此,ET 模式通常要求配合非阻塞 IO 使用,并且读写时要一直处理到返回 EAGAIN 或 EWOULDBLOCK。
典型读法如下:
c
while (true) {
n = read(fd, buffer, sizeof(buffer));
if (n > 0) {
handle_data(buffer, n);
} else if (n == -1 && errno == EAGAIN) {
break;
} else {
close(fd);
break;
}
}
ET 模式可以减少重复通知,但对代码正确性要求更高。写错时容易出现连接明明有数据,却再也收不到通知的问题。
5.6 epoll 与非阻塞 IO 的关系
epoll 通常会和非阻塞 socket 一起使用。
原因是:即使 epoll 告诉你某个 fd 可读,也不代表你后续每一次 read 都一定不会阻塞。尤其是在多线程、多进程或者边缘触发模式下,如果使用阻塞 fd,就可能让事件循环卡住。
设置非阻塞后,如果数据暂时读完了,read 会返回 -1,并设置 errno 为 EAGAIN 或 EWOULDBLOCK。应用程序知道当前不能继续读,就可以回到事件循环等待下一次事件。
5.7 epoll 的典型事件循环
一个简化版的 epoll 服务器事件循环如下:
c
int epfd = epoll_create1(0);
add_fd(epfd, listen_fd, EPOLLIN);
while (true) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
int client_fd = accept(listen_fd, ...);
set_nonblocking(client_fd);
add_fd(epfd, client_fd, EPOLLIN);
} else if (events[i].events & EPOLLIN) {
read_and_handle(fd);
} else if (events[i].events & EPOLLOUT) {
write_pending_data(fd);
}
}
}
这个模型就是很多高性能网络服务的基础。
5.8 epoll 适合什么场景
epoll 非常适合 Linux 下的大量长连接场景,比如:
- Web 服务器。
- 反向代理。
- IM 长连接服务。
- 网关服务。
- Redis 这类事件驱动服务器。
它尤其适合"连接很多,但同一时刻活跃连接相对较少"的场景。
六、select、poll、epoll 的对比
6.1 三者的共同点
select、poll、epoll 都属于 IO 多路复用。
它们的共同点是:
应用程序把多个 fd 交给内核等待,内核返回其中已经就绪的 fd,然后应用程序自己执行读写。
所以它们本质上都是"就绪通知"机制。
6.2 fd 集合管理方式对比
| 机制 | fd 集合管理方式 |
|---|---|
| select | 每次调用传入 fd_set |
| poll | 每次调用传入 pollfd 数组 |
| epoll | 通过 epoll_ctl 注册到内核,长期维护 |
epoll 的优势首先来自 fd 集合管理方式的变化。它不需要每次等待事件时都重新提交完整 fd 集合。
6.3 内核扫描与事件返回方式对比
| 机制 | 内核处理方式 | 返回后应用程序是否要扫描 |
|---|---|---|
| select | 扫描 fd_set | 需要 |
| poll | 扫描 pollfd 数组 | 需要 |
| epoll | 维护就绪队列 | 通常只遍历就绪事件 |
select 和 poll 的成本更多和总连接数相关。epoll 的返回结果更接近活跃连接集合。
6.4 性能差异到底来自哪里
epoll 不是在所有场景下都一定比 select、poll 快。
如果只监听十几个 fd,三者差异可能并不明显。epoll 本身也有注册、维护内核数据结构的成本。
epoll 的优势主要体现在:
- fd 数量很大。
- 大多数 fd 不活跃。
- 连接生命周期较长。
- 应用需要长期反复等待同一批 fd。
这类场景正是高并发网络服务器的典型场景。
6.5 什么时候 select/poll 可能效率比epoll更高
如果只是写简单工具、管理少量 fd,select 或 poll 可能效率比epoll更高,因为epoll还要依赖去创建红黑树和就绪链表。
6.6 为什么 epoll 成为 Linux 网络编程主流
Linux 高并发网络服务通常面对大量连接,而这些连接并不总是活跃。
epoll 通过内核维护 fd 集合和就绪队列,减少了重复传递和重复扫描的成本,因此非常适合这类场景。
七、io_uring:从"等待就绪"到"提交请求"
7.1 io_uring 解决的不是同一个层次的问题
io_uring 是 Linux 中5.1内核最新的异步 IO 机制。它经常被拿来和 epoll 比较,但严格来说,它们解决的问题不完全在同一个层次。
epoll 关心的是:
哪些 fd 已经可以读写?
io_uring 关心的是:
我提交的 IO 请求哪些已经完成?
前者是就绪通知,后者是完成通知。
7.2 Submission Queue 与 Completion Queue
io_uring 的核心是两个环形队列:
- Submission Queue,简称 SQ,提交队列。
- Completion Queue,简称 CQ,完成队列。
应用程序把要执行的 IO 请求放进 SQ。内核从 SQ 中取出请求并执行。执行完成后,内核把完成结果放进 CQ。应用程序再从 CQ 中读取完成事件。
这个结构让应用和内核可以通过共享内存队列交换任务和结果,从而减少系统调用和数据结构传递成本。
7.3 io_uring 的基本工作流程
一个简化的 io_uring 工作流程如下:
- 应用程序初始化 io_uring。
- 应用程序准备一个或多个 IO 请求。
- 应用程序把请求提交到 SQ。
- 内核执行这些请求。
- 请求完成后,内核把结果写入 CQ。
- 应用程序读取 CQ,处理完成结果。
伪代码可以写成:
c
while (true) {
submit_read_request(fd, buffer);
submit_write_request(fd, response);
completions = wait_completions();
for (cqe in completions) {
handle_completion(cqe);
}
}
这和 epoll 的事件循环很不一样。
7.4 epoll 是通知"可以读写",io_uring 是通知"已经完成"
这句话是理解两者区别的关键:
epoll告诉你:"这个 socket 可以读了。"
io_uring告诉你:"你刚才提交的读请求已经完成了,结果在这里。"
使用 epoll 时,读写动作仍然由应用程序主动发起:
c
events = epoll_wait(...);
read(fd, buffer, size);
使用 io_uring 时,应用程序提交的是 IO 操作本身:
c
submit_read(fd, buffer, size);
wait_completion();
所以,io_uring 的抽象层次更进一步。
7.5 io_uring 在网络 IO 中的使用方式
早期 io_uring 更多被关注在文件 IO 上,因为传统 Linux AIO 对普通文件支持并不理想。
随着能力增强,io_uring 也可以用于网络 IO,比如:
- 异步
accept。 - 异步
recv。 - 异步
send。 - 批量提交多个网络操作。
- 批量获取多个完成事件。
对于高性能网络服务来说,io_uring 的吸引力在于减少系统调用次数,并把多个操作批量化处理。
7.6 io_uring 的优势
io_uring 的优势主要包括:
- 减少系统调用次数。
- 支持批量提交请求。
- 支持批量获取完成结果。
- 用户态和内核态通过共享队列协作。
- 能统一处理多类异步操作。
在一些场景下,应用程序可以一次提交多个 IO 请求,然后一次收割多个完成事件。这比每个 IO 都单独陷入内核更高效。
7.7 io_uring 的复杂度与适用边界
io_uring 很强,但它并不意味着 epoll 立刻过时。
原因有几个:
io_uring编程模型更复杂。- 对 Linux 内核版本有要求。
- 不同操作的支持程度与内核版本有关。
- 生态成熟度和排障经验不如
epoll长久。 - 对普通业务服务来说,性能瓶颈未必在这里。
因此,io_uring 更适合对性能要求极高、愿意处理复杂性的系统软件、存储系统、网络框架和基础设施服务。
对于大多数 Linux 网络服务来说,epoll 仍然是非常成熟、稳定、可靠的选择。