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。
二、异常与关闭类事件:EPOLLERR、EPOLLHUP、EPOLLRDHUP
EPOLLERR 表示 fd 上发生了错误,例如异步 socket 连接失败。它不需要显式注册,内核会自动上报。收到 EPOLLERR 后应通过 getsockopt(SO_ERROR) 读取具体错误码,然后关闭连接。
EPOLLHUP 表示挂起,通常是对端完全关闭了连接(读写两个方向都关闭)。它同样不需要注册,内核自动上报。EPOLLHUP 往往与 EPOLLIN 一起出现,此时 read 会返回 0。
EPOLLRDHUP 表示对端关闭了写方向(半关闭),即对端不会再发送数据,但本端仍可以继续发送。它需要显式注册,常用于“对端发完请求就 shutdown(WR),但还要接收响应”的协议,例如 HTTP 客户端发送完请求后关闭写方向。EPOLLRDHUP 让你能在不关闭整个连接的前提下感知对端的半关闭。
三、触发模式: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,处理完再重新注册,保证串行化。
五、事件组合与适用场景对照
实际开发中,事件位很少单独使用,而是按场景组合。下面这张图把七个事件位按“语义类别—触发条件—典型场景—处理动作”组织起来,便于快速对照。
六、组合使用的实践要点
在真实服务中,一个连接通常同时注册多个事件位。例如一个长连接服务可能这样注册:
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 注册。
最后给出一个快速判断表,帮助在编码时决定该监听哪些事件位:
| 场景 | 建议事件位 | 关键动作 |
|---|---|---|
| 监听新连接 | EPOLLIN | 循环 accept 直到 EAGAIN |
| 普通读数据 | EPOLLIN(+EPOLLRDHUP) | read 循环,处理半关闭 |
| 发送缓冲区满 | 临时追加 EPOLLOUT | 写完立即移除 |
| 异步 connect | EPOLLOUT + EPOLLERR | 检查 SO_ERROR 判断成败 |
| 高并发长连接 | EPOLLIN | EPOLLET | 非阻塞 + 读到 EAGAIN |
| 多线程处理同一 fd | EPOLLIN | EPOLLONESHOT | 处理完 MOD 重注册 |
把这七个事件位理解成“内核与用户态之间的状态通知协议”,而不是简单的开关,就能在写代码时清楚地知道:什么时候该注册、什么时候该移除、收到之后该做什么。这正是 epoll 高性能服务稳定运行的基础。