epoll 是 Linux 高并发的基石——Nginx、Redis、Go runtime、Java NIO、Node.js libuv、.NET Kestrel 在 Linux 上,底下全是它。很多人以为 epoll 只是 select/poll 的"升级版",API 长得也像,于是把它当成"支持更多连接的 select"来理解。
这个理解是错的。epoll 不是改良,是一次模型换轨。
select/poll 每次调用都把"所有 fd"扔进内核,让内核线性扫一遍哪些就绪——这是 O(n) 的扫描;epoll 把"我关心哪些 fd"这个状态常驻内核,靠回调把就绪的 fd 主动挂到一个链表上,epoll_wait 只取这个链表——这是 O(1) 的取件。从"轮询"到"回调",才是 epoll 的本质。
本文从 select 的诞生讲起,把这条演进线(select → poll → kqueue → epoll → io_uring)串起来,再钻进 epoll 内核实现(红黑树、就绪链表、回调链路)、LT/ET 的本质、标志位的演进史,最后聊聊 io_uring 这位新挑战者。前序的 I/O 模型全解 和 Reactor 模式 讲过宏观定位,这里把 epoll 这一环彻底拆透。
一、故事的起点:一个线程伺候一个连接
在多路复用出现之前,高并发服务器长这样:
1
2
3
4
5
6
7
8
9
10
| Apache prefork / thread-per-connection 模型:
主线程 accept() → 拿到一个连接
↓
fork() 一个子进程(或 new 一个线程)
↓
子线程: read() → process() → write()
(阻塞在这个连接上,直到客户端断开)
1 个连接 = 1 个线程/进程 全程占用
|
这套模型在连接数几百时没毛病,代码也最直观。但到了 1999 年,互联网开始膨胀,Dan Kegel 在他那篇著名的 The C10K problem 里把窗户纸捅破了:一台机器要同时扛 1 万个连接,怎么办?
1
2
3
4
5
6
7
8
| 每线程默认 8MB 栈 → 1 万个线程需要 ~80GB 虚拟地址空间
(32 位时代直接撞 4GB 地址墙——C10K 在 2000 年前后成为问题就是这个背景)
1 万个线程频繁切换 → CPU 全耗在 context switch
而且这 1 万个连接 99% 时间都在"等数据"(keep-alive、长轮询、聊天)
→ 1 万个线程傻等,CPU 空转
结论:thread-per-connection 撑不到 C10K
根因:阻塞 I/O 让"一个线程只能等一个 fd"
|
破局的关键不是"更多线程",而是"一个线程同时等很多 fd,谁有数据就处理谁"——这就是 I/O 多路复用(I/O multiplexing)。第一个吃螃蟹的,是 1983 年的 select。
二、第一代多路复用:select(1983,4.2BSD)
select 是 BSD UNIX 在 4.2BSD(1983)引入的,思路是:把"我关心的 fd"打包成三组位图,一次性扔给内核,让内核帮你看哪些就绪。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| #include <sys/select.h>
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(fd1, &readfds);
FD_SET(fd2, &readfds);
// ... 最多 FD_SETSIZE 个
struct timeval tv = { .tv_sec = 5 };
int n = select(maxfd + 1, &readfds, NULL, NULL, &tv);
// ↑ ↑ ↑ ↑
// 读集合 写集 异常集 超时
if (n > 0) {
for (int fd = 0; fd <= maxfd; fd++) {
if (FD_ISSET(fd, &readfds)) {
// fd 就绪,去 read
}
}
}
|
1
2
3
4
5
6
7
8
9
| select 的工作流程:
1. 应用把 3 个 fd_set(读/写/异常)从用户空间拷贝到内核
2. 内核遍历所有 fd,挨个检查是否就绪,标记到 fd_set 里
3. 内核把标记后的 fd_set 拷回用户空间
4. 应用再遍历一遍 fd_set,找出哪些被标记了
5. 对就绪的 fd 调 read/write
→ 每次调用都重复 1-4 整个流程
|
select 解决了"一个线程等多个 fd"的问题,功不可没。但它有四个硬伤,每一个都在 C10K 面前致命:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
| 硬伤 1:fd 数量上限 = 1024
FD_SETSIZE 宏默认 1024,fd_set 是位图,每 bit 一个 fd
→ 想管 1 万个连接?改内核宏重编译,没人愿意
(而且位图是定长数组,改了也只是把上限抬高,没解决结构问题)
硬伤 2:每次调用都要传"全部 fd"
三组 fd_set 在用户↔内核之间来回拷贝
1 万个 fd 的位图约 1.2 KB,每次 select 都拷两次
→ 连接越多,单次调用越慢
硬伤 3:内核线性扫描 O(n)
内核要把所有 fd 走一遍查就绪状态
1 万个 fd 里只有 10 个就绪 → 也要扫 1 万次
→ 浪费的 CPU 与活跃连接数无关,与总连接数成正比
硬伤 4:返回后用户还得再扫一遍 O(n)
select 只告诉你"有 n 个就绪",不告诉你哪几个
你得 FD_ISSET(fd) 从 0 遍历到 maxfd 把就绪的挑出来
→ 第二次 O(n)
|
1
2
| 一句话:select 的复杂度是 O(n),而且 n 是"总连接数"不是"活跃连接数"。
连接越多越慢,哪怕它们都闲着。
|
select 不是没救——在连接数少、活跃率高的场景(比如几十个 fd 全都很忙),它简单够用,至今还在用。但 C10K 这种"海量连接、大多空闲"的场景,它扛不住。
三、poll:去掉了 1024,但没去根
poll 是 System V 的产物(SVR3,约 1986),思路和 select 一样,只是换了数据结构:用数组代替位图,每个元素是 struct pollfd。
1
2
3
4
5
6
7
8
9
10
11
12
13
| #include <poll.h>
struct pollfd fds[10000];
fds[0].fd = fd1; fds[0].events = POLLIN;
fds[1].fd = fd2; fds[1].events = POLLIN;
// ...
int n = poll(fds, 10000, 5000); // 5000ms 超时
for (int i = 0; i < 10000; i++) {
if (fds[i].revents & POLLIN) {
// fds[i].fd 就绪
}
}
|
1
2
3
4
| poll 相比 select 的改进:
✓ 用数组代替位图 → 没有 1024 上限,想多少传多少
✓ events/revents 分离 → 不用每次重建"输入"(revents 由内核写,events 不变)
✓ 语义更清晰(POLLIN/POLLOUT/POLLERR...)
|
但 select 的后三个硬伤,poll 一个都没解决:
1
2
3
| 硬伤 2 仍在:每次调用还是传"全部 fd"(整个 pollfd 数组用户↔内核拷贝)
硬伤 3 仍在:内核还是线性扫描所有 fd O(n)
硬伤 4 仍在:返回后用户还是得遍历整个数组找 revents
|
1
2
| poll 解决的是"数量"问题,没解决"性能"问题。
1 万个连接里只有 10 个活跃?poll 照样每次扫 1 万个。
|
为什么 poll 没能根治?因为它的模型没变——仍然是"每次调用都把全集扔给内核线性扫"。只要模型是"轮询全集",复杂度就永远是 O(总连接数)。真正要破 C10K,得换模型。
四、同时代的另一条路:kqueue 与 /dev/poll
Linux 不是唯一被 C10K 逼着想办法的系统。90 年代末到 2000 年初,几条路同时在试:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| FreeBSD kqueue(2000,4.1-RELEASE):
- 不仅告诉你"谁就绪",还告诉你"发生了什么变化"
- kevent 结构 + 一次 kevent() 调用既可注册又可取事件
- 支持 filter(读/写/信号/定时器/子进程...)统一抽象
- 思路接近"边缘触发"——返回的是"变化"而非"当前状态"
- 业界公认设计比 epoll 优雅
Solaris /dev/poll(约 1998):
- 写一次"兴趣集"到一个特殊设备,内核常驻
- 后续 poll 只查"就绪",不用重传全集
- 第一个把"兴趣集常驻内核"落地的方案
Linux 早期的失败尝试:realtime signals + SIGIO
- fd 就绪时内核发实时信号
- 信号队列会溢出(OOM 时丢事件)
- 信号乱序、合并,无法可靠对应到具体 fd
- 实践证明不可靠,被放弃
|
这几条路给出了同一个关键启发:把"我关心哪些 fd"这个状态常驻内核,别每次重传;就绪事件让内核主动通知,别让应用轮询。 Linux 吸收了这些思路,做出了 epoll。
五、epoll 登场(Linux 2.5.44,2002)
epoll 在 2002 年 10 月进入 Linux 2.5.44,2003 年底随 2.6 稳定。API 三个,干净利落:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| #include <sys/epoll.h>
// 1. 创建一个 epoll 实例(内核里建好数据结构)
int epfd = epoll_create1(0);
// 2. 注册/修改/删除关心的 fd(状态常驻内核)
struct epoll_event ev = { .events = EPOLLIN, .data.fd = fd };
epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &ev); // ADD / MOD / DEL
// 3. 等待就绪(只返回就绪的 fd)
struct epoll_event events[128];
int n = epoll_wait(epfd, events, 128, -1); // -1 = 阻塞
for (int i = 0; i < n; i++) {
handle(events[i].data.fd); // events 里全是就绪的
}
|
表面看和 select/poll 差不多,但内核里的机制完全不同。epoll 的三大核心创新:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| 创新 1:兴趣集常驻内核(不再每次重传)
epoll_ctl ADD 时,fd 被记进内核的"兴趣集"(一棵红黑树)
后续 epoll_wait 不用再传全集,只传一个输出数组
→ 硬伤 2 消除:没有全集的用户↔内核拷贝
创新 2:就绪靠回调,不靠扫描(O(1) 取件)
注册 fd 时,内核在它的等待队列上挂一个回调(ep_poll_callback)
数据到达时,驱动触发回调,把这个 fd 主动塞进"就绪链表"
epoll_wait 只需把就绪链表里的元素拷给用户
→ 硬伤 3 消除:不再线性扫描全集,复杂度 O(就绪数)
创新 3:返回的就是就绪的(不用再扫)
events[] 数组里前 n 个全是就绪 fd,直接用
→ 硬伤 4 消除:不需要 FD_ISSET 二次遍历
|
1
2
3
4
5
6
7
8
| 对比一眼就懂:
select/poll:每次 select() → 内核扫 N 个 fd → 返回 → 用户再扫 N 个找就绪
复杂度 O(N),N = 总连接数
epoll: epoll_ctl ADD(一次性)→ 内核挂回调
epoll_wait() → 内核把"被回调塞进链表"的就绪 fd 拷给你
复杂度 O(就绪数),与总连接数无关
|
这就是为什么 epoll 能扛 C10K、C100K 甚至 C1M——它的开销和"活跃连接数"成正比,和"总连接数"几乎无关。1 万个连接里 10 个活跃,epoll 只处理 10 个;select/poll 要扫 1 万个。
六、钻进内核:红黑树、就绪链表与回调链路
光说"回调"太虚。来看 epoll 在内核里到底长什么样。核心是 struct eventpoll(在 fs/eventpoll.c):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| struct eventpoll(简化):
┌─────────────────────────────────────────────┐
│ rbr : 红黑树根 —— 兴趣集(所有注册的 fd)│
│ rdllist : 双向链表 —— 就绪链表(已就绪的 fd)│
│ wq : 等待队列 —— epoll_wait 在此睡眠 │
│ ovflist : 溢出链表(遍历 rdllist 期间的暂存)│
└─────────────────────────────────────────────┘
每个注册的 fd 对应一个 struct epitem:
- rb_node:挂在 rbr 红黑树上(按 fd 排序,去重)
- rdllist_node:就绪时挂在 rdllist 链表上
- ffd:指向 file + fd
- event:关心的事件(EPOLLIN/OUT/ET...)
- 注册时在 file 的等待队列上挂了 ep_poll_callback
|
1
2
3
4
5
| 为什么兴趣集用红黑树?
- epoll_ctl ADD/MOD/DEL 要按 fd 查找/增删 → 红黑树 O(log n),自平衡、按 fd 有序、天然去重
- 不用数组:fd 值可能很大(几万甚至十几万),按 fd 直接建数组会浪费巨量空间
- 不用哈希表:理论 O(1) 更快,但内核里哈希表内存占用大、缓存不友好
- 红黑树更紧凑,且是内核通用数据结构(struct rb_rootCached)
|
完整的数据到达链路(这是 epoll 的灵魂):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| ① 网卡收到数据包 → DMA 写入 ring buffer → 触发硬件中断
↓
② 中断处理(NAPI)→ 软中断 NET_RX_SOFTIRQ
↓
③ 协议栈处理 → 数据挂到 socket 的接收队列
↓
④ sock_def_readable() → 唤醒 socket 的等待队列
↓
⑤ 等待队列上有 epoll 挂的 ep_poll_callback → 触发回调
↓
⑥ ep_poll_callback 把这个 fd 的 epitem 挂到 eventpoll->rdllist
↓
⑦ 唤醒在 eventpoll->wq 上睡眠的 epoll_wait
↓
⑧ epoll_wait 醒来 → 把 rdllist 里的就绪事件拷到用户 events[]
|
1
2
3
4
| 关键洞察:
- "fd 是否就绪"这个判断,是由数据到达时的回调"推"出来的,不是 epoll_wait 主动"拉"的
- 所以 epoll_wait 的开销只取决于"有多少 fd 就绪",与"注册了多少 fd"无关
- 这是从"轮询"到"事件驱动"的范式转换——和 select/poll 的本质区别就在这一步
|
1
2
3
4
5
6
7
| epoll_wait 内部的小细节:
- 进 epoll_wait 时先看 rdllist 非空?有就拷给用户(最多 maxevents 个)
- 空就睡到 wq 上,等回调唤醒
- 拷出去的 epitem:
LT 模式:如果拷完发现还就绪(数据没读完),留在 rdllist 里下次还返回
ET 模式:拷出去就从 rdllist 摘掉,只有"下次有新数据"才重新挂回
- ovflist:防止"遍历 rdllist 拷给用户"期间又有新就绪 fd 来插队丢事件
|
七、LT vs ET:两种触发模式,两种哲学
epoll 最容易被用错的地方,是**水平触发(LT)和边缘触发(ET)**的选择。
1
2
3
4
5
6
7
8
9
10
| LT(Level Triggered,默认):
只要 fd 上"还有数据没读完",每次 epoll_wait 都会返回它
类似 select/poll 的语义
→ 你读一点、剩一点,下次还会通知你
ET(Edge Triggered,EPOLLET):
只在 fd "状态变化"时通知一次(从无数据到有数据)
通知之后你必须一口气把数据读完(读到 EAGAIN)
否则剩下的数据再也不会通知你 → 数据饥饿
→ 类似"边沿触发"的电路:只在跳变时反应
|
1
2
3
4
5
6
7
8
9
10
11
12
| 场景:socket 一次到达 4 KB 数据,你的 buf 只有 1 KB
LT:
epoll_wait 返回 fd → read 1KB → 剩 3KB
下次 epoll_wait → 还会返回这个 fd → 再 read 1KB
... 直到读完
ET:
epoll_wait 返回 fd 一次 → read 1KB → 剩 3KB
下次 epoll_wait → 不再返回这个 fd(没有"新"数据到达)
→ 那 3KB 永远卡在内核缓冲区,直到对端再发数据触发跳变
→ 数据饥饿 / 对端以为你收到了
|
ET 的纪律(违反任何一条都会出 bug):
1
2
3
4
5
6
7
8
9
10
| 1. fd 必须设为非阻塞(O_NONBLOCK)
ET 下你要循环 read 到 EAGAIN,阻塞 fd 会在数据读完时卡死整个线程
2. read/write 必须循环到返回 EAGAIN/EWOULDBLOCK
一次没读完 = 下次没通知 = 数据丢
3. accept 也要循环到 EAGAIN
listen fd 在 ET 模式下,一次只 accept 一个会导致后续连接排队甚至被拒绝
4. 收到 EAGAIN 不是错误,是"本轮读完了"的正常信号
|
为什么 ET 理论上更快?
1
2
3
4
5
6
7
8
| LT:数据没读完,每次 epoll_wait 都返回 → 可能很多次唤醒
ET:一次跳变只通知一次 → 减少 epoll_wait 的返回次数和用户态↔内核态切换
一个慢消费者(4KB 数据,每次 read 1KB):
LT:4 次 epoll_wait 返回 + 4 次 read
ET:1 次 epoll_wait 返回 + 4 次循环 read(在用户态连读)
→ ET 把"读完"的循环放在用户态,少了内核唤醒的开销
|
但 ET 更难写对。生产实践:
1
2
3
4
5
6
| 谁用 ET:nginx(ET + 多 worker,榨干性能)、早期高性能网络库
谁用 LT:Redis(单线程 LT,简单可靠)、Java NIO 默认、大多数业务服务
→ LT 行为接近 select/poll,迁移成本低,不容易出 bug
教训:除非你清楚知道为什么需要 ET,否则用默认 LT。
ET 的性能优势在大多数业务场景被"代码复杂度 + bug 风险"抵消。
|
八、epoll 的演进史:API 与标志位
epoll 不是一蹴而就的,它自己也在长。每个新标志位都对应一个真实痛点。
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
| 2002 Linux 2.5.44 epoll 初登场
epoll_create(size) / epoll_ctl / epoll_wait
(size 参数其实没用,内核动态扩展,留着只是兼容)
2003 Linux 2.6 稳定
EPOLLET(边缘触发)
2007 Linux 2.6.22 signalfd / timerfd / eventfd
"一切皆文件描述符"——信号、定时器、事件都能变成 fd
→ 可以统一塞进 epoll,一个 wait 等所有事件源
(timerfd 2.6.25 补齐)
2008 Linux 2.6.27 epoll_create1(flags)
+ EPOLL_CLOEXEC(fork+exec 时不泄漏 fd)
→ 废弃 size 参数,更干净
+ epoll_pwait(可指定信号屏蔽,原子地"等+改屏蔽")
~2010 EPOLLONESHOT
一个 fd 被通知一次后自动禁用,直到 EPOLL_CTL_MOD 重新 arcm
→ 线程池场景:保证一个 fd 同一时刻只被一个工作线程处理
(避免两个线程同时 read 同一个 fd 的竞态)
2016 Linux 4.5 EPOLLEXCLUSIVE
多个 epoll(不同线程/进程)等同一个 fd 时,只唤醒一个
→ 解决"惊群"(thundering herd):一个连接到来,N 个 worker 全被唤醒
抢同一个 accept,N-1 个白跑
|
1
2
3
4
5
| 演进脉络能看出 epoll 的设计取向:
① 把更多事件源(信号/定时器)统一成 fd → 一个 epoll 管所有
② 减少多线程下的竞争(ONESHOT / EXCLUSIVE)
③ API 清理化(create1 废弃无用参数)
→ 朝"统一、可扩展、低竞争"走,但本质模型(就绪通知)没变
|
九、实战:一个 epoll echo server
把前面所有概念落成一个能跑的程序。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
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
| #define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <fcntl.h>
#include <arpa/inet.h>
#define PORT 9000
#define MAX_EVENTS 1024
#define BUF_SIZE 1024
static void set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main(void) {
/* 1. listening socket */
int lfd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
set_nonblocking(lfd);
struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
bind(lfd, (struct sockaddr *)&addr, sizeof(addr));
listen(lfd, SOMAXCONN);
/* 2. epoll 实例 + 注册 listen fd */
int epfd = epoll_create1(0);
struct epoll_event ev = {0};
ev.events = EPOLLIN; /* LT(默认) */
ev.data.fd = lfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev);
struct epoll_event events[MAX_EVENTS];
char buf[BUF_SIZE];
printf("listening on %d (epoll, LT)\n", PORT);
/* 3. 事件循环 */
for (;;) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
int fd = events[i].data.fd;
if (fd == lfd) {
/* 新连接:循环 accept 到 EAGAIN(LT 下其实 accept 一次也行,循环更稳) */
for (;;) {
int cfd = accept(lfd, NULL, NULL);
if (cfd < 0) {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
perror("accept"); break;
}
set_nonblocking(cfd);
ev.events = EPOLLIN;
ev.data.fd = cfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev);
}
} else {
/* 已连接 fd:循环读到 EAGAIN(LT 下推荐,ET 下必须) */
for (;;) {
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
write(fd, buf, r); /* echo(简化,忽略 partial write) */
} else if (r == 0) {
/* 对端关闭 */
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) break;
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
}
}
}
}
}
}
|
1
2
3
4
5
6
7
| 关键点:
- listen fd 和 conn fd 都设 O_NONBLOCK
(epoll 高并发下阻塞 fd 会卡死整个事件循环)
- accept / read 都循环到 EAGAIN
LT 下这是"推荐",ET 下这是"必须"
- 对端关闭(read 返回 0)或出错时 EPOLL_CTL_DEL + close
(不 DEL 直接 close 也行,内核会自动清理;但显式 DEL 更清晰)
|
改成 ET 版只动两处:
1
2
3
4
5
| // listen fd 注册:加 EPOLLET
ev.events = EPOLLIN | EPOLLET;
// conn fd 注册:加 EPOLLET
ev.events = EPOLLIN | EPOLLET;
|
1
2
3
4
5
6
7
8
| ET 版的硬性要求(上面代码已满足):
✓ fd 非阻塞(已 set_nonblocking)
✓ accept 循环到 EAGAIN(已循环)
✓ read 循环到 EAGAIN(已循环)
→ 所以上面的 LT 代码本身就是"ET 就绪"的,只差一个标志位
这也是为什么推荐"LT 写法即 ET 写法"——无论用不用 ET,
都按"循环到 EAGAIN"写,迁移时只加 EPOLLET,行为天然正确。
|
十、epoll 的陷阱与坑
坑 1:ET 模式不读到 EAGAIN → 数据饥饿
1
2
3
4
5
6
7
| ET 下 read 一次就返回,没读干净
→ 剩下的数据卡在内核缓冲区
→ 没有新的"跳变",epoll_wait 不再通知
→ 对端以为你收到了,业务层丢数据
根因:误把 ET 当 LT 用
修复:ET 下必须循环 read 到 EAGAIN(见上一节的代码)
|
坑 2:ET 下忘了设 O_NONBLOCK → 线程卡死
1
2
3
4
5
6
7
| ET + 阻塞 fd:
循环 read,最后数据读完时 read 不返回 EAGAIN,而是阻塞
→ 整个事件循环线程挂在一个 fd 上
→ 其他所有连接全部卡死
根因:ET 的"循环到 EAGAIN"前提是 fd 非阻塞
修复:epoll 注册的 fd 一律 O_NONBLOCK,无例外
|
坑 3:accept 惊群 → EPOLLEXCLUSIVE
1
2
3
4
5
6
7
8
| 多个 worker 进程/线程各自 epoll_wait 同一个 listen fd
新连接到达 → 所有 worker 被唤醒
只有一个 accept 成功,其余 accept 返回 EAGAIN
→ N-1 个 worker 白醒一次,context switch 浪费
修复:Linux 4.5+ 给 listen fd 注册时加 EPOLLEXCLUSIVE
ev.events = EPOLLIN | EPOLLEXCLUSIVE;
内核只唤醒一个 worker
|
坑 4:EPOLLONESHOT 用完不重新 arcm → fd 失联
1
2
3
4
5
6
7
| ONESHOT 模式:fd 通知一次后自动禁用
worker 处理完必须 EPOLL_CTL_MOD 重新 arcm,否则这个 fd 再也不会有事件
→ 连接"假死"
适合:线程池模型,保证一个 fd 同一时间只被一个线程碰
代价:每次处理完多一次 epoll_ctl 系统调用
陷阱:处理中途异常退出没 arcm → fd 泄漏(永远禁用)
|
坑 5:大量短连接不适合 epoll
1
2
3
4
5
6
7
8
| epoll_ctl ADD/DEL 不是免费的:
- ADD 要建 epitem、插红黑树、在 file 等待队列挂回调
- DEL 要拆回调、从树里删
短连接频繁建断 → epoll_ctl 开销可能超过收益
对比:极低并发 + 短连接,select 反而最简单够用
实践:nginx 用连接池 + 长连接,避免频繁 ADD/DEL
短连接密集的场景考虑连接复用或上游加 keepalive
|
坑 6:fork 后 epoll fd 的继承
1
2
3
4
5
6
| fork() 后子进程继承 epoll fd(指向同一个内核 eventpoll)
但通常你不想父子进程同时 epoll_wait 同一个 epfd
→ 除非配合 EPOLLEXCLUSIVE 做多进程 accept,否则应 epoll_create1(EPOLL_CLOEXEC)
在 exec 时不泄漏
陷阱:多进程共享 epfd 的语义复杂,一般每进程一个 epfd 更清晰
|
十一、谁在用 epoll(后端视角)
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
| nginx
ET + 多 worker(每 worker 一个 epfd)
经典的 Reactor:主 worker epoll_wait → 就绪后非阻塞处理
→ 高并发静态/代理的标杆
Redis
单线程 + epoll(LT)
6.0 引入多线程 IO,仍是 epoll 驱动,只是把 read/write 分摊到 IO 线程
→ LT 足够,简单可靠优先
Go runtime netpoller
runtime 把 epoll 封装成"同步接口 + goroutine 调度"
你写 conn.Read() 看起来是阻塞的,底层 goroutine 在 epoll_wait 上 park
fd 就绪时 runtime 唤醒对应 goroutine
→ "同步写法、异步底层",epoll 是 Go 网络的隐藏地基
Java NIO Selector
Linux 上 SelectorProvider 默认用 epoll
Netty 在此之上做 Reactor/多线程
Node.js libuv
Linux 后端就是 epoll(封装成事件循环)
.NET Kestrel(Linux)
SocketAsyncEventArgs 在 Linux 上映射到 epoll
→ 你写的 async network 代码,到了 Linux 就是 epoll
|
1
2
3
4
5
6
7
8
9
10
| 后端工程师的现实:
你写 await httpClient.GetAsync(...) / go conn.Read() / channel.send()
↓
框架/runtime 把它翻译成 epoll 注册 + 等待
↓
Linux 内核用回调把就绪 fd 推上来
→ epoll 是你所有"高并发网络代码"在 Linux 上的共同底层
哪怕你从不直接调 epoll API,理解它也能解释你遇到的
"连接数上不去""CPU 被 epoll_wait 吃了""ET 丢数据"等问题
|
十二、终章:io_uring 的挑战(2019,Linux 5.1)
epoll 至今仍是主流,但它有一个根本性的局限:它只告诉你"就绪",不替你干活。
1
2
3
4
5
6
7
| epoll 的模型:就绪通知(readiness notification)
epoll_wait → "fd 可读了" → 你自己调 read() → 阻塞/拷贝
→ 阶段1(等数据)交给内核,阶段2(拷贝)还是你自己干
→ 仍是"同步 I/O"(详见 [I/O 模型全解](../internals/01-io-models/))
每次就绪后还要一次 read() 系统调用 → 用户态↔内核态切换
高 QPS 下系统调用本身就是开销
|
2019 年,Jens Axboe(Linux 块层维护者)在 Linux 5.1 引入 io_uring,这是一次更大的换轨:
1
2
3
4
5
6
7
8
9
10
| io_uring 的模型:完成通知(completion notification)
- 两个共享内存环形缓冲:SQ(提交队列)+ CQ(完成队列)
- 你往 SQ 塞一个"读请求"(SQE)→ 内核自己去等数据 + 拷贝
- 完成后内核往 CQ 塞一个"完成结果"(CQE)
→ 阶段1 + 阶段2 全由内核完成,真正的异步 I/O
杀手锏:
① 共享内存 → SQE/CQE 不走系统调用拷贝
② 批量提交 → 一次 syscall 提交一摞请求
③ 可选 sqpoll → 内核常驻一个线程轮询 SQ,连提交 syscall 都省了
|
1
2
3
4
5
6
7
8
9
10
| epoll vs io_uring:
维度 epoll io_uring
─────────────────────────────────────────────────────────
模型 就绪通知(你再去 read) 完成通知(内核替你 read)
真/伪异步 伪(拷贝阶段自己做) 真
系统调用数 每次就绪后还要 read() 可批量、可零(sqpoll)
数据拷贝 每次系统调用拷贝 共享内存环形缓冲
复杂度 简单,API 三个 复杂,SQE/CQE/注册/缓冲管理
生态成熟度 20 年,绝对主流 2019 起,快速演进中
|
但 io_uring 的路并不平坦。2023–2024 年,Google 安全团队接连披露多个通过 io_uring 触发的内核提权漏洞,导致:
1
2
3
4
5
6
7
| 2024 安全风波:
- io_uring 的攻击面大(内核里加了太多代码、与各种子系统交互)
- Google 在 Android / ChromeOS 默认关闭 io_uring
- systemd、部分发行版讨论默认禁用
- 5.x→6.x 期间多次收紧 io_uring 权限(io_uring_disabled sysctl)
→ io_uring 性能极强,但"是否默认可用"成了新的不确定性
|
1
2
3
4
5
6
7
8
9
10
11
12
| epoll 会不会被 io_uring 取代?
短期不会:
- epoll 简单、稳定、20 年生态,几乎所有运行时都依赖它
- io_uring 复杂、API 仍在演进、安全争议未平
- 对绝大多数应用,epoll + 非阻塞 I/O 已经足够快
长期看:
- 极致吞吐(存储、数据库、超高 QPS 网关)会逐步倒向 io_uring
- 新运行时(如 Rust 的 tokio-uring、Go 的实验性 io_uring 支持)在试水
- 但 epoll 作为"默认通用方案"的地位,十年内不动摇
→ 类比:io_uring 之于 epoll,像 epoll 之于 select——新方案更优,但老方案不会消失
|
十三、小结
从 1983 年的 select 到 2019 年的 io_uring,Linux I/O 多路复用走了 40 年。主线只有一条:从"轮询全集"到"回调通知",从"就绪通知"到"完成通知"——每一次换轨都是把更多工作下沉到内核、减少用户态的无效扫描和系统调用。
- select(1983):位图 + 三组 fd_set,1024 上限,O(n) 双重扫描,每次重传全集
- poll(1986):数组去 1024,但模型没变,仍是 O(n) 全集轮询
- epoll(2002):兴趣集常驻红黑树 + 回调挂就绪链表,O(就绪数),C10K 的解药
- epoll 内核机制:
epoll_ctl 挂回调 → 数据到达触发 ep_poll_callback → 入 rdllist → epoll_wait 取件 - LT vs ET:LT 重复通知(像 select),ET 只通知跳变一次(必须读到 EAGAIN)
- 标志位演进:
EPOLLET / EPOLLONESHOT(线程池)/ EPOLLEXCLUSIVE(防惊群)/ signalfd·timerfd·eventfd(统一事件源) - 实战坑:ET 不读 EAGAIN 会饥饿、忘 O_NONBLOCK 会卡死、短连接不适合 epoll、ONESHOT 不 arcm 会失联
- 生态:nginx/Redis/Go/Java/Node/.NET 在 Linux 上全是 epoll,它是高并发网络的共同地基
- io_uring(2019):完成通知 + 共享环 + 批量,是 epoll 的挑战者,但简单可靠的 epoll 仍长期主流
1
2
3
4
| 记住三句话:
1. epoll 的本质是"用回调把 O(n) 扫描换成 O(1) 取件"——不是 select 的改良,是模型换轨
2. LT 像电平(有数据就一直通知),ET 像边沿(跳变才通知一次)——ET 必须读到 EAGAIN,否则饥饿
3. epoll 只告诉你"就绪",io_uring 替你"完成"——前者是现在的地基,后者是未来的方向
|
下次看到 epoll_wait 占 CPU、连接数上不去、或者纠结 ET/LT,回头看这条演进线,问题多半能对上号。和本站的 Reactor 模式、零拷贝 配起来读,Linux 高并发网络的全貌就齐了。