I/O多路复用:epoll机制详解
在前三讲中,非阻塞 I/O 与 select/poll 的组合已为高性能网络编程奠定了基础。然而 select/poll 在大规模并发场景下存在固有的性能瓶颈:每次调用需线性扫描所有注册的描述符,且涉及大量用户态-内核态数据拷贝。epoll 机制通过全新的接口设计和内核数据结构,从根本上解决了这些问题。
以下性能对比图(数据来源:The Linux Programming Interface, No Starch Press)直观展示了 select、poll、epoll 在不同文件描述符规模下的表现差异:

epoll 在 10000 个文件描述符场景下的性能与 10 个描述符时几乎无差异,而 select 和 poll 的性能随描述符数量增长而显著下降。
epoll 内部架构
epoll 的高性能源于其内核数据结构设计。epoll 实例在内核中维护两个核心数据结构:
- 兴趣列表(Interest List):以红黑树(Red-Black Tree)实现,存储所有注册到 epoll 实例的文件描述符及其关注的事件。红黑树的查找、插入、删除时间复杂度均为 O(log n),且支持按 fd 快速定位,避免重复注册。
- 就绪列表(Ready List):以双向链表(Doubly-Linked List)实现,当某个 fd 上的 I/O 事件就绪时,内核通过回调机制将其加入就绪链表。
epoll_wait仅需遍历就绪链表即可返回,时间复杂度 O(k)(k 为就绪事件数)。
这种设计使得 epoll 避免了 select/poll 的全量扫描,仅处理实际发生事件的描述符。
epoll API 详解
epoll_create / epoll_create1
int epoll_create(int size);
int epoll_create1(int flags);
/* 返回值:成功返回 epoll 实例文件描述符;出错返回 -1 */epoll_create() 创建一个 epoll 实例。自 Linux 2.6.8 起,size 参数被内核自动忽略,但仍需传入大于 0 的整数。早期实现中,size 用于告知内核期望监控的描述符数量以预分配内核数据结构;现代内核采用动态分配策略,不再依赖此参数。
epoll_create1() 是推荐使用的接口(Linux 2.6.27+),通过 flags 参数提供扩展能力:
| flags 值 | 说明 | 引入版本 |
|---|---|---|
0 | 等同于 epoll_create(0) | Linux 2.6.27 |
EPOLL_CLOEXEC | 在 exec 时自动关闭 epoll fd | Linux 2.6.27 |
内核版本注记:
epoll_create1()自 Linux 2.6.27 引入,是推荐使用的创建接口。EPOLL_CLOEXEC标志可防止文件描述符在fork+exec场景下泄漏到子进程,在多进程服务端程序中尤为重要。
epoll 实例本身也是一个文件描述符,不再使用时应调用 close() 释放,以便内核回收相关资源。
epoll_ctl
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/* 返回值:成功返回 0;出错返回 -1 */epoll_ctl() 用于向 epoll 实例注册、修改或删除监控事件。
op 参数:
| 操作 | 说明 |
|---|---|
EPOLL_CTL_ADD | 注册文件描述符及其事件 |
EPOLL_CTL_MOD | 修改已注册文件描述符的事件 |
EPOLL_CTL_DEL | 删除文件描述符及其事件 |
epoll_event 结构体:
typedef union epoll_data {
void *ptr;
int fd;
uint32_t u32;
uint64_t u64;
} epoll_data_t;
struct epoll_event {
uint32_t events; /* 事件掩码 */
epoll_data_t data; /* 用户数据 */
};data 字段是联合体,最常用的方式是通过 fd 字段存储文件描述符,或通过 ptr 字段指向自定义数据结构。
事件类型:
| 事件 | 值 | 说明 | 引入版本 |
|---|---|---|---|
EPOLLIN | 文件描述符可读 | Linux 2.6 | |
EPOLLOUT | 文件描述符可写 | Linux 2.6 | |
EPOLLRDHUP | 套接字对端关闭或半关闭(Stream Socket Peer Shutdown) | Linux 2.6.17 | |
EPOLLHUP | 文件描述符挂起(Hang Up) | Linux 2.6 | |
EPOLLET | 启用边缘触发模式(Edge-Triggered) | Linux 2.6 | |
EPOLLONESHOT | 一次性事件,触发后需重新武装(Re-arm) | Linux 2.6.2 | |
EPOLLEXCLUSIVE | 排他唤醒,避免惊群效应(Thundering Herd) | Linux 4.5 | |
EPOLLWAKEUP | 防止系统休眠,需 CAP_BLOCK_SUSPEND 权限 | Linux 3.5 |
EPOLLONESHOT(Linux 2.6.2+):注册此标志后,epoll 在触发一次事件后自动禁用该描述符的后续通知。应用程序处理完事件后需通过
EPOLL_CTL_MOD重新"武装"(re-arm)该描述符。此机制适用于多线程场景,确保同一描述符的事件在同一时刻仅被一个线程处理,避免多线程同时操作同一连接的竞态条件。
EPOLLEXCLUSIVE(Linux 4.5+):当多个 epoll 实例通过
EPOLLEXCLUSIVE注册同一个文件描述符时,事件仅唤醒其中一个实例,而非全部。这有效解决了多进程/多线程监听同一端口时的惊群(Thundering Herd)问题。注意:EPOLLEXCLUSIVE仅适用于EPOLL_CTL_ADD操作,且不可与EPOLLET同时使用。该标志在 Nginx 等高性能 Web 服务器中广泛使用,配合SO_REUSEPORT实现高效的多进程 accept 负载均衡。
epoll_wait / epoll_pwait / epoll_pwait2
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
/* 返回值:就绪事件数目;超时返回 0;出错返回 -1 */
int epoll_pwait(int epfd, struct epoll_event *events, int maxevents,
int timeout, const sigset_t *sigmask);
/* Linux 2.6.19+ */
int epoll_pwait2(int epfd, struct epoll_event *events, int maxevents,
const struct timespec *timeout, const sigset_t *sigmask);
/* Linux 5.11+ */epoll_wait() 阻塞等待 I/O 事件分发。
- events:输出参数,内核将就绪事件写入此数组。数组大小由
maxevents决定。 - maxevents:大于 0 的整数,表示
events数组的容量,即单次最多返回的事件数。 - timeout:超时时间(毫秒)。-1 表示永久等待;0 表示立即返回。
与 select/poll 的关键区别:epoll_wait 仅返回就绪事件,应用程序无需遍历所有注册的描述符。
epoll_pwait()(Linux 2.6.19+)在 epoll_wait 基础上增加了信号掩码参数 sigmask,允许原子地替换进程信号掩码后等待,避免信号处理与 epoll 等待之间的竞态条件。
epoll_pwait2()(Linux 5.11+)进一步将超时精度从毫秒提升至纳秒级,使用 struct timespec 替代整数毫秒参数。对于需要精确超时控制的高性能场景(如低延迟交易系统),此接口提供了更细粒度的时间控制能力。
/* epoll_pwait2 使用示例:设置 100 毫秒 500 纳秒超时 */
struct timespec ts;
ts.tv_sec = 0;
ts.tv_nsec = 100500000; /* 100.5 ms */
int nready = epoll_pwait2(epfd, events, MAXEVENTS, &ts, NULL);epoll 资源限制
epoll 的监控能力受以下系统参数约束:
$ cat /proc/sys/fs/epoll/max_user_watches
8192max_user_watches 限制单个用户可注册的 epoll 监控总数(即兴趣列表中的条目总数)。默认值因系统配置而异,通常为几千到数万。在高并发场景下,可能需要通过 /etc/sysctl.conf 增大此值:
fs.epoll.max_user_watches = 100000内核版本注记:
max_user_watches的默认值在 Linux 4.x 之后有所调整,具体取决于可用内存大小。在内存充足的系统上,默认值可达数十万。可通过cat /proc/sys/fs/epoll/max_user_watches查看当前值。
此外,/proc/sys/fs/epoll/max_user_instances 限制单个用户可创建的 epoll 实例数量(默认通常为 128),在多进程架构中需注意此限制。
Busy Poll 机制
对于极低延迟场景,Linux 提供了 Busy Poll 机制,允许应用程序在 epoll_wait 返回前主动轮询网络设备队列,减少中断处理延迟。
传统 epoll 的事件通知路径为:网卡中断 -> 内核中断处理 -> 就绪链表更新 -> epoll_wait 返回。Busy Poll 允许在 epoll_wait 内部主动调用 napi_busy_loop,直接从网卡驱动的 NAPI 队列中拉取数据,跳过中断处理环节。
系统级配置:
# 全局启用 busy poll,设置轮询时间(微秒)
$ echo 50 > /proc/sys/net/core/busy_poll
$ echo 50 > /proc/sys/net/core/busy_readioctl 接口(Linux 6.9+):Linux 6.9 引入了基于 ioctl 的 per-socket busy poll 配置接口,允许对单个套接字设置 busy poll 参数,无需依赖全局 sysctl 配置:
/* Linux 6.9+ per-socket busy poll 配置 */
struct epoll_event ev;
int epfd = epoll_create1(0);
/* 通过 ioctl 设置 busy poll 参数 */
struct epoll_busy_poll_cfg cfg = {
.busy_poll_usecs = 50, /* 轮询时间(微秒) */
.busy_poll_budget = 64, /* 每次轮询最大处理包数 */
};
ioctl(epfd, EPOLL_IOC_BUSY_POLL, &cfg);内核版本注记:Busy Poll 机制自 Linux 3.11 引入全局 sysctl 配置,Linux 6.9 增加了 per-epoll 实例的 ioctl 接口。该机制以 CPU 使用率为代价换取更低延迟,适用于高频交易等对延迟极度敏感的场景,在通用服务器场景中应谨慎使用。
基于 epoll 的服务器程序
以下将基于 poll 的服务器程序改造为基于 epoll 的实现:
#include "lib/common.h"
#define MAXEVENTS 128
char rot13_char(char c) {
if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
return c + 13;
else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
return c - 13;
else
return c;
}
int main(int argc, char **argv) {
int listen_fd, socket_fd;
int n, i;
int efd;
struct epoll_event event;
struct epoll_event *events;
listen_fd = tcp_nonblocking_server_listen(SERV_PORT);
efd = epoll_create1(0);
if (efd == -1) {
error(1, errno, "epoll create failed");
}
event.data.fd = listen_fd;
event.events = EPOLLIN | EPOLLET;
if (epoll_ctl(efd, EPOLL_CTL_ADD, listen_fd, &event) == -1) {
error(1, errno, "epoll_ctl add listen fd failed");
}
/* 分配返回事件数组 */
events = calloc(MAXEVENTS, sizeof(event));
while (1) {
n = epoll_wait(efd, events, MAXEVENTS, -1);
printf("epoll_wait wakeup\n");
for (i = 0; i < n; i++) {
if ((events[i].events & EPOLLERR) ||
(events[i].events & EPOLLHUP) ||
(!(events[i].events & EPOLLIN))) {
fprintf(stderr, "epoll error\n");
close(events[i].data.fd);
continue;
} else if (listen_fd == events[i].data.fd) {
struct sockaddr_storage ss;
socklen_t slen = sizeof(ss);
int fd = accept(listen_fd, (struct sockaddr *) &ss, &slen);
if (fd < 0) {
error(1, errno, "accept failed");
} else {
make_nonblocking(fd);
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET; /* 边缘触发 */
if (epoll_ctl(efd, EPOLL_CTL_ADD, fd, &event) == -1) {
error(1, errno, "epoll_ctl add connection fd failed");
}
}
continue;
} else {
socket_fd = events[i].data.fd;
printf("get event on socket fd == %d \n", socket_fd);
while (1) {
char buf[512];
if ((n = read(socket_fd, buf, sizeof(buf))) < 0) {
if (errno != EAGAIN) {
error(1, errno, "read error");
close(socket_fd);
}
break;
} else if (n == 0) {
close(socket_fd);
break;
} else {
for (i = 0; i < n; ++i) {
buf[i] = rot13_char(buf[i]);
}
if (write(socket_fd, buf, n) < 0) {
error(1, errno, "write error");
}
}
}
}
}
}
free(events);
close(listen_fd);
}代码解析
1. 创建 epoll 实例
第 23 行调用 epoll_create1(0) 创建 epoll 实例。
2. 注册监听套接字
第 28-32 行通过 epoll_ctl 将监听套接字的 EPOLLIN | EPOLLET 事件注册到 epoll 实例,使用边缘触发模式。
3. 事件循环
第 38 行调用 epoll_wait 等待事件。返回后直接遍历就绪事件数组,无需扫描所有注册的描述符。
4. 错误处理
第 41-46 行检测 EPOLLERR、EPOLLHUP 等错误事件。
5. 新连接处理
第 47-61 行处理监听套接字上的可读事件,调用 accept 获取新连接,设置为非阻塞模式,并注册到 epoll 实例。
6. 数据读写
第 63-84 行处理已连接套接字上的可读事件。由于使用边缘触发模式,必须在循环中持续读取直到 EAGAIN,确保不会遗漏数据。
实验
启动服务器:
$ ./epoll01
epoll_wait wakeup
epoll_wait wakeup
epoll_wait wakeup
get event on socket fd == 6
epoll_wait wakeup
get event on socket fd == 5
...使用 telnet 客户端连接:
$ telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to 127.0.0.1.
Escape character is '^]'.
fasfsafas
snfsfnsnf
^]
telnet> quit
Connection closed.边缘触发(Edge-Triggered)与条件触发(Level-Triggered)
epoll 提供两种事件触发模式,这是其区别于 select/poll 的重要特性。
模式对比
实验对比
以下两个程序在已连接套接字有数据可读时,不调用 read 读取,仅打印日志。
边缘触发模式:
$ ./epoll02
epoll_wait wakeup
epoll_wait wakeup
get event on socket fd == 5服务器仅在数据首次到达时被唤醒一次。
条件触发模式:
$ ./epoll03
epoll_wait wakeup
epoll_wait wakeup
get event on socket fd == 5
epoll_wait wakeup
get event on socket fd == 5
epoll_wait wakeup
get event on socket fd == 5
...服务器持续被唤醒,直到数据被读取。
两种模式的核心差异
| 维度 | 条件触发 (LT) | 边缘触发 (ET) |
|---|---|---|
| 触发条件 | 只要缓冲区非空(条件满足)就持续触发 | 仅在状态从"空"变为"非空"时触发一次 |
| 数据读取 | 可部分读取 | 必须循环读取直到 EAGAIN |
| 编程复杂度 | 较低 | 较高,需确保数据不遗漏 |
| 性能 | 略低(可能多次不必要的唤醒) | 更高(减少不必要的唤醒次数) |
| 与 select/poll 的关系 | 行为一致 | epoll 独有 |
| 适用场景 | 通用场景,从 select/poll 迁移 | 高并发、低延迟场景 |
条件触发:只要描述符上有数据可读(条件满足),每次 epoll_wait 都会返回该事件。
边缘触发:仅在描述符状态从"无可读数据"变为"有可读数据"时触发一次通知。若应用程序未将数据全部读出,后续 epoll_wait 不会再次通知,直到有新数据到达。
实践建议:边缘触发模式性能更优,但编程要求更高——必须在事件触发时循环读取直到
EAGAIN,否则可能丢失数据。条件触发模式编程更简单,与 select/poll 行为一致,适合从 select/poll 迁移的场景。Nginx 默认使用边缘触发模式,而 Redis 使用条件触发模式,两者均取得了优异的性能表现。
epoll 的历史
在 Linux 实现 epoll 之前,Windows 于 1994 年引入了 IOCP(I/O Completion Port,异步 I/O 模型),FreeBSD 于 2000 年引入了 Kqueue(I/O 事件分发框架)。
Linux 在 2002 年引入 epoll,相关设计讨论始于 2000 年。原始讨论可参考 LKML 邮件列表。
为何不移植 Kqueue
Kqueue 的核心接口 kevent 同时负责事件绑定和事件等待:
int kqueue(void);
int kevent(int kq, const struct kevent *changelist, int nchanges,
struct kevent *eventlist, int nevents,
const struct timespec *timeout);
void EV_SET(struct kevent *kev, uintptr_t ident, short filter,
u_short flags, u_int fflags, intptr_t data, void *udata);Linus Torvalds 在最初的设计讨论中明确指出:
So sticky arrays of events are good, while queues are bad. Let's take that as one of the fundamentals.
Linus 认为数组方式(类似 select/poll)是可取的,而队列方式(Kqueue 的设计)则不可取。他将 kevent 的功能拆分为两个独立接口:
struct event {
unsigned long id; /* file descriptor ID */
unsigned long event; /* bitmask of active events */
};
int bind_event(int fd, struct event *event);
int get_events(struct event *event_array, int maxnr, struct timeval *tmout);前者演化为 epoll_ctl,后者演化为 epoll_wait。最终实现中还增加了 epoll_create 用于创建 epoll 句柄。
2002 年,epoll 在 Linux 2.5.44 中首次出现,在 2.6 系列中趋于稳定,为 Linux 高性能网络 I/O 奠定了基础。
总结
epoll 是 Linux 下高性能 I/O 多路复用的事实标准,其核心优势包括:
- O(1) 事件通知:仅返回就绪事件,无需线性扫描所有注册描述符
- 红黑树管理:注册/修改/删除操作时间复杂度 O(log n)
- 回调机制:通过内核回调将就绪事件加入就绪链表,避免轮询
- 边缘触发模式:减少不必要的唤醒次数,提升性能
- 无描述符数量硬限制:受
max_user_watches系统参数约束,可动态调整
使用 epoll 时需重点理解:
- 条件触发(LT):条件满足即持续通知,编程简单
- 边缘触发(ET):仅在状态变化时通知一次,需循环读取直到
EAGAIN epoll_create1()是推荐的创建接口(Linux 2.6.27+)EPOLLONESHOT适用于多线程场景(Linux 2.6.2+)EPOLLEXCLUSIVE解决惊群问题(Linux 4.5+)epoll_pwait2()提供纳秒级超时精度(Linux 5.11+)- Busy Poll 机制可进一步降低延迟(Linux 3.11+ 全局配置,Linux 6.9+ per-epoll ioctl)
思考题
-
修改第 20 讲中 select 的示例,在已连接套接字有数据可读时不调用
read,观察 select 的行为。select 是边缘触发还是条件触发? -
同样修改第 21 讲中 poll 的示例,观察 poll 的行为。poll 是边缘触发还是条件触发?
版本信息
| 项目 | 说明 |
|---|---|
| 更新日期 | 2026-06-09 |
| 目标内核 | Linux 7.0 |
| epoll_create | Linux 2.6 — size 参数自 2.6.8 起被忽略 |
| epoll_create1 | Linux 2.6.27 — 推荐使用,支持 EPOLL_CLOEXEC |
| EPOLLONESHOT | Linux 2.6.2 — 一次性事件,需重新武装 |
| EPOLLEXCLUSIVE | Linux 4.5 — 排他唤醒,避免惊群 |
| EPOLLRDHUP | Linux 2.6.17 — 检测对端关闭/半关闭 |
| EPOLLWAKEUP | Linux 3.5 — 防止系统休眠 |
| epoll_pwait | Linux 2.6.19 — 原子信号掩码替换 |
| epoll_pwait2 | Linux 5.11 — 纳秒级超时精度 |
| busy_poll sysctl | Linux 3.11 — 全局 busy poll 配置 |
| EPOLL_IOC_BUSY_POLL | Linux 6.9 — per-epoll 实例 busy poll ioctl 接口 |
| max_user_watches | /proc/sys/fs/epoll/max_user_watches — 默认值因系统而异 |
| max_user_instances | /proc/sys/fs/epoll/max_user_instances — 默认通常为 128 |