4. epoll事件模型

epoll 的事件模型本质上是一套“内核告诉用户态:这个 fd 现在处于什么可操作状态”的协议。理解每个事件位的语义边界,比记住它们的名字更重要。因为一旦把“可读”当成“有数据”、把“可写”当成“发送成功”,程序就会在边缘条件下出现丢事件、忙轮询或连接泄漏。本节围绕七个核心事件位,说明它们各自代表什么、在什么场景下应该监听、以及如何组合使用。

一、基础事件位:EPOLLIN 与 EPOLLOUT

EPOLLIN 表示对应的文件描述符上有数据可读,或者对端关闭了写方向(此时 read 返回 0)。对于监听 socket,EPOLLIN 表示有新连接可以 accept。需要注意,EPOLLIN 是“可读”而非“已读到数据”,它只保证一次 read 不会被阻塞,不保证能读满你期望的字节数。

EPOLLOUT 表示写缓冲区有空间,可以尝试 write。它最常见的误用是“一直监听 EPOLLOUT”,这会让 epoll_wait 几乎每次都返回,因为大多数时候 socket 都是可写的,从而把事件驱动退化成忙轮询。正确做法是:平时不监听 EPOLLOUT,只有当 write 返回 EAGAIN(内核发送缓冲区满)时,才把 EPOLLOUT 加入关注集合;一旦数据写完,立即移除 EPOLLOUT。

判断依据:write 返回值 > 0 表示部分或全部写入;返回 -1 且 errno == EAGAIN/EWOULDBLOCK 表示缓冲区满,此时才需要注册 EPOLLOUT。

二、异常与关闭类事件:EPOLLERR、EPOLLHUP、EPOLLRDHUP

EPOLLERR 表示 fd 上发生了错误,例如异步 socket 连接失败。它不需要显式注册,内核会自动上报。收到 EPOLLERR 后应通过 getsockopt(SO_ERROR) 读取具体错误码,然后关闭连接。

EPOLLHUP 表示挂起,通常是对端完全关闭了连接(读写两个方向都关闭)。它同样不需要注册,内核自动上报。EPOLLHUP 往往与 EPOLLIN 一起出现,此时 read 会返回 0。

EPOLLRDHUP 表示对端关闭了写方向(半关闭),即对端不会再发送数据,但本端仍可以继续发送。它需要显式注册,常用于“对端发完请求就 shutdown(WR),但还要接收响应”的协议,例如 HTTP 客户端发送完请求后关闭写方向。EPOLLRDHUP 让你能在不关闭整个连接的前提下感知对端的半关闭。

EPOLLERR 和 EPOLLHUP 是“总是被监听”的,即使你没有在 events 中写它们。EPOLLRDHUP 则必须显式加入 events 才会上报。

三、触发模式:EPOLLET 与水平触发

默认情况下 epoll 使用水平触发(LT,Level Triggered):只要 fd 上还有未处理的数据,epoll_wait 就会持续返回该事件。LT 的优点是编程简单,不容易丢事件;缺点是如果一次没读完,会反复唤醒。

EPOLLET 开启边缘触发(Edge Triggered):只有当 fd 状态发生变化时(例如从无数据变为有数据)才上报一次。ET 的优点是减少重复唤醒,适合高并发场景;缺点是必须配合非阻塞 fd,并且必须循环 read/write 直到返回 EAGAIN,否则剩余数据不会再触发新事件,导致“事件丢失”。

// ET 模式下正确的读循环
while (1) {
    n = read(fd, buf, sizeof(buf));
    if (n > 0) { /* 处理数据 */ }
    else if (n == 0) { /* 对端关闭 */ break; }
    else if (errno == EAGAIN || errno == EWOULDBLOCK) { break; }
    else { /* 真正的错误 */ break; }
}

ET 与 LT 的选择不是“谁更高级”,而是“谁更适合当前业务”。短连接、请求-响应型服务用 LT 更稳妥;长连接、高吞吐、需要精细控制唤醒次数的场景用 ET 更合适。

四、一次性事件:EPOLLONESHOT

EPOLLONESHOT 表示该 fd 的事件只上报一次,之后自动从关注集合中失效。要再次监听,必须重新调用 epoll_ctl 并带上 EPOLL_CTL_MOD。它的典型场景是多线程并发处理同一个 fd:如果不加 ONESHOT,一个 fd 上的可读事件可能被多个线程同时唤醒,导致多个线程同时 read 同一个 socket,产生数据竞争。加上 ONESHOT 后,同一时刻只有一个线程能处理该 fd,处理完再重新注册,保证串行化。

五、事件组合与适用场景对照

实际开发中,事件位很少单独使用,而是按场景组合。下面这张图把七个事件位按“语义类别—触发条件—典型场景—处理动作”组织起来,便于快速对照。

epoll 事件位语义与适用场景 可读/可写类 异常/关闭类 触发模式类 EPOLLIN 有数据可读 / 新连接 / 对端关闭写 accept / read 循环 监听 socket 与连接 socket 通用 必须处理,否则 LT 反复唤醒 ET 下需读到 EAGAIN EPOLLOUT 写缓冲区有空间 仅 write 返回 EAGAIN 时注册 写完立即 EPOLL_CTL_MOD 移除 常驻监听会退化为忙轮询 按需注册是关键 EPOLLERR fd 发生错误 无需注册,内核自动上报 getsockopt(SO_ERROR) 取错误码 关闭连接并回收资源 异步 connect 失败常见 EPOLLHUP 对端完全关闭(读写均关) 无需注册,自动上报 常与 EPOLLIN 同时出现 read 返回 0,关闭连接 不可再写 EPOLLRDHUP 对端关闭写方向(半关闭) 必须显式注册才会上报 本端仍可继续发送数据 HTTP 请求发完 shutdown(WR) 等待响应后关闭 EPOLLET 边缘触发,状态变化才上报 必须非阻塞 + 循环读到 EAGAIN 否则剩余数据不再触发 高并发长连接,减少唤醒 短连接用 LT 更稳 EPOLLONESHOT 事件只上报一次 处理完需 EPOLL_CTL_MOD 重注册 保证同一 fd 串行处理 多线程共享同一 fd 的场景 避免多线程同时 read

六、组合使用的实践要点

在真实服务中,一个连接通常同时注册多个事件位。例如一个长连接服务可能这样注册:

uint32_t events = EPOLLIN | EPOLLRDHUP | EPOLLET;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);

这里 EPOLLIN 负责读数据,EPOLLRDHUP 负责感知对端半关闭,EPOLLET 负责减少唤醒。当 write 返回 EAGAIN 时,再通过 EPOLL_CTL_MOD 追加 EPOLLOUT;写完后再次 MOD 去掉 EPOLLOUT。如果该连接由线程池处理,则再加上 EPOLLONESHOT,处理完重新 MOD 注册。

常见错误:在 ET 模式下只 read 一次就返回,导致缓冲区剩余数据不再触发事件;或者把 EPOLLOUT 常驻注册,导致 CPU 空转。这两类问题在压测中通常表现为“吞吐上不去”或“CPU 100% 但 QPS 很低”。

最后给出一个快速判断表,帮助在编码时决定该监听哪些事件位:

场景建议事件位关键动作
监听新连接EPOLLIN循环 accept 直到 EAGAIN
普通读数据EPOLLIN(+EPOLLRDHUP)read 循环,处理半关闭
发送缓冲区满临时追加 EPOLLOUT写完立即移除
异步 connectEPOLLOUT + EPOLLERR检查 SO_ERROR 判断成败
高并发长连接EPOLLIN | EPOLLET非阻塞 + 读到 EAGAIN
多线程处理同一 fdEPOLLIN | EPOLLONESHOT处理完 MOD 重注册

把这七个事件位理解成“内核与用户态之间的状态通知协议”,而不是简单的开关,就能在写代码时清楚地知道:什么时候该注册、什么时候该移除、收到之后该做什么。这正是 epoll 高性能服务稳定运行的基础。