epoll 的前世今生:从 select/poll 的性能困局到 io_uring 的挑战

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 kqueue20004.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_CLOEXECfork+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 全被唤醒
          抢同一个 acceptN-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 高并发网络的全貌就齐了。