1. 项目概述
最近在整理网络编程相关的技术笔记,把 select、poll、epoll 这三种 I/O 多路复用机制从头到尾拉了一遍。这几个东西在面试题里是老面孔了,但是我发现很多同行对它们的理解停留在“select 有 1024 个描述符限制、epoll 性能最好”这种表面结论上。一旦聊到底层实现逻辑、epoll 的 EPOLLLT 和 EPOLLET 具体差异、poll 在大规模文件描述符场景下的性能拐点,很多人就开始含糊了。
我最初接触这些概念是在做一个网关服务的时候,需要同时处理上千条 TCP 连接,当时只知道用 select 写循环,结果连接数一涨就卡得不行。后来慢慢把 poll 和 epoll 都捡起来研究,又翻了不少内核源码和网络协议栈的资料,才真正把这几者之间的演进逻辑串通。这次整理的目的,是希望以“底层实现 + 性能特征”为主线,把这三兄弟的基本原理、触发方式、实践场景和常见坑一次理清楚。
这篇文章适合正在学网络编程的初学者,也适合写过一些并发服务但没仔细研究过底层机制的开发者。关键词是 select、poll、epoll、高性能网络编程,我会尽量把源码级别的行为和用户态的实践经验结合起来讲,顺便把我在实际项目里踩过的坑也交代一遍。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三种 I/O 多路复用模型的设计思路
2.1 从阻塞 I/O 到多路复用的演进逻辑
先聊个最基本的场景。早期网络服务最直观的写法是来一个连接就开一个线程去处理。客户端连接不多的时候没问题,但连接量上来之后内存、线程切换的开销都很恐怖。一个线程默认栈空间 8MB,你开 1 万个线程就是 80GB 的虚拟内存,机器直接扛不住。
于是就有了 I/O 多路复用:把多个连接的文件描述符(fd)集中起来,用一个线程同时监听。谁的 fd 可读、可写,就优先处理谁。这样就不需要“一连接一线程”了,一个线程可以管几千甚至几万个连接。
select 是这个思想最早的具体实现,它是 1983 年左右出现在 BSD 系统里的,至今所有主流操作系统都还带着这个接口。这个 API 的基本模型是:用户把要监听的 fd 集合传给内核,内核通过轮询扫描这批 fd 的状态变化,再把结果集合返回给用户。这个“扫描”动作,恰恰是后续所有性能问题的根源。
2.2 select 的实现机制与天然瓶颈
select 的核心数据结构是 fd_set,本质上是一个位图数组。内核层面通过 do_select 循环遍历 __FD_SET 中描述的所有 fd,把处于就绪状态的加入结果集合。这个过程的时间复杂度是 O(n),其中 n 是监听的文件描述符总数。
有一个非常著名的限制是 FD_SETSIZE,通常被定义成 1024。也就是说 select 能管理的 fd 上限是 1024,想要扩大只能重新编译内核或者修改这个宏。但这个限制还只是表层的,真正要命的是两个问题:
第一个,每次调用 select 都必须把用户态的 fd 集合整体拷贝到内核态。如果 fd 数量大,这个拷贝时间不可忽略。第二个,内核返回后,用户态依然要再次遍历整个 fd 集合,才找得出究竟哪些 fd 就绪了。全量遍历 + 两次拷贝,导致 select 在处理大并发连接时效率直线下降。
不过话说回来,如果只是几十上百个连接,select 是完全够用的。早期很多开源软件比如旧版 nginx 都直接支持 select 作为事件驱动模块。
2.3 poll 的改进与残余问题
poll 是在 System V 的体系里演化出来的,它把 fd_set 位数图换成了 pollfd 结构数组:
c复制struct pollfd {
int fd; // 文件描述符
short events; // 监听的时间掩码:POLLIN、POLLOUT 等
short revents; // 返回的就绪结果
};
这个改变有两个好处:第一,不再受 FD_SETSIZE 限制,理论上可以监听任意数量的 fd;第二,每次只需把整个数组传给内核,用户态可以通过 revents 判断哪些事件发生,不用反复构造和拷贝位图。
但 poll 的核心缺陷还在:它每次都要把所有 fd 传入内核、内核再遍历所有 fd、用户态再遍历所有 fd。这个 O(n) 的遍历是免不了的,n 大了依旧不给力。还有一个隐藏的问题,pollfd 数组中如果存在无用的 fd,poll 并不会自动剔除,你得自己在用户态维护这个数组的有效性,不然每次都把一坨无效节点塞给内核,白白消耗性能。
我在当时实际测过:poll 在单机连接数达到 5000 上下时,CPU 占用率已经开始明显上升,但纯从响应延迟角度看,并没有到不能用的程度,这就是“扇面宽但未塌”的阶段。
2.4 epoll 的事件驱动式革命
epoll 是 Linux 内核 2.6 版本引入的事件驱动框架,把复杂度从“调用时全量扫描”变成“事件到达时主动回调”。
在 epoll 内部,通过三个系统调用完成注册、等待、修改:
epoll_create创建一个epoll实例,内部生成一个eventpoll对象,底层是一个红黑树 + 一个双向链表;epoll_ctl注册新的 fd 到红黑树中,或者修改已有 fd 的事件类型;epoll_wait等待事件就绪,返回给用户态时只需拷贝就绪事件列表,而不再是全量 fd 集合。
这个设计带来了两个关键性能差异:
- 内核不需要每次遍历全部 fd,而是只负责把就绪节点加入双向链表中。新的活动连接仅影响就绪链表,不存在全量扫描。
- 用户态拿到的集合是有事件发生的 fd,数量远小于监听总量,处理时间取决于“真正活跃的连接数”,而不是“连接总数”。
很多资料写 epoll 是“O(1) 复杂度”,准确说是等待事件时与连接总数无关,只与活跃事件数相关。内核通过 ep_poll_callback 把发生了事件的节点挂到就绪队列,epoll_wait 只是把这个队列拷贝到用户空间。
这套机制下,应付 C10K(单机 1 万并发)是比较轻松的,实测在连接数 10 万级别、活跃事件 1% 左右时,epoll 的表现比 poll 能有接近两个数量级的吞吐差距。
3. 核心机制拆解:从源码行为到用户态表现
3.1 select 的判定流程与 FD_ISSET 细节
如果要在用户态直接用 select,最典型的代码流程是:
c复制fd_set read_fds;
struct timeval timeout = {0, 0};
FD_ZERO(&read_fds);
FD_SET(listen_fd, &read_fds);
int ready = select(listen_fd + 1, &read_fds, NULL, NULL, &timeout);
if (FD_ISSET(listen_fd, &read_fds)) {
// 有客户端连接进来
}
这里有一个裸奔级易错点:select 会原地修改传入的 fd_set,内核会把就绪 fd 的对应位置位,把未就绪的位清空。所以!调用之前一定要先复制一份原始 fd_set,不然下一轮循环就找不到要监听哪些连接了。
另一个坑是 select 的第一个参数是最大 fd 值 + 1。很多人误以为这是 fd 的总数,实际内核是用这个值来确定扫描范围从哪开始。如果你监听的是 fd 3、fd 100,最大 fd 是 100,第一个参数必须传 101。如果偷懒传成实际 fd 数量,内核会漏扫高编号 fd,导致你的事件永远不出结果。
还有个细节藏在 timeout 上。select 返回后,内核同样会修改 timeout 结构体,把它剩余等待时间写进去。所以在循环里必须每次重新给 timeout 赋值,否则到后面会出现立即超时的假象。
3.2 poll 的 events 与 revents 的编码技巧
poll 的用法比 select 稍微舒服一点,因为每个 fd 带上了自己的事件掩码,而且结果状态是独立存到 revents 字段里的,不需要破坏原来的 events。但这里有一个容易搞错的地方:POLLIN、POLLOUT、POLLERR 这些事件不是互斥的,真实网络环境下 revents 里可能同时带多个位,你写判断时既不能只等于 POLLIN,也不能漏掉 POLLERR|POLLHUP 这种异常组合。
我实际遇到过一个情况:客户端突然断网,poll 返回后 revents 同时包含 POLLIN 和 POLLERR。如果代码只处理 POLLIN,去调 read() 读到 0 字节,那还能判断出对方关闭;如果只处理 POLLOUT 退避,就会一直盲目往一个已断开连接写数据,触发 SIGPIPE 导致进程退出。
所以完整的 poll 处理逻辑应当长这样:
c复制if (fds[i].revents & POLLIN) {
// 可能有数据,也可能连接断开,需要 read 后根据返回结果判断
}
if (fds[i].revents & (POLLERR | POLLNVAL)) {
// 出错或者 fd 无效,必须显式处理
}
if (fds[i].revents & POLLHUP) {
// 对端挂断,不代表 fd 不能写,但通常需要主动关闭
}
3.3 epoll 的红黑树、就绪队列与两种触发模式
epoll 的强大在于对 fd 的增删改查都是基于红黑树完成的,增删改都是 O(log n),然后就绪事件以链表队列形式存在,epoll_wait 只拷贝就绪节点。
epoll 支持两种触发模式:
电平触发模式(Level Triggered,LT) 是默认模式。只要 fd 还有数据可读,每次 epoll_wait 都会把这个 fd 返回给你。这种模式下你不用担心漏掉事件,但工作量大,每次都通知,效率相对低。如果数据没读完,下次还会继续通知,直到你把数据读完。
边缘触发模式(Edge Triggered,ET) 则比较激进。它只在状态变化时通知一次。比如接收缓冲区里来了一段数据,epoll_wait 会通知一次,但如果你这次没有把所有数据都读完,下次不会再通知你了,除非又有新数据到来。
这直接导致 ET 模式必须使用非阻塞 socket,并且要把数据读到 EAGAIN 为止,否则很容易出现“数据明明还有,但事件已经不再触发”的卡死现象。
从实际项目角度看,LT 适合连接数不是特别大、逻辑简单、追求稳定可控的场景;ET 适合追求高吞吐、连接数极多的场景,但代价是你必须小心处理“读干净缓冲区”这件事。很多人拿 epoll 就说性能好,其实坑全在 ET 的使用方式上。
3.4 内核态与用户态之间的数据拷贝差异
对比三种机制,数据拷贝路径是一个容易忽略的点。
select要把整个fd_set拷入、拷出,每次都会全部搬移;poll要把整个pollfd数组拷入、拷出,每次也是全量;epoll只有epoll_ctl增删改时发生单节点级别的拷贝,epoll_wait返回时只会拷贝就绪节点的内容。
这个差异在大连接场景下非常致命。因为你监听的 fd 总量越大,select 和 poll 每次调用要搬运的数据就越庞大,而冲着 epoll 去的时候,用户态拿到的是精简后的结果集,数据量小、拷贝快。
我记得有一个压测数据:同样监听 5 万个连接,活跃连接 1000 个,select 光在内核态和用户态之间拷贝 fd 位图就花了接近 1 毫秒,而 epoll 拷贝就绪列表只花了 10 微秒左右,差距接近两个数量级。
4. 性能对比:数据、场景与实践经验
4.1 不同连接规模下的纵向对比
为了让大家有个直观认识,我简单整理了一份不同模型在不同连接规模下的典型表现:
| 模型 | 100 连接 | 1000 连接 | 10000 连接 | 单连接事件延迟 | 备注 |
|---|---|---|---|---|---|
select |
很好 | 开始吃力 | 不可用 | 高 | fd 上限 1024 |
poll |
很好 | 可用 | 明显卡顿 | 中 | 线性扫描无上限 |
epoll(LT) |
很好 | 很好 | 稳定 | 低 | 适合大多数服务 |
epoll(ET) |
很好 | 很好 | 优秀 | 最低 | 编码复杂度高 |
这个表不是绝对的,核心变量是活跃连接的比例。连接总数大但活跃的只有几百个,epoll 优势极其明显;如果所有连接每秒都有大量数据读写,活跃连接非常多,那么 poll 和 epoll 的差距会缩小一些,但 epoll 在系统调用次数和拷贝量上依然占优。
4.2 从 C10K 到 C1000K 的挑战
那为什么真正支撑起高并发流量的模块几乎清一色 epoll?我用一个具体场景解释。
假设要做一个推送网关,保持 10 万条长连接。如果每条连接每 5 秒收到一次心跳,那么每秒钟大概有 2 万个连接处于活跃状态。poll 的工作模式是:你把这 10 万个 fd 全部传给内核,内核遍历完 10 万个后只发现 2 万个活跃,返回;然后用户态还要再遍历 10 万个才能找到这 2 万个。
而 epoll 的工作模式是:10 万个 fd 早就注册在红黑树中了,每次 epoll_wait 只需要把就绪链表上的 2 万个活跃节点拷出来。省掉了两个 10 万级的全量遍历,这就是数量级上的差距。
走到 C1000K(单机 100 万连接)这个级别,select 和 poll 基本可以排除出局了,只有 epoll 配合正确的数据结构和内存规划,才可能在一台物理机上扛住百万级别的连接。当然这还牵扯到单进程可打开的 fd 数量限制、内存分配策略、收发包软中断瓶颈等,已经超出 I/O 多路复用模型本身的讨论,但选型方向一定是 epoll。
4.3 真实项目中的选择建议
在真实项目里,我并不会无条件推荐 epoll。
如果你的场景比较简单:连接数很少(一两百以内)、逻辑以短连接为主、代码需要跨平台跑(Windows 上根本没有 epoll,你得用 select 或者 IOCP),那老老实实用 select 完全没问题。配合 timeout 定时任务,甚至能写出非常简洁可靠的事件循环。
如果你预计单机连接会到几千甚至上万,那 poll 是一个过渡方案,它的结构更直观,调试起来好排查问题。但说实话,现在主流 Linux 服务器上已经很少看到新项目从 poll 起步了,基本都是直接 epoll。
epoll 真正吃经验的点在于:调试期不好追踪“为什么某个事件没触发”,因为事件是异步回调式通知,不像 select / poll 那样你可以在返回后挨个检查状态位。我踩过的坑是,某个 fd 直接注册进了 epoll,数据到的时候没有触发事件,查了半天发现是边缘触发模式下早期数据没读完,后面一直等不到新事件。
5. 实操过程:从零实现一个区分三种模型的回声服务
5.1 环境准备与基础 socket 服务框架
为了更直白地展示底层差异,我写了一个最小化的回声服务,分别用 select、poll、epoll 实现同一个功能:接收客户端连接,把客户端发来的数据原样返回。
环境非常简单,Linux 系统 + gcc 即可,不需要第三方库。先定义服务器的基本骨架:
c复制// server.c 通用结构
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define PORT 8888
#define BACKLOG 128
int create_server_fd() {
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd < 0) {
perror("socket");
exit(1);
}
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(PORT);
addr.sin_addr.s_addr = htonl(INADDR_ANY);
if (bind(lfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("bind");
exit(1);
}
if (listen(lfd, BACKLOG) < 0) {
perror("listen");
exit(1);
}
return lfd;
}
这个公共部分体现出几个关键点:SO_REUSEADDR 必须在 bind 之前设置,不然服务重启后端口会被 TIME_WAIT 状态的旧连接占用,导致的典型报错是 Address already in use。
5.2 用 select 实现回声服务的完整代码
select 版本的核心是维护一个活跃连接的数组,每次循环调用 select 前重新设置位图,把无用的 fd 从数组中清除:
c复制void select_echo(int lfd) {
fd_set master_fds, read_fds;
FD_ZERO(&master_fds);
FD_SET(lfd, &master_fds);
int max_fd = lfd;
int client_fds[FD_SETSIZE];
memset(client_fds, -1, sizeof(client_fds));
while (1) {
read_fds = master_fds;
struct timeval timeout = {5, 0};
int ret = select(max_fd + 1, &read_fds, NULL, NULL, &timeout);
if (ret < 0) {
perror("select");
continue;
}
if (ret == 0) {
continue; // 超时
}
if (FD_ISSET(lfd, &read_fds)) {
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int cfd = accept(lfd, (struct sockaddr*)&cli_addr, &cli_len);
if (cfd < 0) perror("accept");
else {
FD_SET(cfd, &master_fds);
if (cfd > max_fd) max_fd = cfd;
for (int i = 0; i < FD_SETSIZE; i++) {
if (client_fds[i] == -1) {
client_fds[i] = cfd;
break;
}
}
}
}
// 检查每个客户端
for (int i = 0; i < FD_SETSIZE; i++) {
if (client_fds[i] == -1) continue;
int cfd = client_fds[i];
if (FD_ISSET(cfd, &read_fds)) {
char buf[1024];
int n = read(cfd, buf, sizeof(buf));
if (n <= 0) {
// 读失败或对端关闭
close(cfd);
FD_CLR(cfd, &master_fds);
client_fds[i] = -1;
} else {
write(cfd, buf, n); // 原样返回
}
}
}
}
}
这个小例子虽然简单,但已经把 select 的两个特征暴露出来了:
- 维护
master_fds与临时read_fds时必须区分开,不然 select 一次之后位图被破坏,下次就寿终正寝了; - 用户态全量遍历
client_fds是不得不付出的代价,连接越多越吃 CPU。
5.3 用 poll 实现回声服务的代码对比
poll 版本只需要维护一个动态 pollfd 数组即可:
c复制void poll_echo(int lfd) {
int capacity = 1024;
struct pollfd* fds = calloc(capacity, sizeof(struct pollfd));
fds[0].fd = lfd;
fds[0].events = POLLIN;
int nfds = 1;
while (1) {
int ret = poll(fds, nfds, -1);
if (ret < 0) {
perror("poll");
continue;
}
if (fds[0].revents & POLLIN) {
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int cfd = accept(lfd, (struct sockaddr*)&cli_addr, &cli_len);
if (cfd < 0) perror("accept");
else {
if (nfds >= capacity) {
capacity *= 2;
fds = realloc(fds, capacity * sizeof(struct pollfd));
}
fds[nfds].fd = cfd;
fds[nfds].events = POLLIN;
nfds++;
}
}
for (int i = 1; i < nfds; i++) {
if (fds[i].fd < 0) continue;
if (fds[i].revents & POLLIN) {
char buf[1024];
int n = read(fds[i].fd, buf, sizeof(buf));
if (n <= 0) {
close(fds[i].fd);
fds[i].fd = -1;
} else {
write(fds[i].fd, buf, n);
}
}
if (fds[i].revents & (POLLERR | POLLHUP | POLLNVAL)) {
close(fds[i].fd);
fds[i].fd = -1;
}
}
}
}
注意 poll 返回值只是就绪连接的数量,并不会告诉你是哪个 fd 就绪了,所以用户态还是得遍历整个 fds 数组。这个“使用者级别的 O(n)”是没法绕开的,即便 poll 在 fd 数量上不再受限,每次事件循环的 CPU 开销依然跟总连接数线性相关。
5.4 用 epoll 实现回声服务:LT 与 ET 写法差异
先看 LT 模式,相对简单很多:
c复制void epoll_lt_echo(int lfd) {
int epfd = epoll_create(1);
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[1024];
while (1) {
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; i++) {
if (events[i].data.fd == lfd) {
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int cfd = accept(lfd, (struct sockaddr*)&cli_addr, &cli_len);
ev.events = EPOLLIN;
ev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
} else {
int cfd = events[i].data.fd;
char buf[1024];
int rn = read(cfd, buf, sizeof(buf));
if (rn > 0) {
write(cfd, buf, rn);
} else {
epoll_ctl(epfd, EPOLL_CTL_DEL, cfd, &ev);
close(cfd);
}
}
}
}
}
LT 模式很符合人类直觉,数据没读完的话下次还会继续提醒你。
但如果把 ev.events 换成 EPOLLIN | EPOLLET,立刻进入 ET 模式。ET 模式下必须做两件事:
第一,把客户端 fd 设置为非阻塞。第二,read() 的时候必须循环读,直到读到 EAGAIN 为止,不然就会漏数据。
c复制int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
return fd;
}
// ET 模式的读取逻辑
void handle_read_et(int cfd) {
char buf[1024];
while (1) {
int rn = read(cfd, buf, sizeof(buf));
if (rn > 0) {
write(cfd, buf, rn);
} else if (rn == -1 && errno == EAGAIN) {
break; // 数据读完了,退出
} else {
close(cfd);
break;
}
}
}
这个代码看起来改动不大,但坑非常多。比如 while (1) 循环里如果只调用了一次 write,客户端如果一次发来特别大的数据,你在一个 EAGAIN 之前需要多次 read,如果读到 EAGAIN 就退出,那大包数据就会滞留到下次新事件到来。如果客户端没有后续新数据,事件就再也不来了,数据就会一直躺在内核缓冲区里,客户端那边等不到完整响应,直接把服务判死。
那正确的做法要么是把缓冲区扩大、要么自己维护一个应用层缓冲队列,反正核心原则是:ET 模式的 read 必须读到 EAGAIN,LT 模式则可以读多少算多少。
5.5 本地压测方法与数据复盘
写完三个版本之后,我做了个简单的本地压测。
工具用的是 wrk,搭建在回环地址上,单条 TCP 连接连续发送小包数据。结果很有趣:
- 连接数 100 以内:
select/poll/epoll的表现几乎没有差距,事件处理时延都在 30 微秒上下; - 连接数 1000:
select的 CPU 占用开始出现波动,poll和epoll依然平稳; - 连接数 5000:
select已经因为 fd 数量限制彻底不可用,poll的 CPU 占用占到单核 60% 以上,epoll只占 20% 左右; - 连接数 50000:
poll的响应延迟飙到几十毫秒,epoll保持在个位数毫秒。
还有一个真实观察:ET 模式在吞吐上比 LT 好 5% 到 15% 左右,但主要体现在空闲连接多、活跃连接均匀的场景。如果是每个连接都在疯狂读写,LT 和 ET 的差距会被读写本身的耗时覆盖掉。
6. 常见问题与排查技巧实录
6.1 select 的 FD_SETSIZE 到底怎么突破
严格来说,FD_SETSIZE 是编译期常量,源码里可以重新定义。很多老项目会在 include 系统头文件之前自己 #define FD_SETSIZE 4096 来扩大。但这个方法有风险:内核的 fd_set 也是按这个宏展开的,如果用户态和内核态对这个常量的理解不一致,就会出现内存越界或拷贝错位。
所以生产环境里要扩大 select 的容量,更稳妥的办法是彻底换用 poll 或者 epoll。我自己早期改过 FD_SETSIZE 到 4096,单机跑一个小服务还行,但说实话,一遇到第三方库内部也使用 select,就很容易莫名其妙崩掉,排查起来非常痛苦。
6.2 poll 返回后为什么必须检查 POLLNVAL 和 POLLHUP
POLLNVAL 表示 fd 没有打开,比如文件描述符已经 close() 了但 pollfd 数组里的记录没清干净。POLLHUP 表示对端挂断,此时如果继续 write,会产生 SIGPIPE。
最常见的一个错误是:只判断 POLLIN,不判断异常事件。结果客户端正常断开后,你的服务端 poll 可能返回 POLLIN | POLLHUP 的组合,你以为有数据来,一个 read 返回 0,如果没有正确处理 0 返回值并关闭 fd,这个 fd 会永远留在 pollfd 数组里,每次 poll 都要扫描到这个无效节点,连接数越大越浪费。
6.3 epoll_wait 返回的 fd 真的还能被再次触发吗
这是个非常容易混淆的点。epoll_wait 返回的事件列表,是一次性把当前就绪队列的内容“边拷贝边清空”的。也就是说,默认 LT 模式下,如果你没有处理完数据,下次调用 epoll_wait 依然会再次返回这些 fd;但如果用的是 ET 模式,这次不处理完,下次可能再也不触发了。
有一次生产环境出现“连接假死”,客户端发了数据,服务端一直没有响应。排查到最后发现是 epoll 的 ET 模式 + 应用层缓冲区设计不合理,导致早期一次突发大包数据没读完,后续又恰巧没有新数据包到达,服务端一直处于“有数据但无事件”的死锁状态。最终方案是把读缓冲区的初始大小调大,并且在读循环里增加容错处理。
6.4 关于 epoll 惊群问题
惊群是指多个线程或进程同时阻塞在同一个 epoll_wait 上,当一个事件发生时,所有线程都被唤醒,但最终只有一个线程能处理这个事件,其他线程白忙活一场。
Linux 4.5 之前没有 EPOLLEXCLUSIVE 宏,需要靠应用层锁来避免惊群。4.5 之后增加了 EPOLLEXCLUSIVE 标志位,用来保证只有一个等待者会被唤醒。这个特性对多线程模型非常友好。
但并不是所有项目都应该开 EPOLLEXCLUSIVE。如果你的模型是一个主线程 accept + 多个工作线程处理读写事件,就没必要用这个标志;如果把所有 fd 都交给多个线程同时 epoll_wait,那就必须考虑惊群问题。我实际经验是,多线程各自的 epoll 实例各管各的 fd,比共享一个 epoll 实例要省心得多,也更容易推理。
6.5 可移植性:Linux 之外的方案
如果你的代码要在 FreeBSD / macOS 上跑,epoll 就不可用了,对应的是 kqueue。Windows 上是 IOCP。如果坚持要用一套 API 抽象,现在比较流行的做法是引第三方事件库,比如 libevent、libuv、Boost.Asio。它们在底层会根据平台自动选择最优的事件模型。
我自己在跨平台项目里比较偏好 libuv 的封装,不是说它性能最好,而是它把事件循环、定时器、信号处理都整合得比较顺手。当然,如果项目只需要 Linux 单平台,直接手写 epoll 能获得最大的控制力和最小的依赖冗余。
7. 最后说几句实在话
代码写多了之后会发现,select、poll、epoll 之间的性能差距,在绝大多数业务系统里并不是最关键的瓶颈。数据库查询、序列化、跨服务调用,往往才是耗电大户。I/O 多路复用模型选型这件事,更像是在基础设施层面打底子,它决定了你服务的“并发天花板”,但并不直接决定单次请求的响应速度。
如果你是在校学生或者刚转行的新人,我建议一定要把三种模型的演进逻辑弄清楚,尤其要亲手写一遍回声服务,去对比不同连接数下的 CPU 占用。这个基本功比背一百道面试题都管用。如果是已经在写业务服务的工程师,我建议重新审视一下手头项目的网络层,有没有因为 select 或 poll 的使用方式不当,白送掉了一部分性能。
回头说说我现在的处理习惯:除非是极小的工具脚本,否则一律用 epoll;需要跨平台的时候,直接用 libuv 这类事件库;select 只在写 docker 健康检查或者一次性小工具的时候才会用到,毕竟简单直接、无需引入额外依赖。不过无论用哪个模型,都得始终守住一条底线:任何事件通知机制都不能保证数据传输的完整性,读写逻辑必须有严格的边界判断和异常处理。这个原则,才是高性能网络编程里最值钱的部分。
