ET 模式只读了一次,为什么连接会永久卡住?从 `epoll` 就绪语义讲清楚

时间:2026/04/10

关键词:epoll、LT、ET、非阻塞 I/O、Reactor、timerfdeventfdsignalfd

一个使用边沿触发(edge-triggered,ET)的服务器收到读事件后,只调用了一次 recv()

1
2
3
4
5
6
/* 错误示例:ET 模式收到事件后只读一次 */
char buffer[1024];
ssize_t n = recv(fd, buffer, sizeof(buffer), 0);
if (n > 0) {
parse(buffer, (size_t)n);
}

客户端明明发送了完整请求,服务器却只处理了一部分,随后连接一直保持 ESTABLISHED,CPU 也不高,epoll_wait() 再没有返回这个 fd。

最反直觉的地方是:socket 接收缓冲区中可能还有数据,事件循环却继续睡眠。

这不是 epoll 丢了数据,而是程序误解了 ET 的契约:

ET 通知的是“就绪状态发生变化”,不是“这里有一条消息”。收到边沿后,应用必须把非阻塞 I/O 做到 EAGAIN,才能确定这一轮就绪已经被消费干净。

本文会用一个无需启动服务器的 Linux C11 实验复现这个现象,再把正确的读、写、accept()、定时器、跨线程通知和信号处理组合成完整 Reactor 思路。


一、epoll 告诉你的究竟是什么?

epoll 维护两个概念上的集合:

1
2
interest list:应用注册了哪些 fd、关心哪些事件
ready list:当前有哪些注册对象被认为已就绪

epoll_wait() 从 ready list 取得事件。它告诉应用“现在执行某类 I/O 不会按通常方式阻塞”,但不会告诉你:

  • 有多少字节可读;
  • 一条业务消息是否完整;
  • 一次 read() 是否能消费全部数据;
  • 一次 write() 是否能发送完整响应;
  • 事件到达后 fd 是否仍然有效。

TCP 又是字节流,同一次客户端发送可以被拆成多次读取,多次发送也可能在输入缓冲中连在一起。因此真正的数据流是:

1
2
3
4
5
6
7
epoll 通知可读

read/recv 把字节追加到连接输入缓冲

协议解析器尝试提取 0 到多条完整消息

剩余半包继续保留,等待后续字节

把一次 epoll 事件、一次 recv() 和一条消息画上等号,是事件驱动服务器最常见的错误之一。


二、LT 和 ET 的差异到底在哪里?

模式 何时继续报告 应用可以只处理一部分吗 主要风险
LT(水平触发) 只要就绪条件仍成立,就会继续报告 可以,下一轮通常仍会提醒 重复通知更多
ET(边沿触发) 主要在就绪状态变化时报告 不能处理后就直接遗忘,必须追踪到 EAGAIN fd 内有数据却再无新边沿

LT 是默认行为。它像一个持续亮着的指示灯:只要接收缓冲区还有数据,下一轮仍可能看到可读。

ET 更像一次门铃:从“没有数据”变成“有数据”时响一次。你开门只拿走一半包裹,门外剩余包裹不会因为仍在那里而再次按铃。

类比只能帮助直觉,准确规则仍是:ET 应使用非阻塞 fd,并把当前就绪方向处理到返回 EAGAINEWOULDBLOCK,再回到 epoll_wait()

ET 并不天然比 LT 快。它可能减少重复通知,却提高状态管理、测试和排障成本。业务解析、数据库、锁竞争或内存复制往往才是真正瓶颈。


三、可运行实验:缓冲区还有 5 字节,第二次等待却超时

下面的程序创建两组本地流式 socketpair

  1. 第一组用错误方式只读取 3 字节,再次 epoll_wait()
  2. 第二组循环读取,直到得到 EAGAIN

它不需要监听端口、外部客户端或后台进程。

运行条件:Linux、glibc、C11。epoll 以及这里使用的 socket 创建标志是 Linux 接口,不能直接在 macOS 或 Windows 编译。

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
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
#define _GNU_SOURCE

#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>

#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

static void fail(const char *operation) {
perror(operation);
exit(EXIT_FAILURE);
}

static int create_et_case(int sockets[2]) {
if (socketpair(AF_UNIX,
SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC,
0, sockets) < 0) {
fail("socketpair");
}

int epoll_fd = epoll_create1(EPOLL_CLOEXEC);
if (epoll_fd < 0) {
fail("epoll_create1");
}

struct epoll_event event = {
.events = EPOLLIN | EPOLLET,
.data.fd = sockets[1]
};
if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, sockets[1], &event) < 0) {
fail("epoll_ctl");
}
return epoll_fd;
}

static void write_message(int fd) {
static const char message[] = "ABCDEFGH";
ssize_t n;
do {
n = write(fd, message, sizeof(message) - 1);
} while (n < 0 && errno == EINTR);

if (n != (ssize_t)(sizeof(message) - 1)) {
fail("write");
}
}

static int wait_once(int epoll_fd, int timeout_ms) {
struct epoll_event event;
int count;
do {
count = epoll_wait(epoll_fd, &event, 1, timeout_ms);
} while (count < 0 && errno == EINTR);

if (count < 0) {
fail("epoll_wait");
}
return count;
}

static size_t drain_until_eagain(int fd,
char *output,
size_t capacity,
int *reached_eagain) {
char chunk[3];
size_t total = 0;
*reached_eagain = 0;

for (;;) {
ssize_t n = read(fd, chunk, sizeof(chunk));
if (n > 0) {
if (total + (size_t)n > capacity) {
errno = EOVERFLOW;
fail("drain buffer");
}
memcpy(output + total, chunk, (size_t)n);
total += (size_t)n;
continue;
}
if (n == 0) {
return total;
}
if (errno == EINTR) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
*reached_eagain = 1;
return total;
}
fail("read");
}
}

static void close_case(int epoll_fd, int sockets[2]) {
close(epoll_fd);
close(sockets[0]);
close(sockets[1]);
}

int main(void) {
int sockets[2];
int epoll_fd = create_et_case(sockets);
write_message(sockets[0]);

if (wait_once(epoll_fd, 1000) != 1) {
fprintf(stderr, "first event did not arrive\n");
return EXIT_FAILURE;
}

char first[4] = {0};
ssize_t first_count = read(sockets[1], first, 3);
if (first_count != 3) {
fail("first read");
}

int second_events = wait_once(epoll_fd, 100);
char remaining[16] = {0};
int reached_eagain = 0;
size_t remaining_count = drain_until_eagain(
sockets[1], remaining, sizeof(remaining), &reached_eagain);

printf("buggy read=%s, second epoll events=%d, "
"bytes still readable=%zu\n",
first, second_events, remaining_count);
close_case(epoll_fd, sockets);

epoll_fd = create_et_case(sockets);
write_message(sockets[0]);
if (wait_once(epoll_fd, 1000) != 1) {
fprintf(stderr, "fixed case event did not arrive\n");
return EXIT_FAILURE;
}

char complete[16] = {0};
size_t complete_count = drain_until_eagain(
sockets[1], complete, sizeof(complete), &reached_eagain);
printf("correct drain bytes=%zu, reached EAGAIN=%s, data=%s\n",
complete_count, reached_eagain ? "yes" : "no", complete);

close_case(epoll_fd, sockets);
return second_events == 0 && remaining_count == 5 &&
complete_count == 8 && reached_eagain
? EXIT_SUCCESS
: EXIT_FAILURE;
}

在 Linux 上编译运行:

1
2
3
cc -std=c11 -O2 -Wall -Wextra -Wpedantic \
epoll_et_demo.c -o epoll_et_demo
./epoll_et_demo

预期输出:

1
2
buggy read=ABC, second epoll events=0, bytes still readable=5
correct drain bytes=8, reached EAGAIN=yes, data=ABCDEFGH

第一行证明了问题的核心:第二次等待没有事件,但随后直接读取仍能拿到 5 字节。第二行则证明,小缓冲区不是问题;只要持续读取到 EAGAIN,8 字节仍能被完整消费。


四、为什么第二次 epoll_wait() 没有报告可读?

第一组实验的状态变化是:

1
2
3
4
5
6
7
接收缓冲:空
↓ 对端写入 ABCDEFGH
接收缓冲:空 → 非空,产生一个 EPOLLIN 边沿
↓ 应用只读取 ABC
接收缓冲:仍然非空,但没有发生新的“空 → 非空”变化

第二次 epoll_wait 超时

内核没有吞掉 DEFGH,只是没有义务为同一持续就绪状态重复产生 ET 通知。

正确版本不断调用 read()

1
2
3
4
read 3 → ABC
read 3 → DEF
read 2 → GH
read → -1, errno=EAGAIN

EAGAIN 在这里不是失败,而是本轮处理完成的边界:“当前已经没有数据能在不阻塞的前提下继续读取”。

这也是为什么 ET 与阻塞 fd 不能安全组合。若最后一次 read() 在没有数据时真的阻塞,整个事件循环就会停在一个连接上,无法服务其他 fd。


五、监听 socket 使用 ET 时,accept() 也要循环吗?

需要。一个监听事件到达时,已完成连接队列里可能有多个连接。只 accept() 一次会留下其他已完成连接,却不一定再获得新边沿。

Linux 上常见写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
for (;;) {
int client_fd = accept4(listen_fd, NULL, NULL,
SOCK_NONBLOCK | SOCK_CLOEXEC);
if (client_fd >= 0) {
register_connection(client_fd);
continue;
}
if (errno == EINTR || errno == ECONNABORTED) {
continue;
}
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break;
}
handle_accept_error(errno);
break;
}

accept4() 在创建连接 fd 时原子设置非阻塞和 close-on-exec。它是 Linux 扩展;其他平台要使用各自接口或在 accept() 后通过 fcntl() 设置,并考虑多线程下的竞态窗口。

EMFILEENFILE 不是普通连接错误,分别提示进程或系统级 fd 资源压力。事件循环若继续无日志高速重试,可能形成错误风暴。服务应监控 fd 使用、限制连接,并设计过载处理。


六、非阻塞写为什么也需要状态机?

socket 可写不代表整个响应都能写完。发送路径必须维护:

1
2
3
4
output buffer
current offset
是否正在关注 EPOLLOUT
连接是否处于关闭/半关闭状态

典型流程是:

1
2
3
4
5
6
7
业务产生响应
→ 追加到有上限的输出缓冲
→ 尝试 send
├─ 全部发送:取消 EPOLLOUT
├─ 部分发送:推进 offset,继续尝试
├─ EAGAIN:保留剩余数据,启用 EPOLLOUT
└─ EPIPE/ECONNRESET:关闭连接

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,也未必会有新边沿。常见设计是:

  1. 为单轮处理设置字节或消息预算;
  2. 把仍处于 ready 状态的连接加入应用自己的 ready queue;
  3. 轮转处理其他连接后,显式继续该连接;
  4. 用状态位避免同一 fd 被重复加入 ready queue。

也可以先使用 LT,让内核持续报告仍就绪的 fd,换取更简单的公平性。ET 减少通知并不代表可以丢掉应用层调度状态。

如果某个 fd 已在本批事件前面被关闭,还要防止后续缓存事件误指向复用后的 fd。生产实现常在 epoll_event.data.ptr 中保存带生命周期控制的连接对象,或使用“fd + generation”校验,而不是只相信裸 fd 数字。


八、EPOLLERREPOLLHUP 和半关闭应该怎样处理?

EPOLLERREPOLLHUP 即使没有显式注册也会被报告。收到它们不等于可以立刻丢弃所有输入:对于流式对象,peer 关闭后,缓冲区里仍可能有未消费数据,读完后才会得到 EOF。

常见检查包括:

1
2
3
4
5
6
int socket_error = 0;
socklen_t length = sizeof(socket_error);
if (getsockopt(fd, SOL_SOCKET, SO_ERROR,
&socket_error, &length) < 0) {
/* getsockopt 本身失败 */
}

EPOLLRDHUP 表示流式 socket 的对端关闭连接或关闭写方向,适合尽早发现半关闭。但真正的资源处理要结合:

  • 输入是否已读到 EOF;
  • 协议请求是否完整;
  • 输出缓冲是否还要排空;
  • 本端是否允许 half-close;
  • 连接对象是否还有 worker 任务引用。

多线程共同从一个 epoll 实例处理连接时,EPOLLONESHOT 可以让某 fd 报告一次后暂时失活,处理完再用 EPOLL_CTL_MOD rearm。它能降低同一连接被并发处理的机会,但不能替代明确的连接所有权、引用生命周期和重入保护。


九、怎样把时间、线程通知和信号也放进 epoll

Linux 提供三类“可读 fd”,让 Reactor 不只处理 socket:

1
2
3
4
socket fd   → 网络读写
timerfd → 定时器到期
eventfd → 跨线程计数通知
signalfd → 被阻塞并转交的信号

timerfd:超时次数也可能大于 1

1
2
int timer_fd = timerfd_create(
CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);

触发后读取一个 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
2
3
4
5
6
7
8
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM);
sigaddset(&mask, SIGHUP);
pthread_sigmask(SIG_BLOCK, &mask, NULL);

int signal_fd = signalfd(
-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);

目标信号应在所有可能接收它的线程中保持阻塞。通常在创建 worker 线程之前设置 mask,让新线程继承,再由 Reactor 从 signalfd 读取 signalfd_siginfo。否则信号仍可能按传统方式递送给某个未阻塞线程。

这些接口都是 Linux 专属。libevent、libuv 等事件库已经封装了部分平台差异时,应优先遵循项目现有抽象,不要在上层再次拼装一套并行事件系统。


十、一个完整 Reactor 中,数据和所有权怎样流动?

稳定的分层通常是:

1
2
3
4
5
6
7
8
9
10
11
listen fd
→ I/O 线程 accept
→ connection 对象归固定 I/O 线程
→ 非阻塞读取到 input buffer
→ 增量协议解析
→ 有界任务队列
→ worker 执行业务
→ 有界结果队列 + eventfd 唤醒
→ 原 I/O 线程验证连接 generation
→ output buffer
→ EPOLLOUT 驱动发送

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 中,但“从不可读变为可读”的边沿已经被消费,程序又错误地回到了等待。

最重要的结论是:

  1. epoll 报告 I/O 就绪,不报告业务消息;
  2. ET 必须配合非阻塞 fd,并把当前方向处理到 EAGAIN
  3. accept()、读取和发送都可能需要循环,部分写必须保存进度;
  4. 为避免单连接饥饿,可以维护应用 ready queue,但不能在未到 EAGAIN 时遗忘该 fd;
  5. timerfdeventfdsignalfd 能把不同事件统一进 Reactor,但不替代队列和状态设计;
  6. LT 更容易正确,只有测量证明值得时才应承担 ET 的复杂度。

可以直接用于代码审查的一条建议是:搜索所有带 EPOLLET 的注册点,再沿调用链确认每个 accept/read/write 分支最终都明确到达 EAGAIN、EOF 或不可恢复错误。


参考资料