I/O 多路复用的演进
在现代高并发网络服务中,如何用少量线程管理海量文件描述符 (socket、管道、设备等)是业界难点。
I/O 多路复用机制允许单个线程同时监控多个文件描述符,当其中任意一个处于 I/O 就绪状态时,通知应用程序执行相应操作,是事件驱动架构的基础。
Linux 系统的 I/O 多路复用经历了三代技术演进:
select:最早的 POSIX 标准接口,受限于FD_SETSIZE宏定义的描述符数量上限,且每次调用需完整拷贝与遍历描述符集合,性能随连接数线性下降。poll:基于链表结构实现,解除了文件描述符数量硬限制,但仍未解决每次调用全量遍历的性能瓶颈。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机制
应用层
回调与等待
核心数据结构
- epoll_ctl(ADD)
- 注册回调到等待队列
注册等待项 - 事件触发
- wake_up 唤醒
- 加入就绪链表
- epoll_wait
- 返回就绪事件
应用程序进程
红黑树 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 事件管理的核心接口。
三种操作类型
-
EPOLL_CTL_ADD:向集合中添加新的文件描述符与事件。若同一epfd + fd重复添加,返回EEXIST错误。cstruct epoll_event ev; ev.events = EPOLLIN; ev.data.fd = socket_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, socket_fd, &ev); -
EPOLL_CTL_MOD:修改已注册文件描述符的关注事件。常用于动态开启/关闭EPOLLOUT、重新武装EPOLLONESHOT等场景。 -
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 中有可读数据
timerfd、eventfd、signalfd等事件 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,状态机如下:
- 初始状态 :仅监听
EPOLLIN,不监听EPOLLOUT - 产生待发送数据 :先直接调用
send()尝试发送 - 全部发送成功 :保持不监听
EPOLLOUT - send 返回 EAGAIN :调用
epoll_ctl(MOD)开启EPOLLOUT - 收到 EPOLLOUT:继续发送积压数据
- 积压数据清空 :调用
epoll_ctl(MOD)关闭EPOLLOUT
该模式本质是将 TCP 发送反压显式建模为应用层状态,仅在真正需要等待可写时才监听事件。
六、高级特性:多线程场景下的能力增强
6.1 EPOLLONESHOT:单事件所有权
注册 EPOLLONESHOT 标志后,文件描述符被 epoll_wait 返回一次后,会被临时禁用;只有应用通过 EPOLL_CTL_MOD 重新武装,才会继续上报事件。
该特性专为多线程 Reactor 架构设计:
- 多个 worker 线程同时阻塞在同一个 epoll 实例上
- 某个连接产生事件时,仅有一个线程获取到该事件
- 连接被临时禁用,不会再向其他线程分发事件
- 处理线程完成读写与状态更新后,重新武装该连接
通过 EPOLLONESHOT 可以避免多线程并发处理同一个连接,无需额外加锁即可保证连接级别的线程安全。注意:触发后必须主动重新武装,否则该连接将永远不再收到事件3。
6.2 EPOLLEXCLUSIVE:解决多实例惊群
惊群问题(Thundering Herd)指当一个事件到来时,所有等待的线程都被唤醒,但最终只有一个线程能成功处理,其余线程做了无效唤醒,造成性能浪费。
需要区分两种惊群场景:
- 多个线程等待同一个 epoll 实例:对于 ET 模式事件,Linux 内核已做优化,通常仅唤醒一个等待线程,不会产生严重惊群。
- 多个独立 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)
- 创建
epitem结构体,初始化事件掩码与用户数据 - 将
epitem插入 epoll 实例的红黑树 - 调用目标文件对象的
poll回调,获取当前就绪状态 - 将 epoll 的回调函数注册到目标文件对象的等待队列
- 若目标当前已就绪,将其加入就绪链表
fd 就绪回调
以 socket 接收数据为例:
- 网卡中断/NAPI 接收数据,经 TCP/IP 协议栈处理后放入 socket 接收缓冲区
- 唤醒 socket 的等待队列
- 执行 epoll 注册的回调函数
ep_poll_callback() - 将对应
epitem加入 epoll 的就绪链表 - 唤醒阻塞在
epoll_wait上的线程
由于回调可能运行在中断上下文,就绪链表的操作使用自旋锁保护。
epoll_wait 执行流程
- 检查就绪链表,若非空则直接取出一批事件
- 若为空,将当前线程加入 epoll 等待队列并进入睡眠
- 被唤醒后,将就绪链表转移到本地临时链表,使用
ovflist接收扫描期间的新事件 - 逐个确认每个 fd 的当前就绪状态,将事件信息拷贝到用户空间
- LT 模式下,若 fd 仍处于就绪状态,重新放回就绪链表
- 将
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(多进程版)。
十三、常见错误避坑指南
- ET 模式只读取一次 :ET 模式必须循环读写到
EAGAIN,否则剩余数据不会再触发事件,导致连接假死。 - 未设置非阻塞 fd:即使 epoll 通知就绪,状态也可能在调用读写前改变,阻塞 fd 会卡死整个事件循环。
- 监听 socket 只 accept 一次 :ET 模式下 accept 队列可能有多个连接,必须循环到
EAGAIN。 - 永久监听 EPOLLOUT:会导致事件循环持续空转,CPU 使用率飙升,仅在有积压发送数据时开启。
- 认为 send 一次就能发完 :
send可能部分成功,必须维护发送偏移量与发送队列。 - 收到 EPOLLHUP 立即关闭连接:缓冲区可能还有未读数据,应先读取完再关闭。
- 忽略 recv 返回 0:返回 0 代表对端发送 FIN,写方向 EOF,是正常的连接关闭信号。
- data.ptr 对象提前释放:事件可能残留在就绪队列或当前批次中,解引用会引发内存错误。
- 认为 close(fd) 后不会再有旧事件:当前批次的事件不会消失,且 fd 可能很快被复用。
- 认为 EPOLLIN 对应完整消息:TCP 是字节流,必须自行处理粘包拆包,维护应用层接收缓冲区。
- 事件循环中执行耗时任务:阻塞磁盘 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 的五个核心结论:
- epoll 是就绪通知,不是异步 I/O 完成,I/O 操作仍需应用主动发起。
- ET 模式必须配合非阻塞 I/O,且读写必须处理到 EAGAIN,否则会出现事件「丢失」。
- EPOLLOUT 仅应在有积压发送数据时启用,永久监听会造成 CPU 空转。
- 事件、fd 编号、连接对象是三个不同的生命周期,必须处理 fd 复用与旧事件问题。
- epoll 的优势是避免每轮扫描全部非活跃 fd,而非所有操作都是绝对 O(1)。