ET 模式只读了一次,为什么连接会永久卡住?从 `epoll` 就绪语义讲清楚
时间:2026/04/10
关键词:
epoll、LT、ET、非阻塞 I/O、Reactor、timerfd、eventfd、signalfd
一个使用边沿触发(edge-triggered,ET)的服务器收到读事件后,只调用了一次 recv():
1 | /* 错误示例:ET 模式收到事件后只读一次 */ |
客户端明明发送了完整请求,服务器却只处理了一部分,随后连接一直保持 ESTABLISHED,CPU 也不高,epoll_wait() 再没有返回这个 fd。
最反直觉的地方是:socket 接收缓冲区中可能还有数据,事件循环却继续睡眠。
这不是 epoll 丢了数据,而是程序误解了 ET 的契约:
ET 通知的是“就绪状态发生变化”,不是“这里有一条消息”。收到边沿后,应用必须把非阻塞 I/O 做到
EAGAIN,才能确定这一轮就绪已经被消费干净。
本文会用一个无需启动服务器的 Linux C11 实验复现这个现象,再把正确的读、写、accept()、定时器、跨线程通知和信号处理组合成完整 Reactor 思路。
一、epoll 告诉你的究竟是什么?
epoll 维护两个概念上的集合:
1 | interest list:应用注册了哪些 fd、关心哪些事件 |
epoll_wait() 从 ready list 取得事件。它告诉应用“现在执行某类 I/O 不会按通常方式阻塞”,但不会告诉你:
- 有多少字节可读;
- 一条业务消息是否完整;
- 一次
read()是否能消费全部数据; - 一次
write()是否能发送完整响应; - 事件到达后 fd 是否仍然有效。
TCP 又是字节流,同一次客户端发送可以被拆成多次读取,多次发送也可能在输入缓冲中连在一起。因此真正的数据流是:
1 | epoll 通知可读 |
把一次 epoll 事件、一次 recv() 和一条消息画上等号,是事件驱动服务器最常见的错误之一。
二、LT 和 ET 的差异到底在哪里?
| 模式 | 何时继续报告 | 应用可以只处理一部分吗 | 主要风险 |
|---|---|---|---|
| LT(水平触发) | 只要就绪条件仍成立,就会继续报告 | 可以,下一轮通常仍会提醒 | 重复通知更多 |
| ET(边沿触发) | 主要在就绪状态变化时报告 | 不能处理后就直接遗忘,必须追踪到 EAGAIN |
fd 内有数据却再无新边沿 |
LT 是默认行为。它像一个持续亮着的指示灯:只要接收缓冲区还有数据,下一轮仍可能看到可读。
ET 更像一次门铃:从“没有数据”变成“有数据”时响一次。你开门只拿走一半包裹,门外剩余包裹不会因为仍在那里而再次按铃。
类比只能帮助直觉,准确规则仍是:ET 应使用非阻塞 fd,并把当前就绪方向处理到返回 EAGAIN 或 EWOULDBLOCK,再回到 epoll_wait()。
ET 并不天然比 LT 快。它可能减少重复通知,却提高状态管理、测试和排障成本。业务解析、数据库、锁竞争或内存复制往往才是真正瓶颈。
三、可运行实验:缓冲区还有 5 字节,第二次等待却超时
下面的程序创建两组本地流式 socketpair:
- 第一组用错误方式只读取 3 字节,再次
epoll_wait(); - 第二组循环读取,直到得到
EAGAIN。
它不需要监听端口、外部客户端或后台进程。
运行条件:Linux、glibc、C11。epoll 以及这里使用的 socket 创建标志是 Linux 接口,不能直接在 macOS 或 Windows 编译。
1 |
|
在 Linux 上编译运行:
1 | cc -std=c11 -O2 -Wall -Wextra -Wpedantic \ |
预期输出:
1 | buggy read=ABC, second epoll events=0, bytes still readable=5 |
第一行证明了问题的核心:第二次等待没有事件,但随后直接读取仍能拿到 5 字节。第二行则证明,小缓冲区不是问题;只要持续读取到 EAGAIN,8 字节仍能被完整消费。
四、为什么第二次 epoll_wait() 没有报告可读?
第一组实验的状态变化是:
1 | 接收缓冲:空 |
内核没有吞掉 DEFGH,只是没有义务为同一持续就绪状态重复产生 ET 通知。
正确版本不断调用 read():
1 | read 3 → ABC |
EAGAIN 在这里不是失败,而是本轮处理完成的边界:“当前已经没有数据能在不阻塞的前提下继续读取”。
这也是为什么 ET 与阻塞 fd 不能安全组合。若最后一次 read() 在没有数据时真的阻塞,整个事件循环就会停在一个连接上,无法服务其他 fd。
五、监听 socket 使用 ET 时,accept() 也要循环吗?
需要。一个监听事件到达时,已完成连接队列里可能有多个连接。只 accept() 一次会留下其他已完成连接,却不一定再获得新边沿。
Linux 上常见写法:
1 | for (;;) { |
accept4() 在创建连接 fd 时原子设置非阻塞和 close-on-exec。它是 Linux 扩展;其他平台要使用各自接口或在 accept() 后通过 fcntl() 设置,并考虑多线程下的竞态窗口。
EMFILE 与 ENFILE 不是普通连接错误,分别提示进程或系统级 fd 资源压力。事件循环若继续无日志高速重试,可能形成错误风暴。服务应监控 fd 使用、限制连接,并设计过载处理。
六、非阻塞写为什么也需要状态机?
socket 可写不代表整个响应都能写完。发送路径必须维护:
1 | output buffer |
典型流程是:
1 | 业务产生响应 |
ET 下可写事件也应处理到输出队列为空,或 send() 返回 EAGAIN。但不要让 EPOLLOUT 永久常驻:绝大多数 TCP socket 经常可写,持续关注会让事件循环获得大量无意义通知。只有确实存在未发送数据时才启用,排空后通过 EPOLL_CTL_MOD 移除。
send() 返回正数可能小于请求长度。EAGAIN 表示暂时写不下;EINTR 通常重试;对已关闭连接发送可能得到 EPIPE 并触发 SIGPIPE。Linux 网络代码可按项目策略使用 MSG_NOSIGNAL,或在进程级忽略 SIGPIPE。
输出缓冲还必须有上限。非阻塞 I/O 避免线程睡眠,却不会阻止慢客户端让内存持续积压。
七、读到 EAGAIN 会不会让一个繁忙连接饿死其他连接?
有可能。一个连接持续涌入大量数据时,无限制地“读到 EAGAIN”可能长期占据事件循环。Linux epoll(7) 也把 starvation 列为 ET 的典型风险。
解决问题不能简单地“读够 64 KiB 就直接忘掉 fd”,因为此时尚未读到 EAGAIN,也未必会有新边沿。常见设计是:
- 为单轮处理设置字节或消息预算;
- 把仍处于 ready 状态的连接加入应用自己的 ready queue;
- 轮转处理其他连接后,显式继续该连接;
- 用状态位避免同一 fd 被重复加入 ready queue。
也可以先使用 LT,让内核持续报告仍就绪的 fd,换取更简单的公平性。ET 减少通知并不代表可以丢掉应用层调度状态。
如果某个 fd 已在本批事件前面被关闭,还要防止后续缓存事件误指向复用后的 fd。生产实现常在 epoll_event.data.ptr 中保存带生命周期控制的连接对象,或使用“fd + generation”校验,而不是只相信裸 fd 数字。
八、EPOLLERR、EPOLLHUP 和半关闭应该怎样处理?
EPOLLERR 与 EPOLLHUP 即使没有显式注册也会被报告。收到它们不等于可以立刻丢弃所有输入:对于流式对象,peer 关闭后,缓冲区里仍可能有未消费数据,读完后才会得到 EOF。
常见检查包括:
1 | int socket_error = 0; |
EPOLLRDHUP 表示流式 socket 的对端关闭连接或关闭写方向,适合尽早发现半关闭。但真正的资源处理要结合:
- 输入是否已读到 EOF;
- 协议请求是否完整;
- 输出缓冲是否还要排空;
- 本端是否允许 half-close;
- 连接对象是否还有 worker 任务引用。
多线程共同从一个 epoll 实例处理连接时,EPOLLONESHOT 可以让某 fd 报告一次后暂时失活,处理完再用 EPOLL_CTL_MOD rearm。它能降低同一连接被并发处理的机会,但不能替代明确的连接所有权、引用生命周期和重入保护。
九、怎样把时间、线程通知和信号也放进 epoll?
Linux 提供三类“可读 fd”,让 Reactor 不只处理 socket:
1 | socket fd → 网络读写 |
timerfd:超时次数也可能大于 1
1 | int timer_fd = timerfd_create( |
触发后读取一个 uint64_t,值表示自上次成功读取以来累计过期多少次。事件循环卡顿 5 秒,1 秒周期定时器的计数可能是 5,而不是 1。业务应根据语义选择补执行、合并执行或只更新到最新时间,不能默认每个 tick 都会单独回调。
大量连接超时通常不应每个连接创建一个 timerfd。时间轮或最小堆可用一个底层 timerfd 驱动,减少 fd 和内核对象数量。
eventfd:通知不是任务本身
1 | int wake_fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC); |
worker 先把结果放进有界队列,再向 eventfd 写入一个 uint64_t。I/O 线程读出累计计数后批量排空结果队列。
真正的数据在队列中,eventfd 只是唤醒和计数提示。这样即使多个通知被合并,也不会丢失任务内容。队列和 eventfd 的写入顺序、内存同步、满队列策略仍需明确。
signalfd:先阻塞,再读取
1 | sigset_t mask; |
目标信号应在所有可能接收它的线程中保持阻塞。通常在创建 worker 线程之前设置 mask,让新线程继承,再由 Reactor 从 signalfd 读取 signalfd_siginfo。否则信号仍可能按传统方式递送给某个未阻塞线程。
这些接口都是 Linux 专属。libevent、libuv 等事件库已经封装了部分平台差异时,应优先遵循项目现有抽象,不要在上层再次拼装一套并行事件系统。
十、一个完整 Reactor 中,数据和所有权怎样流动?
稳定的分层通常是:
1 | listen fd |
I/O 线程负责 socket、协议输入状态和输出顺序;worker 处理不应阻塞事件循环的业务。worker 不直接 close(fd),因为 fd 可能已经关闭并复用给另一条连接。它返回连接 ID、generation 和请求 ID,由 owner 线程决定结果是否仍有效。
输入缓冲、任务队列、结果队列和输出缓冲全部需要容量上限。只限制其中一个,压力就会转移到下一个无限容器。
协议解析必须增量进行。无论是 HTTP 还是自定义长度前缀协议,一次 read() 都不等于一条请求。解析器应限制请求行、header、body 和帧长度,并为“迟迟不发完头部”的连接设置超时。
十一、ET Reactor 最容易忽略哪些工程边界?
1. 非阻塞 connect 的“可写”不等于成功
connect() 返回 EINPROGRESS 后,通过可写事件获知操作完成,再用 getsockopt(SOL_SOCKET, SO_ERROR) 读取最终结果。只看到 EPOLLOUT 就宣布成功会把连接拒绝也当成成功。
2. EAGAIN 不是错误日志
它是正常控制流边界。如果每次 EAGAIN 都记录 ERROR,高负载时日志本身会成为瓶颈。真正错误要保留 errno、连接和协议上下文。
3. 关闭 fd 不代表所有旧引用立即消失
同一 open file description 可能被 dup() 或 fork() 复制引用。批量返回的 epoll events、worker 任务和定时器也可能仍引用连接对象。需要显式生命周期策略,而不是看到 close() 就认为内存可以立刻释放。
4. 定时器扫描也可能阻塞事件循环
每秒遍历百万连接检查超时,会制造周期性延迟尖峰。应使用按到期时间组织的数据结构,并限制单轮清理预算。
5. ET 不会自动提供背压
读得更彻底可能更快地把压力搬到用户态。慢 worker 和慢客户端仍需要暂停读取、容量上限、deadline 和拒绝策略。
十二、常见误区
误区 1:一次 epoll_wait() 对应一次完整 I/O
它只报告就绪状态。实际字节数、消息边界和部分写仍由应用处理。
误区 2:ET 少通知,所以一定比 LT 高性能
如果瓶颈在业务、锁或网络,ET 收益可能不可见,错误和公平调度成本却真实存在。应先测量。
误区 3:读到短数据就一定已经读空
对某些流式 fd,在特定条件下短读可表明当前数据耗尽,但通用且不易误用的 ET 规则仍是非阻塞循环到 EAGAIN;数据报、终端等对象不能套用所有流式假设。
误区 4:收到 HUP 应立即关闭,不必再读
peer 关闭时缓冲区仍可能有数据。应先按协议和 EOF 语义消费已有输入。
误区 5:eventfd 的计数就是任务数量
通知可能合并,队列操作也可能失败。任务队列才是事实来源,eventfd 用于唤醒。
误区 6:EPOLLONESHOT 让连接天然线程安全
它只暂时禁用后续通知。连接对象、任务回投和关闭竞态仍需所有权与生命周期控制。
十三、什么时候选择 LT,什么时候选择 ET?
优先选择 LT 的场景:
- 首先需要把协议和连接状态机做正确;
- 团队更重视简单性与可排障性;
- 单轮只想处理有限预算,并依赖内核再次提醒;
- 尚未证明重复就绪通知是瓶颈。
考虑 ET 的场景:
- 所有 fd 都明确是非阻塞;
- 所有 accept/read/write 路径都经过短读、部分写和
EAGAIN测试; - 应用有自己的 ready 状态和公平调度;
- 连接生命周期、rearm 和跨线程所有权已经明确;
- 基准测试证明通知或
epoll_ctl开销值得这份复杂度。
如果项目已经采用成熟事件库,应使用库暴露的语义和缓冲模型,而不是绕过它直接操作同一 fd 的 epoll 注册。
十四、总结
回到开头:ET 服务器只读一次后永久卡住,是因为数据仍在 fd 中,但“从不可读变为可读”的边沿已经被消费,程序又错误地回到了等待。
最重要的结论是:
epoll报告 I/O 就绪,不报告业务消息;- ET 必须配合非阻塞 fd,并把当前方向处理到
EAGAIN; accept()、读取和发送都可能需要循环,部分写必须保存进度;- 为避免单连接饥饿,可以维护应用 ready queue,但不能在未到
EAGAIN时遗忘该 fd; timerfd、eventfd和signalfd能把不同事件统一进 Reactor,但不替代队列和状态设计;- LT 更容易正确,只有测量证明值得时才应承担 ET 的复杂度。
可以直接用于代码审查的一条建议是:搜索所有带 EPOLLET 的注册点,再沿调用链确认每个 accept/read/write 分支最终都明确到达 EAGAIN、EOF 或不可恢复错误。