1. 从一次回显服务的“卡死”说起
如果你在 Linux 上写过高并发网络服务,大概率绕不开 epoll。它是什么?一句话:epoll 是 Linux 内核提供的一种 IO 多路复用机制,让一个线程就能同时盯住成千上万个 socket 的读写状态。它能做什么?把“哪个连接有数据可读、哪个连接可写”这件事委托给内核去检测,你的程序只需要处理真正就绪的那批 fd。适合谁?写 TCP 服务器、网关、即时通讯后端、自定义协议服务的同学,尤其是被 C10K 甚至 C100K 连接数折磨过的人。
我见过太多人在回显服务里踩同一个坑:用 LT 模式跑得好好的,一改成 ET 就发现客户端发来的长数据只回了一半,或者服务端直接卡在read上不动了。根因往往不是 epoll 本身,而是没搞清楚 LT、ET-block、ET-no-block 这三种组合在事件注册和 socket 阻塞属性上的差异。这篇就聚焦这个场景,用回显服务把三种模式的配置骨架、验证动作、排障要点一次讲透,正文会给出可复制的参数清单和压测验证步骤,帮你定位 ET 模式下的读写循环与 EAGAIN 处理。
2. 三种模式到底差在哪:一张表先建立直觉
在动手写代码前,先把行为差异钉死。epoll 的事件注册靠struct epoll_event的events字段,默认是水平触发(LT),加上EPOLLET就变成边沿触发(ET)。而 socket 是否阻塞,由fcntl的O_NONBLOCK决定,跟 epoll 是两套独立开关。
| 模式 | events 标志 | socket 属性 | 通知行为 | 典型风险 |
|---|---|---|---|---|
| LT | EPOLLIN | 阻塞或非阻塞均可 | 缓冲区有数据就反复通知 | 效率略低,但不易丢数据 |
| ET-block | EPOLLIN | EPOLLET | 阻塞 | 状态变化只通知一次 | 循环读时阻塞在 read,饿死其他 fd |
| ET-no-block | EPOLLIN | EPOLLET | 非阻塞 | 状态变化只通知一次 | 必须处理 EAGAIN,否则误判断开 |
注意:ET 模式的核心契约是“内核只在你上次没读完、这次又有新数据时才再通知”。所以你必须一次把缓冲区读干净,而“读干净”的标志就是
read返回 -1 且errno == EAGAIN。
LT 的语义是“只要读缓冲区还有数据,epoll_wait就会一直返回这个 fd”。这解释了为什么 LT 模式下即使你每次只读 5 字节,剩下的数据下一轮还会被通知,不会丢。而 ET 只在你“从无到有”或“从读完到又有新数据”时通知一次,读不完的部分如果没在本次循环里处理掉,就可能永远等不到下一次通知。
3. TaoToken 前置:把模型对话和接入文档放到手边
写这类底层代码时,我习惯把调试辅助和文档查询的入口准备好。TaoToken 提供模型对话、Coding Plan、API Keys 和接入文档几个入口,适合在写 epoll 服务时顺手查参数、验证协议行为。
- 想直接和模型对话确认 epoll 语义或让它帮你 review 读写循环,走模型对话:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite
- 长期写网络编程、Agent 类项目,需要稳定的编码额度,看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite
- 要生成和管理调用凭证,进控制台:https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite
- 创建 API Key:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite
- 接入文档(含接口说明):https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite
- 如果你用 Claude Code 这类工具做辅助开发,Anthropic 兼容入口在这里:https://taotoken.net/claudecode-anthropic?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite
API 基址统一是 https://taotoken.net/api ,注意这个地址不带 UTM 参数,配置时直接填即可。官网入口是 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 。
4. 可复制配置:三种模式的 epoll 注册骨架
下面用回显服务(echo server)作为统一场景:监听 8888 端口,收到数据原样回发。三种模式共用epoll_create、epoll_ctl、epoll_wait三件套,差异集中在事件注册和通信循环。
4.1 公共骨架:epoll 三件套
#include <sys/epoll.h> #include <fcntl.h> #include <errno.h> int epfd = epoll_create(1); // size 已废弃,填 >0 即可 struct epoll_event ev, evs[128]; ev.events = EPOLLIN; ev.data.fd = lfd; // 监听 fd 先挂上 epoll_ctl(epfd, EPOLL_CTL_ADD, lfd, &ev); while (1) { int n = epoll_wait(epfd, evs, 128, -1); // -1 表示一直阻塞 for (int i = 0; i < n; ++i) { int curfd = evs[i].data.fd; if (curfd == lfd) { int cfd = accept(lfd, NULL, NULL); // 三种模式的差异从这里开始 } else { // 通信处理,三种模式差异最大 } } }4.2 LT 模式:默认行为,最省心
LT 不需要额外标志,socket 保持默认阻塞即可。通信循环里读一次、回一次就行,剩下的数据下一轮epoll_wait会继续通知。
// accept 后注册 ev.events = EPOLLIN; // 不加 EPOLLET 就是 LT ev.data.fd = cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); // 通信循环 char buf[1024] = {0}; int num = read(curfd, buf, sizeof(buf)); if (num > 0) { write(curfd, buf, num); } else if (num == 0) { epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL); close(curfd); }4.3 ET-block:能跑,但埋着雷
ET-block 只加EPOLLET,socket 仍是阻塞。问题在于:ET 要求你循环读到 EAGAIN,但阻塞 socket 在数据读完后read会直接挂起,而不是返回 -1。结果就是当前 fd 把整个线程卡死,其他连接全部饿死。
ev.events = EPOLLIN | EPOLLET; // 边沿触发 ev.data.fd = cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); // 通信循环:这段在 ET-block 下会阻塞 char buf[512]; int len; while ((len = read(curfd, buf, sizeof(buf))) > 0) { write(curfd, buf, len); } // 数据读完后 read 不返回,卡在这里4.4 ET-no-block:生产环境该用的组合
正确做法是把通信 fd 设成非阻塞,再配合 ET。这样read读完后返回 -1 且errno == EAGAIN,循环自然退出,线程继续处理其他 fd。
// accept 后立刻设置非阻塞 int flag = fcntl(cfd, F_GETFL); flag |= O_NONBLOCK; fcntl(cfd, F_SETFL, flag); ev.events = EPOLLIN | EPOLLET; ev.data.fd = cfd; epoll_ctl(epfd, EPOLL_CTL_ADD, cfd, &ev); // 通信循环:读到 EAGAIN 为止 char buf[512]; int len; while ((len = read(curfd, buf, sizeof(buf))) > 0) { write(STDOUT_FILENO, buf, len); write(curfd, buf, len); } if (len == 0) { printf("client disconnect\n"); epoll_ctl(epfd, EPOLL_CTL_DEL, curfd, NULL); close(curfd); } else if (errno == EAGAIN || errno == EWOULDBLOCK) { printf("data drained\n"); // 正常,数据读完了 } else { perror("read"); // 真正的错误 }注意:
EAGAIN和EWOULDBLOCK在 Linux 上值相同,但写上两个更可移植。判断顺序必须是先判len == 0(对端关闭),再判 EAGAIN,最后才是真错误。
5. 验证请求:用压测动作确认三种模式行为
光看代码不够,得用实际动作验证。我试过用nc和自写客户端配合,观察服务端打印的epoll_wait返回次数。
5.1 验证 LT 的“反复通知”
在 LT 服务端里,每次epoll_wait返回都打印一次计数。客户端一次性发 20 字节,服务端每次只读 5 字节:
# 客户端 printf '1234567890abcdefghij' | nc 127.0.0.1 8888你会看到服务端打印多次“epoll 返回第 N 次”,每次读走 5 字节,直到读完。这证明 LT 在缓冲区有数据时会持续通知。
5.2 验证 ET 的“只通知一次”
同样客户端发 20 字节,ET 服务端如果只读一次 5 字节,你会发现剩下的 15 字节再也没被处理,epoll_wait不再返回该 fd。这就是 ET 丢数据的经典现象。
5.3 验证 ET-no-block 的“读干净”
把通信循环改成while读到 EAGAIN,再发 20 字节,服务端会一次性把 20 字节全部回显,并打印“data drained”。用strace -e trace=read,write跟踪,能看到read返回 -1 EAGAIN 后循环退出。
5.4 压测脚本骨架
# 并发 100 连接,每连接发 1KB for i in $(seq 1 100); do (head -c 1024 /dev/zero | nc 127.0.0.1 8888 > /dev/null) & done wait观察 ET-no-block 服务端 CPU 占用平稳、无连接饿死;ET-block 则可能在某个连接上卡住,后续连接无响应。
6. 本篇常见错排查
6.1 read 返回 -1 就 exit,把 EAGAIN 当致命错误
这是 ET-no-block 最常见的坑。数据读完后read返回 -1,errno是 EAGAIN,这是正常状态,不是错误。如果你直接perror加exit,服务就崩了。正确做法是单独判断 EAGAIN 分支。
6.2 ET 模式下忘了设非阻塞,线程被 read 挂起
表现是服务端处理完第一个连接后,第二个连接完全没反应。用gdbattach 上去看堆栈,会发现卡在read系统调用。解决就是 accept 后立刻fcntl设O_NONBLOCK。
6.3 只注册 EPOLLIN,却想写大块数据
ET 模式下写事件也要注意。如果你注册了EPOLLOUT,写缓冲区不满时 LT 会一直通知,ET 只在“从满到不满”时通知一次。大块数据要自己维护发送偏移量,配合EPOLLOUT重试。
6.4 忘记处理对端 RST
压测时可能看到read: Connection reset by peer。这表示对端发了 TCP RST,通常是客户端异常退出。服务端应捕获这个错误,清理 fd,而不是让整个进程退出。
6.5 epoll_ctl DEL 后没 close
EPOLL_CTL_DEL只是把 fd 从 epoll 树摘掉,不会关闭 fd。漏掉close会导致 fd 泄漏,长跑服务最终耗尽描述符。两件事要成对出现。
7. 继续验证与接入
三种模式的骨架到这里就完整了。LT 适合快速原型和对丢数据零容忍的场景;ET-no-block 是高性能服务的标配,但必须把 EAGAIN 处理写对;ET-block 基本只用于理解原理,生产别用。
如果你想用模型帮你 review 这段读写循环,或者让它生成对应的压测客户端,可以直接走模型对话入口:https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite 。长期写网络编程和 Agent 项目,需要稳定编码额度,看 Coding Plan:https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite 。接入前先创建 API Key:https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite ,参数细节对照接入文档:https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=epoll_lt_et_socket&utm_campaign=rewrite 。API 基址是 https://taotoken.net/api ,配置时直接填。