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

一句话痛点:明明只有几个 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。

三个系统调用的分工:
| 调用 | 做什么 | 复杂度 |
|---|---|---|
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 就绪回调链的最初一环源于网卡硬件中断——从中断到唤醒等待队列的完整链路见 interrupts。
三、LT vs ET:水平触发 vs 边缘触发
epoll 支持两种触发模式,通过 epoll_ctl 注册时给 event 加不加 EPOLLET 标志区分。这是 epoll 用法里最容易踩坑的地方。
- LT(Level Triggered,水平触发,默认):只要 fd 上还有数据没读完(缓冲区非空),每次
epoll_wait都会继续报告它就绪。像"水位只要还在线上,就一直响警报"。 - ET(Edge Triggered,边缘触发):只在 fd 状态发生变化的那一刻(从"没数据"变"有数据")通知一次;如果你这次没把数据读干净,下次
epoll_wait不会再因这批旧数据通知你。像"只在水位跨过线的瞬间响一次"。

ET 的正确用法:收到通知后,必须循环 read/write 直到返回 EAGAIN(表示缓冲区真的读干净了),而且 fd 必须是非阻塞的(否则读干后再 read 会阻塞住整个事件循环)。
对比与选型:
| 维度 | LT(水平触发,默认) | ET(边缘触发) |
|---|---|---|
| 通知时机 | 只要还有数据,每次 wait 都报 | 仅状态变化的一刻报一次 |
| 是否会漏 | 不会漏(没读完下次还提醒) | 会漏(没一次读干,数据卡住) |
| 读法 | 读一次/读一部分都行,可下次再读 | 必须循环读到 EAGAIN |
| fd 要求 | 阻塞/非阻塞都可 | 必须非阻塞 |
| 唤醒次数 | 多(可能重复通知同一批数据) | 少(每批数据只唤醒一次) |
| 难度 vs 性能 | 简单、不易错、稍多唤醒 | 高性能、唤醒少、难写对 |
正确的读法示意:
// 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 成功,其余的醒来一看无事可做又睡回去——白白付出了一堆上下文切换和调度开销(上下文切换代价见 context-switch)。

演进与解法:
| 方案 | 做法 | 效果 |
|---|---|---|
| 早期 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。
五、用法骨架代码
一个典型的 epoll 事件循环(ET 模式、非阻塞 fd):
#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 挂进就绪链表 | interrupts |
| epoll_wait 的开销 | epoll_create/epoll_ctl/epoll_wait 都是系统调用,每次陷入/返回有固定成本;epoll 快在"一次 epoll_wait 批量拿回多个就绪 fd",把 per-fd 的 syscall 摊薄 | syscall |
| 看连接状态/数量 | 用 ss 看 LISTEN/ESTABLISHED/TIME_WAIT 分布、accept 队列(Recv-Q/Send-Q)、连接总数——验证"连接多但活跃少" | ss |
| epoll 在 IO 模型里的位置 | epoll 是"IO 多路复用"这一类的 Linux 实现,横向和阻塞/非阻塞/异步 IO 对比 | io-models |
| epoll 之上的线程模型 | 单个 epoll_wait 循环就是一个 Reactor;多核如何摆放多个 Reactor、配合 SO_REUSEPORT | thread-models |
观测小抄:
# 看某进程在哪些 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 = "红黑树记住全部 fd + 回调把就绪的挑进链表 + epoll_wait 只收就绪的",把 select/poll 的 O(n) 三宗罪降成 O(1);LT 简单不漏、ET 高性能但必须循环读到 EAGAIN 配非阻塞 fd;惊群用 EPOLLEXCLUSIVE 或 SO_REUSEPORT 解决。
相关阅读:epoll 是 IO 多路复用的一种,横向对比见 io-models;epoll 如何组织成事件循环、配合 Reactor 与 SO_REUSEPORT 见 thread-models;fd 就绪的回调链源于网卡中断唤醒,见 interrupts;惊群唤醒带来的上下文切换开销见 context-switch。