登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  linux

Linux epoll ET 模式下为什么必须循环读到 EAGAIN

来源:17golang原创

时间:2026-09-09 00:59:03 366浏览 收藏

Linux epoll 使用 ET(edge-triggered,边沿触发)时,事件通知告诉你的不是“以后每次都还有数据”,而是“这个文件描述符从某个状态变成了可读或可写”。如果一次只读走一部分,剩余数据可能不会再次产生边沿,事件循环就会把连接暂时忘掉。实际处理方式是:把 fd 设为非阻塞,在一次事件处理里持续读取,直到 readrecv 返回 EAGAINEWOULDBLOCK

要点速览
  • ET 关注状态变化;残留在接收缓冲区的数据不一定重新唤醒 epoll_wait
  • 非阻塞读循环中,正数表示继续处理,0 表示对端关闭,EAGAIN 表示本轮 I/O 已耗尽。
  • “读到 EAGAIN”解决的是事件丢失;就绪队列和轮转预算解决的是单个 fd 长时间占用 CPU。

ET 的风险不是少读一次,而是留下一个不会主动提醒你的状态

假设 socket 缓冲区里来了 8 KB 数据,epoll_wait 返回一次可读事件,业务代码只调用一次 read(fd, buf, 1024)。读完 1 KB 后,缓冲区还剩 7 KB。LT 模式会因为“仍然可读”继续报告;ET 模式更关心从不可读到可读的那次变化,后续等待可能看不到这 7 KB。

所以 ET 的第一条边界是:收到事件后,应把 fd 视为“持续就绪”,直到一次非阻塞读写明确返回 EAGAIN。这也是为什么 ET 通常必须配合非阻塞 fd;如果 fd 仍是阻塞模式,循环读到最后可能卡在下一次系统调用,整个事件循环无法服务其他连接。

Linux epoll ET 模式中 epoll_wait、非阻塞 socket 与接收缓冲区之间的静态边界关系
图1:把事件通知、非阻塞 socket 和接收缓冲区放在同一张静态关系图中,理解为什么一次只读一段会留下未重新触发的状态。

非阻塞读取要把 EAGAIN 当成“本轮完成”

下面的处理函数只展示读取边界,不负责协议拆包。协议层应把读取到的字节追加到连接自己的输入缓冲区,再根据长度字段或分隔符判断完整消息。

#include 
#include 

static int drain_read(int fd, char *buf, size_t cap) {
    for (;;) {
        ssize_t n = recv(fd, buf, cap, 0);

        if (n > 0) {
            // 把 n 个字节追加到连接的协议缓冲区,再继续排空 socket。
            append_to_connection_buffer(buf, (size_t)n);
            continue;
        }
        if (n == 0) {
            // 返回 0 表示对端有序关闭,调用方应移出 epoll 并 close(fd)。
            return 0;
        }
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            // 当前内核接收缓冲区已读空,本轮事件处理可以结束。
            return -2;
        }
        // 其他 errno 是真实 I/O 错误,不能伪装成“暂时没有数据”。
        return -1;
    }
}

关键判断顺序不能反:0 是关闭,EAGAIN 是暂时读空;前者要清理 fd,后者要保存连接状态并回到事件循环。示例里的 append_to_connection_buffer 代表协议层自己的半包缓冲,不应把未完成消息丢在临时数组里。

返回值含义处理动作
n > 0读到数据追加到协议缓冲区,继续读
n == 0对端关闭写端取消监听并释放连接资源
EAGAIN/EWOULDBLOCK本轮没有更多数据保存状态,等待下一次事件
其他错误真实 I/O 失败记录 errno,按连接策略关闭或恢复

读到 EAGAIN 之后,还要防住两个工程风险

第一个风险是“排空一个 fd”变成无限工作。对端可以持续发送数据,使你的循环长期拿不到 EAGAIN,其他连接因此饥饿。常见控制方式是维护就绪队列:事件到来时只把连接标记为 ready,每轮给它一个字节数或消息数预算;预算用完就暂存连接,轮转处理其他 ready fd。

第二个风险是关闭后的事件缓存。一次 epoll_wait 可能返回多个事件,前面的事件处理已经关闭了后面某个 fd。连接对象应有 removed/closed 标记,处理缓存中的后续事件前先检查它,避免对已经释放的对象继续读写。若使用 EPOLLONESHOT,处理完后还必须通过 epoll_ctl(..., EPOLL_CTL_MOD, ...) 重新激活 fd,否则连接会保持失活。

Linux epoll ET 事件循环中就绪队列、读取预算、连接状态和关闭清理之间的静态关系
图2:用就绪队列、连接状态和关闭清理三个边界解释“读到 EAGAIN”之后如何避免单 fd 饥饿和失效事件。

用这张检查清单判断 ET 读取是否可靠

  • 注册 EPOLLET 的 fd 是否确实设置了 O_NONBLOCK
  • 一次事件是否持续读到 EAGAIN,而不是只调用一次 read
  • 是否把 0EAGAIN 和其他错误分开记录?
  • 协议半包是否保存在连接状态中,而不是丢在临时缓冲区?
  • 是否给高流量连接设置轮转预算,并在关闭后使事件缓存失效?

对流式 fd,Linux 的手册也提到,若能保证 fd 始终是 stream-oriented,读取量小于请求量可以帮助判断当前 I/O 空间已耗尽;但通用事件循环更稳妥的约定仍是非阻塞读到 EAGAIN。这样不会把“短读”错误地套用到数据报或其他边界敏感的 fd 上。

常见问题

ET 模式一定要每次读到 EAGAIN 吗?

对需要持续消费的非阻塞 fd,这是最清晰、最通用的边界。流式 fd 在有严格前提时也可以用短读判断耗尽,但不能把这个特例当成所有 fd 的规则。

为什么不能继续使用阻塞 socket?

因为最后一次读取可能等待未来数据,阻塞当前线程;ET 事件循环通常还要服务其他 fd,所以应使用非阻塞模式。

读到 EAGAIN 后要不要立刻再次调用 epoll_wait?

可以回到事件循环,但先保存半包、写缓冲区和连接状态。若使用 ONESHOT,还要在状态可继续处理时重新 arm。

参考:Linux epoll(7) 手册

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>