# epoll 的原理与用法 —— 从 select/poll 的困境到"注册一次 + 回调就绪"

> 高并发服务器要同时盯着成千上万个连接，等哪个"有数据可读/可写"。`select`/`poll` 的做法是"每次都把全部 fd 交给内核、内核挨个查一遍"——连接一多就爆。`epoll` 换了思路:**fd 只注册一次,就绪时靠回调主动上报**,于是 `epoll_wait` 拿就绪 fd 是 O(1)。本篇从数据结构讲清 epoll 为什么快,再讲 LT/ET 两种触发模式的区别与坑、惊群问题,最后给可套用的骨架代码。

## 一、为什么需要 epoll:select/poll 的三宗罪

`select`/`poll` 是最早的 IO 多路复用接口。它们的调用模型是:**每次调用,都把"我关心的全部 fd"整份交给内核,内核挨个扫一遍,告诉你哪些就绪**。连接数一大,三处开销都是 O(n)。三者的完整序列图对比与选型指南见 [select/poll/epoll 深度对比](/concepts/network/io-multiplexing-comparison.md):

| 环节 | select/poll 的做法 | 代价 |
|------|-------------------|------|
| ① 传入 | 每次调用把**全部 fd 集合**从用户态拷贝到内核态 | O(n) 拷贝,连接越多拷越久 |
| ② 检查 | 内核**遍历全部 fd**,逐个查是否就绪 | O(n) 扫描,大部分 fd 其实没动静 |
| ③ 取回 | 返回后用户态还要**再遍历全部 fd**,才知道哪几个就绪 | O(n) 查找 |

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<u>> #E3F2FD
  BorderColor<<u>>     #1976D2
  BackgroundColor<<k>> #FFF3E0
  BorderColor<<k>>     #F57C00
}
rectangle "用户态\n持有 10000 个 fd" <<u>> as U
rectangle "内核\n逐个扫描 10000 个 fd" <<k>> as K
U -right-> K : ① 每次 select 都整份拷 10000 个 fd 进内核
K -left-> U : ③ 返回后用户态再遍历 10000 个 fd 找就绪
note bottom of K : ② 内核 O(n) 扫全部 fd\n哪怕只有 1 个就绪也要扫完 10000 个
@enduml
```

一句话痛点:**明明只有几个 fd 就绪,却每次都要为全部 fd 付出 O(n) 代价**,而且这份 fd 集合每次调用都要重新告诉内核一遍(内核不"记住")。`select` 还额外受限于 `FD_SETSIZE`(通常 1024)。`poll` 去掉了个数上限,但 O(n) 三宗罪原样保留。

> epoll 的破局思路:**fd 只注册一次(内核记住它),平时不扫描,谁就绪了谁自己"举手"(回调),`epoll_wait` 只收举手的那几个。**

## 二、核心数据结构与原理:红黑树 + 就绪链表 + 回调

`epoll_create` 在内核创建一个 **eventpoll 对象**,它内部有两个关键结构:

- **一棵红黑树**:存放**所有被监视的 fd**。`epoll_ctl` 的增(ADD)、删(DEL)、改(MOD)都在这棵树上操作,O(log n)。用红黑树是因为要频繁按 fd 查找/去重,又要支持动态增删。
- **一个就绪链表 `rdllist`**:只挂**当前已经就绪**的 fd。`epoll_wait` 只看这条链表。

关键在**回调**:`epoll_ctl(ADD)` 把 fd 挂上红黑树时,顺带给这个 fd 在内核里注册一个**回调函数** `ep_poll_callback`。当这个 fd 的数据就绪时(网卡收到数据 → 硬件中断 → 协议栈处理 → 唤醒等待队列),这个回调被触发,它做的事就是:**把该 fd 对应的节点挂进就绪链表 `rdllist`**。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<ep>> #E8F5E9
  BorderColor<<ep>>     #388E3C
  BackgroundColor<<rb>> #E3F2FD
  BorderColor<<rb>>     #1976D2
  BackgroundColor<<rd>> #FFEBEE
  BorderColor<<rd>>     #C62828
}
rectangle "eventpoll 对象 (epoll_create 创建)" <<ep>> {
  rectangle "红黑树:所有被监视的 fd\nfd3  fd8  fd15  fd42 ... (共 10000 个)\nepoll_ctl 增删改 O(log n)" <<rb>> as RB
  rectangle "就绪链表 rdllist:只挂已就绪的 fd\nfd8  fd42\nepoll_wait 直接取,O(1)" <<rd>> as RD
}
note right of RB
  epoll_ctl(ADD) 时:
  ① 把 fd 挂上红黑树
  ② 给 fd 注册回调 ep_poll_callback
end note
note right of RD
  网卡中断→协议栈唤醒→回调触发
  回调把就绪的 fd 加进这条链表
end note
RB ..> RD : 某 fd 就绪时,回调把它\n从"被监视"提到"就绪"
@enduml
```

三个系统调用的分工:

| 调用 | 做什么 | 复杂度 |
|------|--------|--------|
| `epoll_create` / `epoll_create1` | 创建 eventpoll 对象(红黑树 + 就绪链表),返回一个 epfd | 一次 |
| `epoll_ctl(epfd, ADD/MOD/DEL, fd, event)` | 在红黑树上增/改/删一个被监视 fd,并注册回调 | O(log n),**只在增删时调用,不是每次循环都调** |
| `epoll_wait(epfd, events, maxevents, timeout)` | 检查就绪链表:空就睡,非空就把就绪 fd 拷给用户 | **O(1) 拿到就绪 fd,不扫描全部** |

对比 select/poll 的三宗罪,epoll 逐条破解:

| 环节 | select/poll | epoll |
|------|------------|-------|
| 传入 fd | 每次调用整份拷贝全部 fd | `epoll_ctl` 注册一次,内核用红黑树**记住**,之后不再重复传 |
| 内核检查 | 每次 O(n) 遍历全部 fd | 靠回调,就绪的自己进链表,**内核不主动扫** |
| 取回就绪 | 返回后 O(n) 遍历找就绪 | `epoll_wait` 直接返回就绪链表,**返回几个就处理几个** |

> 这就是 epoll 在"连接多、活跃少"(海量长连接、少量活跃)场景下碾压 select/poll 的根本原因:代价只和**就绪的 fd 数**成正比,和**总连接数**几乎无关。反过来,如果几乎所有 fd 每次都就绪(极高活跃度),epoll 的优势就不明显了。

fd 就绪回调链的最初一环源于**网卡硬件中断**——从中断到唤醒等待队列的完整链路见 [../process/interrupts.md](/concepts/process/interrupts.md)。

## 三、LT vs ET:水平触发 vs 边缘触发

`epoll` 支持两种触发模式,通过 `epoll_ctl` 注册时给 event 加不加 `EPOLLET` 标志区分。这是 epoll 用法里最容易踩坑的地方。

- **LT(Level Triggered,水平触发,默认)**:只要 fd 上**还有数据没读完**(缓冲区非空),每次 `epoll_wait` 都会**继续报告**它就绪。像"水位只要还在线上,就一直响警报"。
- **ET(Edge Triggered,边缘触发)**:只在 fd 状态**发生变化的那一刻**(从"没数据"变"有数据")通知**一次**;如果你这次没把数据读干净,下次 `epoll_wait` **不会**再因这批旧数据通知你。像"只在水位跨过线的瞬间响一次"。

```plantuml
@startuml
skinparam shadowing false
skinparam sequence {
  ParticipantBackgroundColor #E3F2FD
  ParticipantBorderColor     #1976D2
  ArrowColor                 #1565C0
  LifeLineBorderColor        #90A4AE
}
participant "内核收到\n1000B 数据" as K
participant "epoll_wait" as E
participant "你的代码\n每次只 read 200B" as U
== LT 模式:没读完就一直报 ==
K -> E : fd 缓冲区有 1000B
E -> U : 报告就绪
U -> U : read 200B (剩 800B)
E -> U : 下次 wait 又报告(还有 800B)
U -> U : read 200B (剩 600B)
E -> U : 继续报告 ... 直到读空
note over U : 简单!不怕漏,分几次读都行
== ET 模式:只在到达那一刻报一次 ==
K -> E : fd 缓冲区来了 1000B(状态变化)
E -> U : **只报告一次**
U -> U : read 200B (剩 800B) 就返回去干别的
note over E #FFCDD2 : 下次 wait **不再报告**这 800B!\n除非又来了**新**数据触发新的边缘
note over U #FFCDD2 : 那 800B 就"卡"在缓冲区里没人读\n—— 漏数据 / 连接假死
@enduml
```

**ET 的正确用法**:收到通知后,必须**循环 read/write 直到返回 `EAGAIN`**(表示缓冲区真的读干净了),而且 fd 必须是**非阻塞**的(否则读干后再 read 会阻塞住整个事件循环)。

对比与选型:

| 维度 | LT(水平触发,默认) | ET(边缘触发) |
|------|--------------------|---------------|
| 通知时机 | 只要还有数据,每次 wait 都报 | 仅状态变化的一刻报一次 |
| 是否会漏 | 不会漏(没读完下次还提醒) | **会漏**(没一次读干,数据卡住) |
| 读法 | 读一次/读一部分都行,可下次再读 | **必须循环读到 EAGAIN** |
| fd 要求 | 阻塞/非阻塞都可 | **必须非阻塞** |
| 唤醒次数 | 多(可能重复通知同一批数据) | 少(每批数据只唤醒一次) |
| 难度 vs 性能 | 简单、不易错、稍多唤醒 | 高性能、唤醒少、**难写对** |

正确的读法示意:

```c
// LT:可以简单读一次(下次没读完还会再通知),但通常也写成循环
n = read(fd, buf, sizeof(buf));   // 处理这次拿到的即可
// ET:必须把内核缓冲区一次性读干净,直到 EAGAIN
while (1) {
    ssize_t n = read(fd, buf, sizeof(buf));
    if (n > 0)        { /* 处理 buf */ continue; }
    if (n == 0)       { /* 对端关闭 */ close_conn(fd); break; }
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) break; // 读干净了,正常退出
        if (errno == EINTR) continue;                        // 被信号打断,重试
        /* 其他错误 */ close_conn(fd); break;
    }
}
```

> 经验法则:**默认用 LT,简单不易错**;只有在明确需要压榨唤醒次数、且能保证"循环读到 EAGAIN + 非阻塞 fd"时才上 ET。ET 写错的典型症状是"偶发连接假死/数据处理不全",极难排查。

## 四、惊群(thundering herd)

**惊群**:多个线程/进程各自 `epoll_wait` 在**同一个 listen fd** 上等新连接。当一个连接到来时,内核把它们**全部唤醒**,但只有一个能 `accept` 成功,其余的醒来一看无事可做又睡回去——白白付出了一堆上下文切换和调度开销(上下文切换代价见 [../process/context-switch.md](/concepts/process/context-switch.md))。

```plantuml
@startuml
skinparam shadowing false
skinparam rectangle {
  BackgroundColor<<w>> #E3F2FD
  BorderColor<<w>>     #1976D2
  BackgroundColor<<bad>> #FFCDD2
  BorderColor<<bad>>   #C62828
}
rectangle "一个新连接到来" as C
rectangle "worker1 epoll_wait" <<w>> as W1
rectangle "worker2 epoll_wait" <<w>> as W2
rectangle "worker3 epoll_wait" <<w>> as W3
C --> W1 : 唤醒
C --> W2 : 唤醒
C --> W3 : 唤醒
note bottom of W1 : accept 成功 ✔
note bottom of W2 : accept 失败,白醒 ✘
note bottom of W3 : accept 失败,白醒 ✘
@enduml
```

演进与解法:

| 方案 | 做法 | 效果 |
|------|------|------|
| 早期 accept 惊群 | 多进程阻塞在 `accept` 同一 fd | 内核后来已修复:只唤醒一个 |
| epoll 惊群(历史问题) | 多个 epoll 实例监视同一 listen fd | 曾经全唤醒,后成经典坑 |
| **EPOLLEXCLUSIVE**(Linux 4.5+) | 注册 listen fd 时加此标志 | 内核**只唤醒一个**等待者,治标解惊群 |
| **SO_REUSEPORT**(Linux 3.9+) | 每个 worker **各自 bind 同一端口、各有独立 listen 队列** | 内核按连接哈希分发,**根本上无惊群、无锁竞争**,还天然负载均衡 |

`SO_REUSEPORT` 是现代高并发服务器(如 Nginx `reuseport`)的标配,它不只是解惊群,还顺带解决了"单点 accept 瓶颈"。其细节和"共享 listen fd vs 各自 listen"的对比见 [thread-models.md](/concepts/network/thread-models.md)。

## 五、用法骨架代码

一个典型的 epoll 事件循环(ET 模式、非阻塞 fd):

```c
#include <sys/epoll.h>
#include <fcntl.h>
#include <unistd.h>
// 把 fd 设为非阻塞(ET 模式必须)
void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
int main() {
    int listen_fd = /* socket + bind + listen,已设非阻塞 */;
    // ① 创建 epoll 实例(create1 可传 EPOLL_CLOEXEC)
    int epfd = epoll_create1(0);
    // ② 把 listen_fd 注册进 epoll,监听可读事件 + 边缘触发
    struct epoll_event ev;
    ev.events  = EPOLLIN | EPOLLET;
    ev.data.fd = listen_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
    struct epoll_event events[1024];
    while (1) {
        // ③ 等待就绪(-1 = 无限阻塞直到有事件);返回值 n = 就绪 fd 数
        int n = epoll_wait(epfd, events, 1024, -1);
        for (int i = 0; i < n; i++) {
            int fd = events[i].data.fd;
            if (fd == listen_fd) {
                // ET 下 accept 也要循环收干,直到 EAGAIN
                while (1) {
                    int conn = accept(listen_fd, NULL, NULL);
                    if (conn < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        break; // 其他错误
                    }
                    set_nonblocking(conn);
                    struct epoll_event cev;
                    cev.events  = EPOLLIN | EPOLLET;
                    cev.data.fd = conn;
                    epoll_ctl(epfd, EPOLL_CTL_ADD, conn, &cev);
                }
            } else if (events[i].events & EPOLLIN) {
                // ET 下必须循环读到 EAGAIN,把缓冲区读干净
                while (1) {
                    char buf[4096];
                    ssize_t r = read(fd, buf, sizeof(buf));
                    if (r > 0)  { /* 处理 buf */ continue; }
                    if (r == 0) { close(fd); break; }      // 对端关闭,内核会自动从 epoll 移除
                    if (r < 0) {
                        if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                        if (errno == EINTR) continue;
                        close(fd); break;
                    }
                }
            }
        }
    }
}
```

要点回顾:`epoll_ctl(ADD)` 只在连接建立时调一次(不是每轮循环调);`epoll_wait` 返回值就是就绪 fd 数,只处理这几个;ET 模式下 `accept` 和 `read` 都要循环到 `EAGAIN`。

## 六、和本仓库其他文档的关系 + 怎么观测

epoll 不是孤立机制,它踩在本仓库进程篇的几块基石上,也能用观测工具看到效果:

| 关注点 | 关系 | 去哪篇 |
|--------|------|--------|
| **fd 就绪的源头** | 就绪链表由**网卡收包中断**驱动:网卡 DMA 收包 → 硬中断 → 软中断走协议栈 → 数据进 socket 缓冲区 → 触发 `ep_poll_callback` 把 fd 挂进就绪链表 | [../process/interrupts.md](/concepts/process/interrupts.md) |
| **epoll_wait 的开销** | `epoll_create`/`epoll_ctl`/`epoll_wait` 都是**系统调用**,每次陷入/返回有固定成本;epoll 快在"一次 `epoll_wait` 批量拿回多个就绪 fd",把 per-fd 的 syscall 摊薄 | [../process/syscall.md](/concepts/process/syscall.md) |
| **看连接状态/数量** | 用 `ss` 看 `LISTEN`/`ESTABLISHED`/`TIME_WAIT` 分布、accept 队列(`Recv-Q`/`Send-Q`)、连接总数——验证"连接多但活跃少" | [../../tools/network/ss.md](/tools/network/ss.md) |
| **epoll 在 IO 模型里的位置** | epoll 是"IO 多路复用"这一类的 Linux 实现,横向和阻塞/非阻塞/异步 IO 对比 | [io-models.md](/concepts/network/io-models.md) |
| **epoll 之上的线程模型** | 单个 `epoll_wait` 循环就是一个 Reactor;多核如何摆放多个 Reactor、配合 `SO_REUSEPORT` | [thread-models.md](/concepts/network/thread-models.md) |

观测小抄:

```bash
# 看某进程在哪些 fd 上等 IO / 是否卡在 epoll_wait
strace -f -e trace=epoll_wait,epoll_ctl,accept4,read -p <PID>
# 看连接数量与状态分布(印证"海量连接、少量活跃")
ss -s                      # 汇总:各状态连接数
ss -tanp | grep ESTAB | wc -l   # 当前 ESTABLISHED 连接数
ss -tlnp                   # LISTEN socket 及其 accept 队列(Recv-Q/Send-Q)
```

> **一句话**:epoll 的就绪由[网卡中断](/concepts/process/interrupts.md)驱动、通过[系统调用](/concepts/process/syscall.md)暴露给应用,想验证"连接多不等于活跃多"就用 [`ss`](/tools/network/ss.md) 看连接分布。

## 七、一句话总结

> **epoll = "红黑树记住全部 fd + 回调把就绪的挑进链表 + epoll_wait 只收就绪的",把 select/poll 的 O(n) 三宗罪降成 O(1);LT 简单不漏、ET 高性能但必须循环读到 EAGAIN 配非阻塞 fd;惊群用 EPOLLEXCLUSIVE 或 SO_REUSEPORT 解决。**

> 相关阅读:epoll 是 IO 多路复用的一种,横向对比见 [io-models.md](/concepts/network/io-models.md);epoll 如何组织成事件循环、配合 Reactor 与 SO_REUSEPORT 见 [thread-models.md](/concepts/network/thread-models.md);fd 就绪的回调链源于网卡中断唤醒,见 [../process/interrupts.md](/concepts/process/interrupts.md);惊群唤醒带来的上下文切换开销见 [../process/context-switch.md](/concepts/process/context-switch.md)。
