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

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,避免饿死其它连接
epoll ET 模式中兴趣列表、非阻塞 socket、读取缓冲区、EAGAIN 与事件循环的静态关系图
图1:监听边界、连接边界和调度边界之间的静态关系;这是原创说明图,不是运行截图。

先把监听 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);
}
EPOLLIN ET、read 返回值、EOF、errno、EPOLLONESHOT、epoll_ctl MOD、ready list 与连接状态的静态关系图
图2:事件输入、重置和公平调度三类边界的关系;这是原创结构图,不是终端或运行结果截图。

大流量连接要防止一个 fd 饿死其它 fd

“一直读到 EAGAIN”是 ET 的正确语义,但在一个连接持续有数据时,单次 drain 也可能占用事件循环很久。内核手册将这种情况称为 starvation 风险。工程上可以给单次处理设置字节数或消息数预算:预算耗尽时把连接放入用户态 ready list,下一轮轮询再继续,而不是假装已经读空。

要注意区分两个状态:真正收到 EAGAIN 表示当前内核读缓冲区已空;预算耗尽表示应用主动暂停,连接可能仍然可读。后一种情况要由连接状态记录“还有工作”,并按公平策略重新调度。

迁移后怎样做回归检查

切换 ET 后,回归重点应放在事件边界而不是只看“请求能否成功返回”。可以准备以下检查项,并在真实测试环境中观察日志、连接状态和资源回收:

  1. 一次写入少量数据后,确认读循环能处理数据并在 EAGAIN 返回后保存状态。
  2. 一次写入超过单次缓冲区容量的数据,确认不会因为只读一次而长期等待。
  3. 对端发送完数据后半关闭或关闭,确认 read() == 0 进入清理路径,而不是循环空转。
  4. 在读循环中注入 EINTR,确认它只重试当前读,不会重复提交已经消费的业务数据。
  5. 制造不可恢复的 errno,确认日志包含 fd 和错误原因,连接不会继续挂在 epoll 实例里。
  6. 开启 EPOLLONESHOT 时,确认每条处理路径都会在状态保存后完成 MOD 重置。
  7. 让一个 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 表示。

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