Linux --- epoll (I/O就绪事件通知机制)

I/O 多路复用的演进

在现代高并发网络服务中,如何用少量线程管理海量文件描述符 (socket、管道、设备等)是业界难点。

I/O 多路复用机制允许单个线程同时监控多个文件描述符,当其中任意一个处于 I/O 就绪状态时,通知应用程序执行相应操作,是事件驱动架构的基础。

Linux 系统的 I/O 多路复用经历了三代技术演进:

  1. select:最早的 POSIX 标准接口,受限于 FD_SETSIZE 宏定义的描述符数量上限,且每次调用需完整拷贝与遍历描述符集合,性能随连接数线性下降
  2. poll:基于链表结构实现,解除了文件描述符数量硬限制,但仍未解决每次调用全量遍历的性能瓶颈。
  3. epoll:Linux 2.6 系列正式稳定的原生增强型多路复用接口,通过「兴趣注册与事件获取分离」的设计,在大量空闲连接的场景下实现了性能质的飞跃,成为当前 Linux 高性能网络服务的标准。

Nginx、Redis、Envoy、libuv、Boost.Asio 等知名项目均以 epoll 作为 Linux 平台的核心事件驱动引擎。

epoll定义:

epoll 是 Linux 内核提供的 IO 多路复用机制,通过事件驱动监控大量文件描述符,仅返回就绪事件。

epoll负责告知应用「某个文件描述符当前已满足 I/O 条件」,应用再主动发起 read/write 系统调用完成数据读写。这一本质区别是理解 epoll 的基础,也是其与 io_uring 代表的完成通知(Completion Notification)模型的主要差异。


一、epoll:双集合模型

从用户视角看,一个 epoll 实例内部维护两个集合:兴趣集合(Interest List)就绪集合(Ready List),二者共同构成了 epoll 事件驱动的基础模型。

1.1 兴趣集合(Interest List)

兴趣集合是应用程序向内核注册的、需要监控的文件描述符及其关注事件的集合 。应用通过 epoll_ctl 系统调用向集合中添加、修改或删除监控项;一旦注册完成,该集合会长期保存在内核中,无需每次等待事件时重复提交全量数据。

1.2 就绪集合(Ready List)

就绪集合是当前已满足 I/O 就绪条件的监控项队列。当被监控的文件描述符状态发生变化(如 socket 收到数据、发送缓冲区出现空闲、定时器到期等)时,内核会通过回调机制将对应监控项加入就绪队列。

epoll_wait 系统调用直接从该队列中获取就绪事件,无需遍历全部监控的文件描述符

1.3 事件流转流程

epoll 从事件注册到交付用户态的完整流程如下:
#mermaid-svg-n0ZIVuF7DxzXPHZO{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-n0ZIVuF7DxzXPHZO .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-n0ZIVuF7DxzXPHZO .error-icon{fill:#552222;}#mermaid-svg-n0ZIVuF7DxzXPHZO .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-n0ZIVuF7DxzXPHZO .marker{fill:#333333;stroke:#333333;}#mermaid-svg-n0ZIVuF7DxzXPHZO .marker.cross{stroke:#333333;}#mermaid-svg-n0ZIVuF7DxzXPHZO svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-n0ZIVuF7DxzXPHZO p{margin:0;}#mermaid-svg-n0ZIVuF7DxzXPHZO .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-n0ZIVuF7DxzXPHZO .cluster-label text{fill:#333;}#mermaid-svg-n0ZIVuF7DxzXPHZO .cluster-label span{color:#333;}#mermaid-svg-n0ZIVuF7DxzXPHZO .cluster-label span p{background-color:transparent;}#mermaid-svg-n0ZIVuF7DxzXPHZO .label text,#mermaid-svg-n0ZIVuF7DxzXPHZO span{fill:#333;color:#333;}#mermaid-svg-n0ZIVuF7DxzXPHZO .node rect,#mermaid-svg-n0ZIVuF7DxzXPHZO .node circle,#mermaid-svg-n0ZIVuF7DxzXPHZO .node ellipse,#mermaid-svg-n0ZIVuF7DxzXPHZO .node polygon,#mermaid-svg-n0ZIVuF7DxzXPHZO .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-n0ZIVuF7DxzXPHZO .rough-node .label text,#mermaid-svg-n0ZIVuF7DxzXPHZO .node .label text,#mermaid-svg-n0ZIVuF7DxzXPHZO .image-shape .label,#mermaid-svg-n0ZIVuF7DxzXPHZO .icon-shape .label{text-anchor:middle;}#mermaid-svg-n0ZIVuF7DxzXPHZO .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-n0ZIVuF7DxzXPHZO .rough-node .label,#mermaid-svg-n0ZIVuF7DxzXPHZO .node .label,#mermaid-svg-n0ZIVuF7DxzXPHZO .image-shape .label,#mermaid-svg-n0ZIVuF7DxzXPHZO .icon-shape .label{text-align:center;}#mermaid-svg-n0ZIVuF7DxzXPHZO .node.clickable{cursor:pointer;}#mermaid-svg-n0ZIVuF7DxzXPHZO .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-n0ZIVuF7DxzXPHZO .arrowheadPath{fill:#333333;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-n0ZIVuF7DxzXPHZO .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-n0ZIVuF7DxzXPHZO .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-n0ZIVuF7DxzXPHZO .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-n0ZIVuF7DxzXPHZO .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-n0ZIVuF7DxzXPHZO .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-n0ZIVuF7DxzXPHZO .cluster text{fill:#333;}#mermaid-svg-n0ZIVuF7DxzXPHZO .cluster span{color:#333;}#mermaid-svg-n0ZIVuF7DxzXPHZO div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-n0ZIVuF7DxzXPHZO .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-n0ZIVuF7DxzXPHZO rect.text{fill:none;stroke-width:0;}#mermaid-svg-n0ZIVuF7DxzXPHZO .icon-shape,#mermaid-svg-n0ZIVuF7DxzXPHZO .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-n0ZIVuF7DxzXPHZO .icon-shape p,#mermaid-svg-n0ZIVuF7DxzXPHZO .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-n0ZIVuF7DxzXPHZO .icon-shape .label rect,#mermaid-svg-n0ZIVuF7DxzXPHZO .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-n0ZIVuF7DxzXPHZO .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-n0ZIVuF7DxzXPHZO .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-n0ZIVuF7DxzXPHZO :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 文件系统
内核空间 - epoll机制
应用层
回调与等待
核心数据结构

  1. epoll_ctl(ADD)
  2. 注册回调到等待队列
    注册等待项
  3. 事件触发
  4. wake_up 唤醒
  5. 加入就绪链表
  6. epoll_wait
  7. 返回就绪事件
    应用程序进程
    红黑树 rbr

存储所有监控的fd
就绪链表 rdlist

存储已就绪的事件
epoll回调函数

ep_poll_callback
等待队列

等待该事件的进程
Socket / 文件对象

file_operations

这种「兴趣声明与事件获取分离」的设计,是 epoll 区别于 select/poll 的核心优势。

当系统中存在大量空闲连接时,select 与 poll 的性能会随连接数显著下降,而 epoll 的性能受总连接数影响极小;仅当几乎所有连接都持续活跃时,三者的性能差距才会明显缩小。


二、系统调用

epoll 提供三个系统调用完成事件全生命周期管理,分别负责实例创建、集合控制与事件等待

2.1 epoll_create1:创建 epoll 实例

c 复制代码
#include <sys/epoll.h>
int epoll_create1(int flags);

该函数创建一个新的 epoll 内核对象,成功返回对应的文件描述符,失败返回 -1。

  • 参数 flags :当前仅支持 EPOLL_CLOEXEC 标志,表示在进程执行 execve 时自动关闭该文件描述符,避免 fd 意外泄漏到子进程,是生产环境的推荐用法。
  • 历史接口 epoll_create(size) :早期 epoll 使用哈希表存储兴趣集合,size 参数用于提示内核初始大小。自 Linux 2.6.8 起,兴趣集合改为红黑树实现,size 参数不再生效,仅要求值大于 0。

常见错误码:

  • EMFILE:进程打开的文件描述符数量达到资源限制
  • ENFILE:系统全局文件描述符数量达到上限
  • ENOMEM:内核内存不足,无法创建新的 epoll 实例

2.2 epoll_ctl:管理兴趣集合

c 复制代码
int epoll_ctl(int epfd, int op, int fd, struct epoll_event* event);

该函数用于向 epoll 实例的兴趣集合中添加、修改或删除监控项,是 epoll 事件管理的核心接口。

三种操作类型
  1. EPOLL_CTL_ADD :向集合中添加新的文件描述符与事件。若同一 epfd + fd 重复添加,返回 EEXIST 错误。

    c 复制代码
    struct epoll_event ev;
    ev.events = EPOLLIN;
    ev.data.fd = socket_fd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &ev);
  2. EPOLL_CTL_MOD :修改已注册文件描述符的关注事件。常用于动态开启/关闭 EPOLLOUT、重新武装 EPOLLONESHOT 等场景。

  3. EPOLL_CTL_DEL:从集合中移除文件描述符。

关闭文件描述符前,应先执行 EPOLL_CTL_DEL 移除监控,再调用 close(fd),避免底层文件对象生命周期不一致带来的事件异常。

epoll 支持所有可通过 poll 接口暴露就绪状态的文件描述符;普通磁盘文件、目录不支持 epoll 监控,添加时会返回 EPERM 错误。

2.3 epoll_wait:等待就绪事件

c 复制代码
int epoll_wait(
    int epfd,
    struct epoll_event* events,
    int maxevents,
    int timeout
);

该函数阻塞等待就绪事件,将就绪事件集合拷贝到用户提供的 events 数组中,返回就绪事件的数量。

  • 返回值
    • >0>0>0:实际就绪的事件数量
    • =0= 0=0:等待超时
    • =−1= -1=−1:发生错误,错误码存于 errno
  • timeout 参数
    • -1:无限阻塞,直到有事件到达
    • 0:非阻塞,立即返回当前就绪事件
    • >0:最多阻塞指定毫秒数
  • maxevents 参数 :本次调用最多返回的事件数量,必须大于 0。当就绪事件多于 maxevents 时,后续调用会继续返回剩余事件,内核采用轮转调度减少饥饿风险。

标准调用写法(处理信号中断):

c 复制代码
int n;
do {
    n = epoll_wait(epfd, events, MAX_EVENTS, -1);
} while (n == -1 && errno == EINTR);

2.4 扩展接口:epoll_pwait 与 epoll_pwait2

epoll_pwait 支持原子地替换线程的信号掩码,避免「检查条件-信号到达-进入阻塞」之间的竞态窗口。它在语义上等价于「修改信号掩码-调用 epoll_wait-恢复信号掩码」,但由内核保证操作原子性。

epoll_pwait2 将超时参数改为 struct timespec,支持纳秒级时间精度。


三、事件结构与标准事件类型

3.1 epoll_event 结构体

c 复制代码
struct epoll_event {
    uint32_t events;   // 事件掩码
    epoll_data_t data; // 用户数据联合体
};

其中 epoll_data_t 是一个联合体,内核不会解释其内容,仅在 epoll_ctl 时保存,epoll_wait 时原样返回,用于传递用户自定义上下文:

c 复制代码
typedef union epoll_data {
    void*    ptr;    // 指向自定义连接对象
    int      fd;     // 文件描述符
    uint32_t u32;    // 32位整数ID
    uint64_t u64;    // 64位整数ID
} epoll_data_t;
上下文传递的工程实践
  • data.fd:最简单直接,但存在文件描述符复用导致的旧事件错配风险。
  • data.ptr:性能最高,可直接访问连接对象,但必须严格保证对象生命周期,否则会引发 use-after-free 内存错误。
  • data.u64:生产环境推荐用法,可存储单调递增的连接 ID、fd + generation 版本号等稳定标识,有效规避 fd 复用问题。

3.2 常用事件类型

EPOLLIN:可读就绪

表示文件描述符当前可执行读取操作,触发场景包括:

  • TCP 套接字接收缓冲区有数据到达
  • 监听套接字有新的连接待 accept
  • 管道、FIFO 中有可读数据
  • timerfdeventfdsignalfd 等事件 fd 触发

注意:EPOLLIN 仅表示字节流中有数据可读,不保证能读取到完整的业务消息。TCP 是字节流协议,应用层必须维护接收缓冲区与协议解析状态机,处理粘包、拆包问题。

EPOLLOUT:可写就绪

表示文件描述符当前可执行写入操作,即发送缓冲区有空闲空间。

TCP 套接字在连接建立后,发送缓冲区通常长期处于空闲状态。如果永久监听 EPOLLOUT,会导致事件循环被持续唤醒,造成 CPU 空转。正确的使用策略是:

  • 发送队列为空时,不监听 EPOLLOUT
  • 调用 send 返回 EAGAIN(发送缓冲区满)且仍有积压数据时,开启 EPOLLOUT
  • 积压数据全部发送完成后,立即关闭 EPOLLOUT

EPOLLOUT 仅代表「有数据待发送且之前因反压未完成」的状态。

EPOLLRDHUP:对端写关闭

表示流式套接字的对端关闭了连接,或关闭了写方向(shutdown(fd, SHUT_WR)),即 TCP 半关闭状态。

EPOLLHUP 更精准地检测对端不再发送数据的场景,推荐与 EPOLLIN 配合注册使用。

EPOLLHUP:挂断事件

表示流式通道发生挂断。需要特别注意:挂断事件发生后,内核接收缓冲区中可能仍有未读取的数据。因此收到 EPOLLHUP 时不应立即关闭连接,而应先读取剩余数据,直到 read/recv 返回 0 确认 EOF 后再释放资源。

EPOLLERR:错误事件

表示文件描述符发生错误。即使注册时未显式指定该事件,内核也会自动上报。

可通过 getsockopt(fd, SOL_SOCKET, SO_ERROR, ...) 获取具体的 socket 错误码。

EPOLLPRI:高优先级事件

表示存在异常或带外数据,如 TCP 带外数据(OOB)、终端 packet mode 状态变化等,在普通网络服务中较少使用。


四、触发模式深度:LT 与 ET

epoll 支持两种事件触发模式:水平触发(Level Triggered, LT)边沿触发(Edge Triggered, ET)

4.1 水平触发(LT):默认模式

LT 模式下,只要文件描述符仍处于就绪状态,epoll_wait 就会持续返回该事件。

例如:socket 接收缓冲区有 2000 字节数据,第一次 epoll_wait 返回 EPOLLIN,应用仅读取 1000 字节,缓冲区剩余 1000 字节。下一次调用 epoll_wait 时,LT 模式仍会返回 EPOLLIN,因为「可读」状态仍然成立。

可以类比为:水位高于警戒线时,警报持续响起。

LT 模式的优缺点
  • 优点 :编程模型简单,接近 poll 的语义,不易因漏处理数据导致事件永久丢失,适合业务逻辑复杂度高、连接规模非极端的场景。
  • 缺点:若数据未一次性处理完毕,会产生重复事件通知,在高频大规模场景下增加系统唤醒开销。

4.2 边沿触发(ET):高性能模式

ET 模式下,仅当文件描述符的就绪状态发生边沿变化(从不可就绪变为就绪)时,才会触发一次事件通知。

沿用上述例子:缓冲区进入 2000 字节数据,触发一次 EPOLLIN。若应用仅读取 1000 字节后再次调用 epoll_wait,即使缓冲区仍有剩余数据,ET 模式也不会再次返回事件,直到新的数据到达产生新的边沿。

可以类比为:仅在水位从警戒线下方穿越到上方时,警报响一次。

ET 模式并非「一个 fd 只通知一次」。只要状态再次发生边沿变化(如读到 EAGAIN 后新数据再次到达),就会再次触发事件。此外,内核可能合并两次 epoll_wait 之间的多个事件,因此 epoll 事件不能作为数据包数量或状态变化次数的可靠计数器。

4.3 ET 模式的强制编程规则

ET 模式减少了重复事件开销,但对编程规范有严格要求,违反规则会导致事件「丢失」、程序卡死。

规则一:必须使用非阻塞文件描述符

ET 模式下,事件通知仅代表状态发生过变化,不代表当前操作一定不会阻塞。若使用阻塞 fd,可能在读写过程中因状态改变而永久阻塞,卡死整个事件循环。

推荐在创建 fd 时直接设置非阻塞:

c 复制代码
// 创建 socket 时直接设置非阻塞与执行时关闭
int fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);

// accept 时使用 accept4 直接设置属性
int client_fd = accept4(listen_fd, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC);
规则二:读取必须循环到 EAGAIN

每次收到 EPOLLIN 事件后,必须循环调用 recv/read,直到返回 EAGAIN/EWOULDBLOCK,表示当前接收缓冲区已读空。

c 复制代码
while (true) {
    char buf[4096];
    ssize_t n = recv(fd, buf, sizeof(buf), 0);
    if (n > 0) {
        process_data(buf, n);
        continue;
    }
    if (n == 0) {
        // 对端关闭连接
        handle_close(fd);
        break;
    }
    if (errno == EINTR) continue;
    if (errno == EAGAIN || errno == EWOULDBLOCK) {
        // 数据已读完,等待下一次边沿事件
        break;
    }
    handle_error(fd);
    break;
}
规则三:写入必须循环到全部完成或 EAGAIN

发送数据时,必须循环调用 send/write,直到数据全部发送完毕,或返回 EAGAIN(发送缓冲区满)。此时开启 EPOLLOUT 等待下一次可写事件。

4.4 EAGAIN 含义

对于非阻塞文件描述符:

  • recv 返回 EAGAIN:当前接收缓冲区暂时没有更多数据,继续读会阻塞
  • send 返回 EAGAIN:当前发送缓冲区暂时没有空闲空间,继续写会阻塞

EAGAIN 不是错误,而是 epoll 状态机的切换点:

  • 读到 EAGAIN → 停止读取,等待下一次 EPOLLIN
  • 写到 EAGAIN → 停止写入,开启 EPOLLOUT 等待可写事件

五、常见场景的正确处理

5.1 监听套接字的 accept 处理

监听套接字收到 EPOLLIN 表示 accept 队列中有新连接。在 ET 模式下,不能只调用一次 accept(),因为队列中可能同时存在多个连接,必须循环 accept 直到返回 EAGAIN

即使 epoll 刚报告可读,也可能因其他线程抢先 accept、连接在 accept 前因网络错误消失等原因导致阻塞,因此监听套接字本身也必须设置为非阻塞。

正确示例:

c 复制代码
while (true) {
    int client_fd = accept4(listen_fd, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC);
    if (client_fd >= 0) {
        add_client_to_epoll(client_fd);
        continue;
    }
    if (errno == EINTR) continue;
    if (errno == EAGAIN || errno == EWOULDBLOCK) break;
    perror("accept4");
    break;
}

5.2 EPOLLOUT 的状态机管理

永久监听 EPOLLOUT 是 epoll 编程最常见的错误之一,会导致 LT 模式下 CPU 使用率 100% 空转。正确的做法是基于发送队列状态动态开关 EPOLLOUT,状态机如下:

  1. 初始状态 :仅监听 EPOLLIN,不监听 EPOLLOUT
  2. 产生待发送数据 :先直接调用 send() 尝试发送
  3. 全部发送成功 :保持不监听 EPOLLOUT
  4. send 返回 EAGAIN :调用 epoll_ctl(MOD) 开启 EPOLLOUT
  5. 收到 EPOLLOUT:继续发送积压数据
  6. 积压数据清空 :调用 epoll_ctl(MOD) 关闭 EPOLLOUT

该模式本质是将 TCP 发送反压显式建模为应用层状态,仅在真正需要等待可写时才监听事件。


六、高级特性:多线程场景下的能力增强

6.1 EPOLLONESHOT:单事件所有权

注册 EPOLLONESHOT 标志后,文件描述符被 epoll_wait 返回一次后,会被临时禁用;只有应用通过 EPOLL_CTL_MOD 重新武装,才会继续上报事件。

该特性专为多线程 Reactor 架构设计:

  • 多个 worker 线程同时阻塞在同一个 epoll 实例上
  • 某个连接产生事件时,仅有一个线程获取到该事件
  • 连接被临时禁用,不会再向其他线程分发事件
  • 处理线程完成读写与状态更新后,重新武装该连接

通过 EPOLLONESHOT 可以避免多线程并发处理同一个连接,无需额外加锁即可保证连接级别的线程安全。注意:触发后必须主动重新武装,否则该连接将永远不再收到事件3

6.2 EPOLLEXCLUSIVE:解决多实例惊群

惊群问题(Thundering Herd)指当一个事件到来时,所有等待的线程都被唤醒,但最终只有一个线程能成功处理,其余线程做了无效唤醒,造成性能浪费。

需要区分两种惊群场景:

  1. 多个线程等待同一个 epoll 实例:对于 ET 模式事件,Linux 内核已做优化,通常仅唤醒一个等待线程,不会产生严重惊群。
  2. 多个独立 epoll 实例监听同一个 fd:典型场景是多进程服务,每个进程有独立的 epoll 实例,都监听同一个监听套接字。新连接到达时,所有进程都会被唤醒。

EPOLLEXCLUSIVE 自 Linux 4.5 引入,专门用于第二种场景,减少多个 epoll 实例监控同一 fd 时的惊群现象。该标志只能在 EPOLL_CTL_ADD 时设置,不能后续通过 MOD 修改。


七、内核实现与性能

7.1 数据结构

Linux 内核中,epoll 实例由 struct eventpoll 表示,定义于 fs/eventpoll.c,核心成员包括:

成员 类型 作用
rbr struct rb_root_cached 红黑树,存储所有注册的监控项(epitem),支持 O(logN) 查找、插入、删除
rdllist struct list_head 就绪项双向链表,即 Ready List,就绪事件按 FIFO 顺序取出
ovflist struct epitem* 溢出链表,在扫描就绪列表期间,临时接收并发到达的新事件
wq wait_queue_head_t 等待队列,阻塞在 epoll_wait 上的线程挂在此处
mtx / lock struct mutex / spinlock_t 互斥锁与自旋锁,分别保护控制路径与就绪队列并发访问

每个注册的文件描述符对应一个 struct epitem 结构,包含所属 epoll、目标文件对象、事件掩码、用户数据、红黑树节点、就绪链表节点等信息。

7.2 内核视角的流程

添加监控项(epoll_ctl ADD)
  1. 创建 epitem 结构体,初始化事件掩码与用户数据
  2. epitem 插入 epoll 实例的红黑树
  3. 调用目标文件对象的 poll 回调,获取当前就绪状态
  4. 将 epoll 的回调函数注册到目标文件对象的等待队列
  5. 若目标当前已就绪,将其加入就绪链表
fd 就绪回调

以 socket 接收数据为例:

  1. 网卡中断/NAPI 接收数据,经 TCP/IP 协议栈处理后放入 socket 接收缓冲区
  2. 唤醒 socket 的等待队列
  3. 执行 epoll 注册的回调函数 ep_poll_callback()
  4. 将对应 epitem 加入 epoll 的就绪链表
  5. 唤醒阻塞在 epoll_wait 上的线程

由于回调可能运行在中断上下文,就绪链表的操作使用自旋锁保护。

epoll_wait 执行流程
  1. 检查就绪链表,若非空则直接取出一批事件
  2. 若为空,将当前线程加入 epoll 等待队列并进入睡眠
  3. 被唤醒后,将就绪链表转移到本地临时链表,使用 ovflist 接收扫描期间的新事件
  4. 逐个确认每个 fd 的当前就绪状态,将事件信息拷贝到用户空间
  5. LT 模式下,若 fd 仍处于就绪状态,重新放回就绪链表
  6. ovflist 中的事件合并回主就绪链表,返回事件数量

7.3 关于 O(1) 的真相

「epoll 是 O(1) 而 select/poll 是 O(N)」是非常流行的简化表述,但并不严谨。基于内核实现,各操作的实际复杂度如下:

操作 时间复杂度
epoll_create1() 近似 O(1)
epoll_ctl ADD/DEL/MOD O(log N)(红黑树操作)
fd 就绪回调加入就绪链表 近似 O(1)
epoll_wait() 返回 K 个事件 与 K 近似线性相关
用户态事件处理 O(K)

更准确的表述是:epoll_wait 无需每次线性扫描全部 N 个注册 fd,其开销主要与当前就绪事件数 K 相关。在 N 很大而 K 很小的典型互联网场景下,epoll 相比 select/poll 有压倒性的性能优势。

但 epoll 的整体开销还包括系统调用开销、锁竞争、事件重确认、数据拷贝、缓存失效等,并非所有场景下都一定优于 poll。当几乎所有连接都持续活跃时,select/poll 与 epoll 的性能差距会显著缩小11


八、容易踩坑的底层细节

8.1 文件描述符与打开文件描述

用户空间看到的文件描述符(fd,整数)只是一个索引,指向内核中的打开文件描述(Open File Description,对应 struct file 。调用 dup()fork() 会产生多个不同的 fd 指向同一个底层文件对象。

epoll 区分注册项的依据是「fd + 对应 open file description」,因此:

  • 同一个 fd 在同一个 epoll 实例中重复添加返回 EEXIST
  • dup() 得到的新 fd 可以作为独立项加入同一个 epoll
  • 关闭其中一个 fd 不代表底层文件对象销毁,只要还有其他引用,epoll 仍可能上报事件

最佳实践:在复制或关闭 fd 前,显式执行 EPOLL_CTL_DEL 移除监控,避免因生命周期不一致导致的异常事件1

8.2 关闭 fd 与旧事件残留

一次 epoll_wait 会返回一批事件到用户空间数组。如果在处理前几个事件时,关闭了后面某个事件对应的连接,该事件仍然会存在于当前批次中,不会自动消失。

更危险的是 fd 复用问题:Linux 内核会优先分配最小可用的 fd 编号。关闭旧 fd 后,该编号可能很快被新连接复用,导致当前批次中的旧事件被错误地应用到新连接上,引发逻辑混乱。

生产环境的解决方案:

  • 使用单调递增的连接 ID,而非直接使用 fd 作为标识
  • 采用 fd + generation 版本号机制,每次分配 fd 时递增版本
  • 关闭连接后延迟释放对象,设置「已关闭」标记
  • 处理事件前,在连接表中校验标识的有效性

8.3 epoll 嵌套限制

epoll fd 本身也是可轮询的文件描述符,因此可以被另一个 epoll 实例监控。但存在以下限制:

  • epoll 实例不能监听自己,否则返回 EINVAL
  • 内核会检测循环嵌套(A→B→C→A),最大嵌套深度为 5,超过返回 ELOOP

普通业务几乎不需要 epoll 嵌套,将所有事件源加入同一个 epoll 实例通常是更简单的方案3


九、统一事件源:构建 Reactor 事件循环

epoll 的价值不仅限于网络 socket。Linux 将大量内核事件抽象为文件描述符,都可以加入 epoll 统一管理,构建全功能的事件驱动循环。

9.1 timerfd:定时器事件

timerfd 将定时器抽象为文件描述符,定时器到期后 fd 变为可读。读取一个 uint64_t 整数,可得到自上次读取以来的到期次数。

c 复制代码
int timer_fd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);

它可以直接加入 epoll 监控,用于实现连接超时、定时任务等功能,是事件驱动架构中定时器的标准实现方式8

9.2 eventfd:线程/进程间通知

eventfd 是一个轻量级的事件通知 fd,基于内核计数器实现。其他线程写入一个 64 位整数会增加计数器,计数器非零时 fd 可读。

c 复制代码
int event_fd = eventfd(0, EFD_NONBLOCK | EFD_CLOEXEC);

相比用于通知的 pipe,eventfd 仅占用一个文件描述符,内核开销更低,是跨线程唤醒事件循环、提交异步任务的首选方案9

9.3 signalfd:信号事件化

signalfd 将 Unix 信号抽象为文件描述符。先阻塞对应信号,再创建 signalfd,信号触发时 fd 变为可读,读取可获得信号详细信息。

c 复制代码
sigset_t mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
pthread_sigmask(SIG_BLOCK, &mask, nullptr);
int signal_fd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);

通过 signalfd 可以将异步信号转化为同步的 fd 事件,在事件循环中统一处理,避免信号处理函数的异步编程复杂性10

最终,一个完整的 Reactor 事件循环可以统一处理所有类型的事件:

text 复制代码
监听 socket EPOLLIN  → 接受新连接
客户端 socket EPOLLIN  → 读取请求数据
客户端 socket EPOLLOUT → 发送响应数据
timerfd EPOLLIN        → 处理超时与定时任务
eventfd EPOLLIN        → 处理跨线程任务
signalfd EPOLLIN       → 处理退出信号,优雅停机

十、生产级 ET 事件循环骨架

以下是一个完整的 ET 模式 echo 服务事件循环实现,覆盖了前文提到的所有最佳实践:非阻塞 fd、循环读写到 EAGAIN、动态开关 EPOLLOUT、单调连接 ID 规避 fd 复用、正确的关闭顺序。

cpp 复制代码
#include <arpa/inet.h>
#include <cerrno>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <memory>
#include <string>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <unordered_map>

namespace {
constexpr std::uint64_t kListenerToken = 0;
constexpr int kMaxEvents = 128;

struct Connection {
    std::uint64_t id{};
    int fd{-1};
    std::string output;
    std::size_t send_offset{0};
};

bool modify_connection_events(int epfd, const Connection& connection) {
    epoll_event ev{};
    ev.events = EPOLLIN | EPOLLRDHUP | EPOLLET;
    if (connection.send_offset < connection.output.size()) {
        ev.events |= EPOLLOUT;
    }
    ev.data.u64 = connection.id;
    if (epoll_ctl(epfd, EPOLL_CTL_MOD, connection.fd, &ev) == -1) {
        std::fprintf(stderr, "EPOLL_CTL_MOD failed: %s\n", std::strerror(errno));
        return false;
    }
    return true;
}
}  // namespace

void run_event_loop(int listen_fd) {
    const int epfd = epoll_create1(EPOLL_CLOEXEC);
    if (epfd == -1) {
        std::perror("epoll_create1");
        return;
    }

    epoll_event listen_event{};
    listen_event.events = EPOLLIN | EPOLLET;
    listen_event.data.u64 = kListenerToken;
    if (epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &listen_event) == -1) {
        std::perror("EPOLL_CTL_ADD listener");
        close(epfd);
        return;
    }

    std::unordered_map<std::uint64_t, std::unique_ptr<Connection>> connections;
    std::uint64_t next_connection_id = 1;

    auto close_connection = [&](std::uint64_t id) {
        auto it = connections.find(id);
        if (it == connections.end()) return;
        const int fd = it->second->fd;
        epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
        close(fd);
        connections.erase(it);
    };

    epoll_event events[kMaxEvents];
    for (;;) {
        int event_count;
        do {
            event_count = epoll_wait(epfd, events, kMaxEvents, -1);
        } while (event_count == -1 && errno == EINTR);

        if (event_count == -1) {
            std::perror("epoll_wait");
            break;
        }

        for (int i = 0; i < event_count; ++i) {
            const std::uint64_t token = events[i].data.u64;
            const std::uint32_t mask = events[i].events;

            // 处理监听套接字
            if (token == kListenerToken) {
                for (;;) {
                    const int client_fd = accept4(
                        listen_fd, nullptr, nullptr, SOCK_NONBLOCK | SOCK_CLOEXEC
                    );
                    if (client_fd >= 0) {
                        const std::uint64_t id = next_connection_id++;
                        auto connection = std::make_unique<Connection>();
                        connection->id = id;
                        connection->fd = client_fd;

                        epoll_event client_event{};
                        client_event.events = EPOLLIN | EPOLLRDHUP | EPOLLET;
                        client_event.data.u64 = id;

                        if (epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &client_event) == -1) {
                            std::fprintf(stderr, "ADD client failed: %s\n", std::strerror(errno));
                            close(client_fd);
                            continue;
                        }
                        connections.emplace(id, std::move(connection));
                        continue;
                    }
                    if (errno == EINTR) continue;
                    if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                    std::fprintf(stderr, "accept4 failed: %s\n", std::strerror(errno));
                    break;
                }
                continue;
            }

            // 跳过已关闭连接的旧事件
            auto connection_it = connections.find(token);
            if (connection_it == connections.end()) continue;

            Connection& connection = *connection_it->second;
            bool should_close = false;

            // 处理错误事件
            if (mask & EPOLLERR) {
                int socket_error = 0;
                socklen_t error_length = sizeof(socket_error);
                getsockopt(connection.fd, SOL_SOCKET, SO_ERROR, &socket_error, &error_length);
                std::fprintf(stderr, "socket error: %s\n", std::strerror(socket_error));
                should_close = true;
            }

            // 处理可读与挂断事件
            if (mask & (EPOLLIN | EPOLLRDHUP | EPOLLHUP)) {
                for (;;) {
                    char buffer[4096];
                    const ssize_t received = recv(connection.fd, buffer, sizeof(buffer), 0);
                    if (received > 0) {
                        // echo 逻辑:将接收数据加入发送队列
                        connection.output.append(buffer, static_cast<std::size_t>(received));
                        continue;
                    }
                    if (received == 0) {
                        should_close = true;
                        break;
                    }
                    if (errno == EINTR) continue;
                    if (errno == EAGAIN || errno == EWOULDBLOCK) break;
                    should_close = true;
                    break;
                }
            }

            // 尝试发送积压数据
            if (!should_close) {
                while (connection.send_offset < connection.output.size()) {
                    const char* data = connection.output.data() + connection.send_offset;
                    const std::size_t remaining = connection.output.size() - connection.send_offset;
                    const ssize_t sent = send(connection.fd, data, remaining, MSG_NOSIGNAL);
                    if (sent > 0) {
                        connection.send_offset += static_cast<std::size_t>(sent);
                        continue;
                    }
                    if (sent == -1 && errno == EINTR) continue;
                    if (sent == -1 && (errno == EAGAIN || errno == EWOULDBLOCK)) break;
                    should_close = true;
                    break;
                }
            }

            if (should_close) {
                close_connection(token);
                continue;
            }

            // 发送完成后清理缓冲区
            if (connection.send_offset == connection.output.size()) {
                connection.output.clear();
                connection.send_offset = 0;
            }

            // 根据发送队列状态更新事件监听
            if (!modify_connection_events(epfd, connection)) {
                close_connection(token);
            }
        }
    }

    // 资源清理
    for (auto& [id, conn] : connections) {
        close(conn->fd);
    }
    close(epfd);
}

说明:该代码仅为事件循环核心骨架。生产环境还需补充 TCP 协议解析、缓冲区上限控制、慢客户端治理、空闲超时、TLS、日志指标、优雅停机、内存池等工程能力。


十一、工程实践进阶话题

11.1 连接饥饿与公平性

ET 模式要求读写到 EAGAIN,但如果某个连接持续不断地产生数据,事件循环会一直处理该连接,导致其他连接长时间得不到处理,产生连接饥饿问题。

Linux man-pages 建议应用层维护自己的就绪队列,以轮询(Round-Robin)方式处理就绪 fd1。工程上常用预算控制方案:

  • 每个连接每轮事件循环最多处理固定字节数(如 64KB)或固定消息数
  • 达到预算后,即使未读到 EAGAIN,也主动让出 CPU,将连接放回应用层就绪队列
  • 下一轮事件循环继续处理该连接

注意:ET 模式下主动停止读取后,不会再有新的边沿事件触发,因此必须由应用层记住该连接仍处于就绪状态,不能直接丢弃。

11.2 资源限制与调优

每个 epoll 注册项都会占用内核内存。Linux 通过 /proc/sys/fs/epoll/max_user_watches 限制同一用户在所有 epoll 实例中可注册的 fd 总数,达到限制时 EPOLL_CTL_ADD 返回 ENOSPC

根据官方估算,64 位系统下每个注册项约占用 160 字节内核内存,默认限制根据系统可用低端内存自动计算1

高并发服务还需同时关注以下资源限制:

  • 进程 fd 上限:ulimit -n
  • 系统全局 fd 上限:/proc/sys/fs/file-max
  • TCP 接收/发送缓冲区大小、TCP 内存参数
  • 连接跟踪表大小
  • 应用层每连接内存开销

即使 epoll 可以监控十万级连接,如果每个连接分配 128KB 应用层缓冲区,十万连接就需要约 12GB 内存。因此高并发系统的核心不仅是 epoll,更在于精细化的每连接内存设计与反压机制。


十二、epoll 典型线程架构模型

基于 epoll 可以构建多种线程架构,适配不同的业务场景。

12.1 单线程 Reactor

单线程中完成 epoll_wait、accept、读写、协议解析、业务处理全流程。

  • 优点:无锁开销,状态机简单,缓存局部性好,适合轻量请求场景。
  • 缺点:无法利用多核,耗时操作会阻塞整个事件循环,增加所有连接的延迟。
  • 代表项目:Redis 单线程事件循环。

12.2 Reactor + 工作线程池

I/O 线程负责 epoll 事件等待、数据读写、协议拆包,将业务计算任务投递到工作线程池;工作线程完成业务逻辑后,将响应写入发送队列,通过 eventfd 唤醒 I/O 线程发送。

  • 优点:分离 I/O 与计算,充分利用多核,是最通用的高并发服务模型。
  • 注意:同一个连接的 socket I/O 与事件状态修改应由固定 I/O 线程负责,避免多线程并发操作同一个连接带来的生命周期与竞态问题。

12.3 多独立 Reactor(多 Reactor 多线程)

启动多个 Reactor 线程,每个线程拥有独立的 epoll 实例、连接表与任务队列。新连接按负载均衡策略分配到某个 Reactor,之后生命周期内不迁移。

  • 优点:无全局共享锁,缓存亲和性好,可线性扩展到多核,是高性能网络框架的主流架构。
  • 代表项目:Netty、Nginx(多进程版)。

十三、常见错误避坑指南

  1. ET 模式只读取一次 :ET 模式必须循环读写到 EAGAIN,否则剩余数据不会再触发事件,导致连接假死。
  2. 未设置非阻塞 fd:即使 epoll 通知就绪,状态也可能在调用读写前改变,阻塞 fd 会卡死整个事件循环。
  3. 监听 socket 只 accept 一次 :ET 模式下 accept 队列可能有多个连接,必须循环到 EAGAIN
  4. 永久监听 EPOLLOUT:会导致事件循环持续空转,CPU 使用率飙升,仅在有积压发送数据时开启。
  5. 认为 send 一次就能发完send 可能部分成功,必须维护发送偏移量与发送队列。
  6. 收到 EPOLLHUP 立即关闭连接:缓冲区可能还有未读数据,应先读取完再关闭。
  7. 忽略 recv 返回 0:返回 0 代表对端发送 FIN,写方向 EOF,是正常的连接关闭信号。
  8. data.ptr 对象提前释放:事件可能残留在就绪队列或当前批次中,解引用会引发内存错误。
  9. 认为 close(fd) 后不会再有旧事件:当前批次的事件不会消失,且 fd 可能很快被复用。
  10. 认为 EPOLLIN 对应完整消息:TCP 是字节流,必须自行处理粘包拆包,维护应用层接收缓冲区。
  11. 事件循环中执行耗时任务:阻塞磁盘 I/O、重型计算、同步 RPC 都会增加所有连接的尾延迟,应异步化。

十四、适用场景与技术对比

14.1 epoll 的适用边界

epoll 非常适合以下场景:

  • 海量 TCP/UDP 连接、低活跃度的长连接服务
  • 代理服务器、WebSocket、RPC 框架、消息队列
  • 需要统一管理 socket、定时器、信号、设备等多种事件源的事件驱动架构
  • 低线程数、高吞吐的 Reactor 架构服务

以下场景 epoll 并非最优选择:

  • 仅少量文件描述符的简单服务
  • 以普通磁盘文件 I/O 为主的应用
  • 业务以长时间 CPU 计算为主,I/O 不是瓶颈
  • 需要跨平台统一接口的应用
  • 需要真正异步完成语义的场景

14.2 epoll vs io_uring

io_uring 是 Linux 5.1 引入的新一代异步 I/O 框架,采用完成通知模型,与 epoll 的就绪通知模型有本质区别:

维度 epoll io_uring
核心模型 就绪通知(Readiness) 异步提交与完成(Completion)
读写方式 事件通知后主动调用 recv/send 提前提交 I/O 请求,内核完成后通知结果
通知含义 现在可以执行 I/O 了 某个 I/O 操作已经完成
支持对象 可轮询的文件描述符 网络、文件、定时器、套接字操作等
系统调用 等待仍需 read/write 系统调用 可批量提交与收割,减少系统调用
成熟度 非常成熟,生态完善 较新,功能持续迭代中
编程模型 Reactor 模式 Proactor 模式或混合模式

epoll 的优势在于接口稳定、调试经验丰富、框架支持广泛,对于绝大多数网络服务仍然是最稳妥的选择。io_uring 在减少系统调用、支持异步磁盘 I/O 等方面有优势,是未来的技术发展方向。


总结

epoll 是 Linux 高性能网络编程的基石,其核心价值在于通过兴趣集合与就绪集合分离的设计,解决了 select/poll 在海量连接下的性能瓶颈。

理解 epoll 的五个核心结论:

  1. epoll 是就绪通知,不是异步 I/O 完成,I/O 操作仍需应用主动发起。
  2. ET 模式必须配合非阻塞 I/O,且读写必须处理到 EAGAIN,否则会出现事件「丢失」。
  3. EPOLLOUT 仅应在有积压发送数据时启用,永久监听会造成 CPU 空转。
  4. 事件、fd 编号、连接对象是三个不同的生命周期,必须处理 fd 复用与旧事件问题。
  5. epoll 的优势是避免每轮扫描全部非活跃 fd,而非所有操作都是绝对 O(1)。
相关推荐
AlfredZhao1 小时前
进程都杀了,为什么 `netstat` 还能看到端口?
linux
一条泥憨鱼1 小时前
【从0开始学习计算机网络】| 邮件协议入门:SMTP、POP3、IMAP
linux·运维·计算机网络·github
LJianK12 小时前
服务器、节点 、 集群、分布式
运维
姚不倒2 小时前
etcd 学习系列(四):存储模型 —— WAL 和 Snapshot 是如何配合的
运维·etcd
come112343 小时前
Nginx `location` 配置说明(后端开发版)
运维·nginx
z落落4 小时前
C# WinForm Socket 转串口设备服务器+Modbus CRC16校验+Socket 客户端
服务器·开发语言·c#
SkyWalking中文站10 小时前
SkyWalking 11 与 BanyanDB 0.11:在存储引擎内部实现 Trace 尾部采样
运维·监控·自动化运维
姚不倒10 小时前
etcd 学习系列(二):集群架构 —— 3 节点是如何工作的
运维·架构·etcd
pt104311 小时前
网络自动化Python课程:Cisco PyATS网络自动化测试框架
运维·自动化