Linux epoll ET 模式怎样处理读空后的事件循环
来源:17golang原创
时间:2026-11-01 10:11:35 133浏览 收藏
Linux 的 epoll 切换到 ET(edge-triggered,边缘触发)后,读事件不能只调用一次 read() 就回到 epoll_wait()。正确的边界是:fd 必须是非阻塞的,收到读事件后持续读取,直到读到 EAGAIN 或明确遇到 EOF、错误;只有把当前连接状态保存好,事件循环才可以安全等待下一次通知。
官方资料入口:https://www.kernel.org/doc/html/latest/
ET 模式报告的是“状态发生了变化”,不是“缓冲区里还剩多少数据”。因此一次通知可能对应多次读取;读到一半就重新等待,剩余数据可能不会再次触发事件。把非阻塞读循环排空到 EAGAIN,再处理连接状态,是最稳妥的基本写法。
从 LT 切到 ET,真正变化的是事件假设
LT(level-triggered)更像对 poll() 的加速:只要 fd 仍然可读,下一次等待通常仍会收到提示。ET 则只在可读状态发生变化时提醒。假设接收缓冲区先到达 8 KB,第一次事件里只读走 4 KB,剩余 4 KB 仍在缓冲区中;如果之后没有新的数据到达,下一次 epoll_wait() 可能一直等下去。
所以 ET 的迁移重点不是把 EPOLLIN 改成 EPOLLIN | EPOLLET,而是同时改变读处理契约:
| 事项 | LT 下容易采用的写法 | ET 下应明确的边界 |
|---|---|---|
| fd 属性 | 阻塞或非阻塞都可能工作 | 使用非阻塞 fd,避免一次读操作卡住整个事件循环 |
| 一次通知 | 读一次,剩余数据由下一次通知继续处理 | 循环读取,直到 EAGAIN 或连接结束 |
| 状态保存 | 简单场景可把状态留在调用栈 | 把半包、待发送数据和连接状态放到 fd 对应对象里 |
| 公平性 | 持续处理一个 fd 的风险较低 | 大流量 fd 需要预算或 ready list,避免饿死其它连接 |

先把监听 fd 和连接 fd 设成非阻塞
ET 读循环依赖“读不到了就返回”,因此监听 socket 和新接收的连接 socket 都应设置 O_NONBLOCK。下面的辅助函数只负责设置 fd 属性,错误直接返回给上层;它没有在本机执行,代码中的注释说明了关键参数和失败边界。
#include#include static int set_nonblocking(int fd) { // 读取已有标志,避免覆盖 O_CLOEXEC 等调用方已经设置的属性。 int flags = fcntl(fd, F_GETFL, 0); if (flags == -1) { return -1; } // 保留原标志,只追加非阻塞模式;失败时由调用方关闭 fd 或回滚。 if (fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1) { return -1; } return 0; }
设置非阻塞并不意味着可以忽略错误。F_GETFL 或 F_SETFL 失败时,连接对象不能继续进入 ET 事件循环,否则后面的“读到 EAGAIN”假设并不成立。
一次事件要把可读数据读到 EAGAIN
读处理函数应把“还有数据”“对端正常关闭”“当前读空”“被信号打断”和“真正错误”分开。对流式 socket 来说,read() 返回正数只代表这次拿到了一段数据,不代表消息已经完整;返回 0 才表示对端已经有序关闭,返回 -1 后再看 errno。
#include#include #include enum read_result { READ_KEEP = 0, READ_CLOSE = 1, READ_ERROR = 2 }; static enum read_result drain_read(int fd, char *buf, size_t cap) { for (;;) { // 每轮只取当前缓冲区容量;真实项目应把数据交给半包解析器。 ssize_t n = read(fd, buf, cap); if (n > 0) { // 正数只说明收到一段字节;解析状态要保存在连接对象中。 consume_bytes(buf, (size_t)n); continue; } if (n == 0) { // EOF 表示对端有序关闭;先处理残留协议状态,再移除 fd。 return READ_CLOSE; } if (errno == EINTR) { // 信号打断不等于连接异常,继续本次 drain 读循环。 continue; } if (errno == EAGAIN || errno == EWOULDBLOCK) { // 已经读空:保存连接状态后,才可以回到 epoll_wait。 return READ_KEEP; } // 其它 errno 需要记录并走统一关闭路径,不能伪装成读空。 record_read_error(fd, errno); return READ_ERROR; } }
这里的 consume_bytes() 和 record_read_error() 是业务层接口,示例没有假定具体协议。关键点是:不要把 EAGAIN 当成失败,也不要把其它错误吞掉;连接对象必须记住半包数据、待发送数据以及是否已经看到 EOF。
为什么读空后仍可能需要处理写事件
“读到 EAGAIN”只结束当前读方向,不代表连接对象可以被当成空壳丢弃。一个请求可能已经解析出响应,但 socket 的发送缓冲区暂时写不下。此时可以从 EPOLLIN | EPOLLET 切换为带 EPOLLOUT 的关注集合,并把待发送字节保存在连接状态中。
static int update_interest(int epfd, struct connection *conn) {
struct epoll_event ev = {0};
// 读事件持续保留;只有存在待发送数据时才关注写事件。
ev.events = EPOLLIN | EPOLLET;
if (conn->out_bytes > 0) {
ev.events |= EPOLLOUT;
}
ev.data.ptr = conn;
// MOD 重新读取当前关注集合,避免遗留已完成的写事件兴趣。
return epoll_ctl(epfd, EPOLL_CTL_MOD, conn->fd, &ev);
}
这一步的工程含义是把 fd 事件和连接状态绑定起来:事件循环只负责分发,协议解析器负责消费输入,发送器负责推进输出。若把响应数据留在局部变量里,读循环返回后它就会丢失,下一次 EPOLLOUT 没有可继续发送的内容。
EPOLLONESHOT 和 ET 一起用时要主动重置
多线程事件循环常用 EPOLLONESHOT 让一个 fd 的一次通知只交给一个工作线程。它的代价是:事件被取出后,fd 会暂时失去后续通知;工作线程处理完当前状态后必须通过 epoll_ctl(EPOLL_CTL_MOD) 重新设置关注事件。若只读到 EAGAIN 却忘记 MOD,连接看起来就像“突然不再触发”。
static int rearm_connection(int epfd, struct connection *conn) {
struct epoll_event ev = {0};
// 读空后仍保持读关注;若有输出,追加写关注。
ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
if (conn->out_bytes > 0) {
ev.events |= EPOLLOUT;
}
ev.data.ptr = conn;
// 只有状态已经写回连接对象后才重置,避免并发线程重复接管。
return epoll_ctl(epfd, EPOLL_CTL_MOD, conn->fd, &ev);
}

大流量连接要防止一个 fd 饿死其它 fd
“一直读到 EAGAIN”是 ET 的正确语义,但在一个连接持续有数据时,单次 drain 也可能占用事件循环很久。内核手册将这种情况称为 starvation 风险。工程上可以给单次处理设置字节数或消息数预算:预算耗尽时把连接放入用户态 ready list,下一轮轮询再继续,而不是假装已经读空。
要注意区分两个状态:真正收到 EAGAIN 表示当前内核读缓冲区已空;预算耗尽表示应用主动暂停,连接可能仍然可读。后一种情况要由连接状态记录“还有工作”,并按公平策略重新调度。
迁移后怎样做回归检查
切换 ET 后,回归重点应放在事件边界而不是只看“请求能否成功返回”。可以准备以下检查项,并在真实测试环境中观察日志、连接状态和资源回收:
- 一次写入少量数据后,确认读循环能处理数据并在
EAGAIN返回后保存状态。 - 一次写入超过单次缓冲区容量的数据,确认不会因为只读一次而长期等待。
- 对端发送完数据后半关闭或关闭,确认
read() == 0进入清理路径,而不是循环空转。 - 在读循环中注入
EINTR,确认它只重试当前读,不会重复提交已经消费的业务数据。 - 制造不可恢复的 errno,确认日志包含 fd 和错误原因,连接不会继续挂在 epoll 实例里。
- 开启
EPOLLONESHOT时,确认每条处理路径都会在状态保存后完成 MOD 重置。 - 让一个 fd 持续产生数据,确认 ready list 或预算机制仍能让其它连接获得处理机会。
一份可落地的 ET 迁移清单
- 输入层:监听 fd 和连接 fd 都显式设置
O_NONBLOCK。 - 读层:收到 ET 读事件后循环读取,分别处理正数、0、
EINTR、EAGAIN和其它错误。 - 协议层:把半包解析、消息边界和 EOF 后的残留数据处理放在连接对象中。
- 输出层:待发送字节不能留在局部变量里,只有输出缓冲区非空时才关注
EPOLLOUT。 - 并发层:使用
EPOLLONESHOT时,处理完成后由持有连接状态的线程重新 MOD。 - 调度层:对持续活跃的 fd 设置公平预算,区分“真的读空”和“应用主动让出”。
一句话总结:ET 模式的事件循环不是“收到一次 EPOLLIN 就读一次”,而是“收到状态变化后,把当前读工作推进到明确边界”。对流式 fd,这个边界通常是 EAGAIN、EOF 或真实错误;对并发事件循环,还要把连接状态、重置和公平调度一起设计。
相关问题
读到 EAGAIN 后还要再次调用 epoll_ctl MOD 吗?
普通 ET 场景不需要为了 EAGAIN 本身重复 MOD;保存好连接状态后回到 epoll_wait() 即可。只有使用 EPOLLONESHOT 或确实需要改变关注事件时,才需要 MOD 重置。
把 ET 改回 LT 能否解决读不全?
LT 可能让“仍可读”的 fd 在后续等待中继续出现,但它不能替代正确的 EOF、错误、半包和公平调度处理。如果选择 LT,应明确接受它的通知语义,而不是把它当作 ET 读循环的补丁。
为什么循环里不能只判断 errno == EAGAIN?
某些平台把“暂时不可读”同时表示为 EAGAIN 或 EWOULDBLOCK。对 socket 代码通常应同时判断二者;另外,read() 返回 0 是 EOF,不会通过 errno 表示。
-
Golang · Go教程 | 4个月前 | 超时控制 · 故障排查 · Go教程 · 后端工程 · Golang实战 · HTTP客户端 · golang Go 性能优化 net/http context Transport 超时 http.Client 生产实践205 收藏
-
Golang · Go教程 | 3个月前 | 并发 · HTTP · 性能优化 · 故障排查 · Go教程 · Go Goroutine 连接复用 pprof http.Client close Response.Body201 收藏
-
Golang · Go教程 | 3个月前 | golang · JSON · 故障排查 · Go教程 · 接口设计 · JSON Go 接口兼容性 DisallowUnknownFields 严格解码174 收藏
-
423 收藏
-
Golang · Go教程 | 2个月前 | golang · 服务端 · 故障排查 · net/http · 连接泄漏 StateIdle ConnState Go net/http StateHijacked311 收藏
-
277 收藏
-
126 收藏
-
300 收藏
-
138 收藏
-
198 收藏
-
146 收藏
-
477 收藏
-
296 收藏
-
451 收藏
-
349 收藏
-
126 收藏
-
237 收藏
-
- 前端进阶之JavaScript设计模式
- 设计模式是开发人员在软件开发过程中面临一般问题时的解决方案,代表了最佳的实践。本课程的主打内容包括JS常见设计模式以及具体应用场景,打造一站式知识长龙服务,适合有JS基础的同学学习。
- 立即学习 543次学习
-
- GO语言核心编程课程
- 本课程采用真实案例,全面具体可落地,从理论到实践,一步一步将GO核心编程技术、编程思想、底层实现融会贯通,使学习者贴近时代脉搏,做IT互联网时代的弄潮儿。
- 立即学习 516次学习
-
- 简单聊聊mysql8与网络通信
- 如有问题加微信:Le-studyg;在课程中,我们将首先介绍MySQL8的新特性,包括性能优化、安全增强、新数据类型等,帮助学生快速熟悉MySQL8的最新功能。接着,我们将深入解析MySQL的网络通信机制,包括协议、连接管理、数据传输等,让
- 立即学习 500次学习
-
- JavaScript正则表达式基础与实战
- 在任何一门编程语言中,正则表达式,都是一项重要的知识,它提供了高效的字符串匹配与捕获机制,可以极大的简化程序设计。
- 立即学习 487次学习
-
- 从零制作响应式网站—Grid布局
- 本系列教程将展示从零制作一个假想的网络科技公司官网,分为导航,轮播,关于我们,成功案例,服务流程,团队介绍,数据部分,公司动态,底部信息等内容区块。网站整体采用CSSGrid布局,支持响应式,有流畅过渡和展现动画。
- 立即学习 485次学习