高并发服务里最常见的一类卡顿不是“CPU 不够”,而是 大量连接上只有少量真正可读/可写。若对每个连接开线程阻塞 read/write,上下文切换与内存开销会迅速失控;若用轮询扫所有 fd,又会把 CPU 烧掉。Linux 提供的 epoll 就是为“只在就绪时被唤醒”设计的 I/O 事件通知机制。
本文基于 Linux man-pages 的 epoll(7)、epoll_create(2)/epoll_create1(2)、epoll_ctl(2)、epoll_wait(2),并结合 Python selectors 与 Nginx 连接处理文档,把 interest/ready 两张表、LT/ET 语义、ONESHOT、非阻塞与 EAGAIN、常见坑与排障 讲清楚,方便你在自研事件循环或排查 Nginx/Redis 类服务时按机制定位。
一、问题背景:为什么 select/poll 会“长不大”
传统 select(2) / poll(2) 也能多路复用,但工程上有两个长期痛点:
- 每次调用都要反复提交关注集合。调用者把“我关心哪些 fd、关心读还是写”整包传给内核;内核再线性扫描。连接数上来后,用户态↔内核态的拷贝与扫描成本随 N 增长。
- 就绪集合表达方式不友好。
select还有历史性的 fd 数量上限(与FD_SETSIZE相关),大规模长连接场景很难优雅扩展。
epoll 的定位不同:它在内核里维护一个 epoll 实例(本质是“容器 + 回调”),应用 只注册一次兴趣,之后用 epoll_wait 拉取当前就绪事件。man 页明确指出:epoll 既可作 水平触发(level-triggered, LT) 也可作 边缘触发(edge-triggered, ET),并且 能较好扩展到大量被监视的文件描述符。
二、核心机制:interest list 与 ready list
从用户视角看,一个 epoll 实例可理解为两张表(epoll(7) 原文概念):
| 结构 | 含义 |
|---|---|
| interest list(epoll set) | 进程通过 epoll_ctl 注册、希望监视的 fd 集合 |
| ready list | interest list 的子集(更准确:其中“就绪”的引用集合),由内核因 I/O 活动动态填充 |
配套系统调用职责清晰:
| 调用 | 作用 |
|---|---|
epoll_create / epoll_create1 | 创建 epoll 实例,返回 epoll 自身的 fd |
epoll_ctl | 对 interest list 做 ADD / MOD / DEL |
epoll_wait / epoll_pwait | 从 ready list 取回最多 maxevents 个事件;无事件时可阻塞 |
2.1 创建实例:优先 epoll_create1
epoll_create(size) 在 Linux 2.6.8 之后 忽略 size(但仍要求 size > 0)。更推荐 epoll_create1(flags):可传 EPOLL_CLOEXEC,避免 fork+exec 时泄漏 epoll fd(与 open(2) 的 O_CLOEXEC 同理)。
2.2 注册兴趣:epoll_ctl 与事件掩码
struct epoll_event {
uint32_t events; /* EPOLLIN | EPOLLOUT | ... */
epoll_data_t data; /* 用户数据:常存 fd 或指针 */
};
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
常用 op:
EPOLL_CTL_ADD:加入 interest listEPOLL_CTL_MOD:修改已有项的事件掩码/用户数据EPOLL_CTL_DEL:从 interest list 删除
常见事件位(epoll_ctl(2)):
| 标志 | 含义 |
|---|---|
EPOLLIN | 可读(含对端数据、监听 socket 有新连接等) |
EPOLLOUT | 可写 |
EPOLLRDHUP | 流式套接字对端关闭或关闭写半部(2.6.17+,ET 下检测对端关闭很有用) |
EPOLLPRI | 带外/异常条件(语义见 poll(2) 的 POLLPRI) |
EPOLLERR | 错误;无需在 events 里显式设置,epoll_wait 总会报告 |
EPOLLHUP | hang up;同样 总会报告,不必手动设置 |
EPOLLET | 请求 边缘触发(默认是水平触发) |
EPOLLONESHOT | 通知一次后自动禁用该 fd,需 MOD 重新武装 |
EPOLLEXCLUSIVE | 多 epoll 监听同一 fd 时的互斥唤醒(4.5+) |
工程建议:监听 socket 与连接 socket 都建议设为非阻塞;读写方向若会频繁切换,可一次 ADD 时就挂 EPOLLIN|EPOLLOUT,避免来回 MOD 切换。
2.3 等待就绪:epoll_wait
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
要点:
maxevents必须 > 0,表示输出数组容量。timeout单位毫秒:0立即返回;-1一直阻塞;正数限时等待。时间基准为CLOCK_MONOTONIC。- 返回值:就绪事件个数;
0表示超时;-1出错(常见EINTR)。 - 返回的
events[i].events是 实际发生 的事件掩码;data原样带回你在ctl时写入的用户数据。
epoll_wait 的返回策略还会尽量避免 饥饿:若某次只处理“已经知道就绪”的一小撮 fd,而忽略其他已就绪 fd,系统可能长期“看不见”后者。应用层也应做 就绪队列 + 轮转处理,而不是在单次事件上无限 read 到饿死其它连接(man 页 “Starvation” 小节专门提醒)。
三、水平触发 vs 边缘触发:语义决定写法
这是 epoll 最容易写错的地方。epoll(7) 用管道例子讲得很直白:
- 把管道读端
rfd加入 epoll; - 写端写入 2KB;
- 调用
epoll_wait→ 报告rfd可读; - 应用只读了 1KB,缓冲区仍剩数据;
- 再次
epoll_wait:- LT(默认):只要缓冲区仍“可读”,就会 继续 报告就绪;
- ET(
EPOLLET):只在状态 发生变化 时报告;若你没把数据读到“暂时不可读”,可能 不再通知,下一次wait会“卡住”,尽管缓冲里还有数据。
3.1 LT:像 poll,好写,易“吵”
- 语义接近
poll:条件成立就通知。 - 适合:业务逻辑简单、可接受重复通知、希望少踩坑。
- 风险:若某 fd 长期可读但你处理很慢,它会 反复 出现在 ready list,拖慢整个循环。
3.2 ET:变化才通知,必须“抽干 + 非阻塞”
man 页对 ET 的建议可压缩成两条硬规则:
- fd 必须非阻塞。否则一次阻塞
read/write可能饿死处理其它 fd 的任务。 - 只有在
read/write返回EAGAIN(或等价“暂时不可用”)之后,才回去epoll_wait。也就是说,事件到来后要 循环读写直到资源暂时耗尽,不能“读一次就回 wait”。
ET 适合:超高连接数、希望减少重复唤醒、团队能严格遵守非阻塞与 drain 协议的场景。很多高性能网络库默认 ET + 非阻塞。
3.3 EPOLLONESHOT:一次性通知与重武装
设置 EPOLLONESHOT 后,某个 fd 被 epoll_wait 报告一次,就会在 interest list 中被 禁用,直到你用 EPOLL_CTL_MOD 重新启用。它常用于:
- 多线程 worker:保证同一连接同一时刻只被一个线程处理;
- 防止在处理完成前被其它线程再次取到同一事件。
代价是:处理结束后 必须记得 rearm,否则该连接“静音”。
四、最小可运行实验:管道上的 LT vs ET
下面用 Python 演示 同一场景在 LT/ET 下的不同表现(Linux 上 selectors.EpollSelector;其它平台会落到 kqueue/select 等,行为不完全等价)。目标不是抄生产框架,而是把 man 页语义变成可观测实验。
#!/usr/bin/env python3
"""LT vs ET demo on a pipe (Linux)."""
import os, select, sys
def make_pipe():
r, w = os.pipe()
# set non-blocking on read end for ET-safe drain
os.set_blocking(r, False)
os.set_blocking(w, False)
return r, w
def run(edge: bool):
r, w = make_pipe()
ep = select.epoll()
flags = select.EPOLLIN
if edge:
flags |= select.EPOLLET
ep.register(r, flags)
os.write(w, b"A" * 2048)
print("mode:", "ET" if edge else "LT")
# first wait should fire
ev = ep.poll(timeout=0.2)
print(" first poll:", ev)
# only read 1024, leave 1024 in buffer
data = os.read(r, 1024)
print(" partial read:", len(data))
# second wait: LT should still report; ET often does not
ev2 = ep.poll(timeout=0.2)
print(" second poll:", ev2)
# drain remaining
rest = b""
while True:
try:
chunk = os.read(r, 4096)
if not chunk:
break
rest += chunk
except BlockingIOError:
break
print(" drained rest:", len(rest))
ep.close(); os.close(r); os.close(w)
if __name__ == "__main__":
run(edge=False)
print("---")
run(edge=True)
预期现象(内核版本与缓冲细节可能略有差异,但方向稳定):
- LT:partial read 后第二次
poll仍可能看到EPOLLIN(缓冲仍可读)。 - ET:partial read 后第二次
poll往往 超时为空,直到再次有“新数据写入”或你换回 LT/ONESHOT 策略。
这正是生产代码里 “ET 下偶发假死:连接其实有数据,事件循环却不再被唤醒” 的最小复现。
4.1 监听 socket 的标准骨架(伪代码)
/* listener, conn 均为 nonblocking */
epoll_ctl(epfd, EPOLL_CTL_ADD, listener, &(struct epoll_event){
.events = EPOLLIN | (use_et ? EPOLLET : 0),
.data.fd = listener,
});
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 == listener) {
for (;;) { /* ET 下要循环 accept 到 EAGAIN */
int c = accept(listener, ...);
if (c < 0) { if (errno == EAGAIN) break; /* handle error */ }
set_nonblocking(c);
epoll_ctl(epfd, EPOLL_CTL_ADD, c, &(struct epoll_event){
.events = EPOLLIN | EPOLLRDHUP | (use_et ? EPOLLET : 0),
.data.fd = c,
});
}
} else {
do_use_fd(fd); /* ET: 读写到 EAGAIN;注意 EPOLLERR/HUP */
}
}
}
do_use_fd 在 ET 下必须把 可读数据读尽、可写缓冲区写满到 EAGAIN;半包协议要在用户态自己做缓冲拼装。
五、工程映射:语言运行时与 Nginx
5.1 Python selectors
标准库 selectors 把 epoll/kqueue/select 统一成高层 API:
DefaultSelector:当前平台最高效实现的别名(Linux 上通常是EpollSelector)。EVENT_READ/EVENT_WRITE:跨平台事件抽象。register/unregister/modify/select:对应 ctl + wait 的职责拆分。
自研 asyncio 之外的小工具时,优先用 selectors,比直接 select.epoll 更易移植;但若你要精细控制 EPOLLET/ONESHOT,仍需回到平台原生接口。
5.2 Nginx 的连接处理方式
Nginx 文档 Connection processing methods 写明:Linux 2.6+ 使用高效的 epoll 方法,并支持 EPOLLRDHUP、EPOLLEXCLUSIVE 等标志。平台支持多种方法时,Nginx 会自动选最高效者,也可用 use 指令显式指定。这解释了为何“同一套配置在 Linux 与 FreeBSD 上行为细节不同”——底层分别是 epoll 与 kqueue。
六、常见坑与排查清单
| 症状 | 更可能的机制原因 | 排查/修复 |
|---|---|---|
| ET 下连接“有数据但不再触发” | partial read 后未 drain 到 EAGAIN | 循环读;确认非阻塞;对照 man 管道示例 |
| 单连接阻塞拖死整进程 | 阻塞 fd + 同步 read/write | 全部 O_NONBLOCK;慢业务丢线程池/协程 |
| 某连接饿死其它连接 | 单 fd 上无限处理大流量 | 就绪列表 + 轮转;限制单次读写字节/次数 |
| ONESHOT 后永久静音 | 处理完未 EPOLL_CTL_MOD rearm | 统一在处理结束路径 rearm |
| 对端已关仍不清理 | 只看 EPOLLIN,忽略挂断 | 处理 EPOLLRDHUP/EPOLLHUP/EPOLLERR,读到 0 关闭 |
epoll_ctl ADD 报 EEXIST | 重复 ADD 同一 fd | 用 MOD 改掩码,或先 DEL |
epoll_wait 被信号打断 | 返回 -1 + EINTR | 循环重试,或使用 epoll_pwait 管理信号掩码 |
| 多进程抢同一监听 socket | 惊群/重复唤醒 | 评估 EPOLLEXCLUSIVE、SO_REUSEPORT 等策略 |
排障时建议同时抓三份证据:
- fd 是否非阻塞(
fcntl标志); - interest 掩码是否含
EPOLLET/ONESHOT/RDHUP; - 一次事件处理是否读/写到 EAGAIN,以及是否处理 hangup。
七、和 select/poll 的选型建议
- 小工具、fd 很少、要极致可移植:
poll/select或高层封装足够。 - Linux 上万级长连接、自研事件循环:epoll(多数生产库默认 ET + 非阻塞)。
- 跨 Unix 可移植高性能库:用 libuv / netty / Go runtime 等已封装的多路复用层,而不是在业务里手写三套 epoll/kqueue/IOCP。
- 只做业务、不碰事件循环:理解 LT/ET 与非阻塞,是为了读懂框架文档和 flame graph,而不是 reinvent。
八、总结
- epoll 在内核维护 interest list + ready list,用
create/ctl/wait三件套把“注册”和“等待”拆开,从而在大规模 fd 上更省。 - 默认 LT:条件成立就通知,写法像 poll;ET:状态变化才通知,必须 非阻塞 + 读/写到 EAGAIN。
EPOLLONESHOT适合多线程串行化处理,但务必 处理完 rearm;EPOLLRDHUP/HUP/ERR是连接回收的关键信号。- 饥饿、假死、惊群,大多不是“epoll 不灵”,而是 事件语义与读写协议不匹配。先复现 LT/ET 差异,再改生产代码。
掌握这些机制后,再看 Nginx worker、Redis 事件循环、Netty EventLoop 或自研网关,会发现它们都在同一套规则上做工程化封装。
参考资料
- Linux man-pages: epoll(7) — interest/ready、LT/ET、ONESHOT、建议用法与陷阱
- Linux man-pages: epoll_ctl(2) — 事件标志、ADD/MOD/DEL、EPOLLET/ONESHOT/EXCLUSIVE
- Linux man-pages: epoll_wait(2) — timeout、maxevents、就绪返回语义
- Linux man-pages: epoll_create(2) / epoll_create1(2) — 创建实例与
EPOLL_CLOEXEC - Python 3 Docs: selectors — High-level I/O multiplexing —
EpollSelector/DefaultSelector - Nginx Docs: Connection processing methods — Linux 上 epoll 方法及 RDHUP/EXCLUSIVE 支持说明