{T}

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

c
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 fdLinux 2.6.27

内核版本注记epoll_create1() 自 Linux 2.6.27 引入,是推荐使用的创建接口。EPOLL_CLOEXEC 标志可防止文件描述符在 fork + exec 场景下泄漏到子进程,在多进程服务端程序中尤为重要。

epoll 实例本身也是一个文件描述符,不再使用时应调用 close() 释放,以便内核回收相关资源。

epoll_ctl

c
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 结构体

c
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

c
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 替代整数毫秒参数。对于需要精确超时控制的高性能场景(如低延迟交易系统),此接口提供了更细粒度的时间控制能力。

c
/* 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 的监控能力受以下系统参数约束:

code
$ cat /proc/sys/fs/epoll/max_user_watches
8192

max_user_watches 限制单个用户可注册的 epoll 监控总数(即兴趣列表中的条目总数)。默认值因系统配置而异,通常为几千到数万。在高并发场景下,可能需要通过 /etc/sysctl.conf 增大此值:

code
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 队列中拉取数据,跳过中断处理环节。

系统级配置

code
# 全局启用 busy poll,设置轮询时间(微秒)
$ echo 50 > /proc/sys/net/core/busy_poll
$ echo 50 > /proc/sys/net/core/busy_read

ioctl 接口(Linux 6.9+):Linux 6.9 引入了基于 ioctl 的 per-socket busy poll 配置接口,允许对单个套接字设置 busy poll 参数,无需依赖全局 sysctl 配置:

c
/* 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 的实现:

c
#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 行检测 EPOLLERREPOLLHUP 等错误事件。

5. 新连接处理

第 47-61 行处理监听套接字上的可读事件,调用 accept 获取新连接,设置为非阻塞模式,并注册到 epoll 实例。

6. 数据读写

第 63-84 行处理已连接套接字上的可读事件。由于使用边缘触发模式,必须在循环中持续读取直到 EAGAIN,确保不会遗漏数据。

实验

启动服务器:

code
$ ./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 客户端连接:

code
$ 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 读取,仅打印日志。

边缘触发模式

code
$ ./epoll02
epoll_wait wakeup
epoll_wait wakeup
get event on socket fd == 5

服务器仅在数据首次到达时被唤醒一次。

条件触发模式

code
$ ./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 同时负责事件绑定和事件等待:

c
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 的功能拆分为两个独立接口:

c
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)

思考题

  1. 修改第 20 讲中 select 的示例,在已连接套接字有数据可读时不调用 read,观察 select 的行为。select 是边缘触发还是条件触发?

  2. 同样修改第 21 讲中 poll 的示例,观察 poll 的行为。poll 是边缘触发还是条件触发?

版本信息

项目说明
更新日期2026-06-09
目标内核Linux 7.0
epoll_createLinux 2.6 — size 参数自 2.6.8 起被忽略
epoll_create1Linux 2.6.27 — 推荐使用,支持 EPOLL_CLOEXEC
EPOLLONESHOTLinux 2.6.2 — 一次性事件,需重新武装
EPOLLEXCLUSIVELinux 4.5 — 排他唤醒,避免惊群
EPOLLRDHUPLinux 2.6.17 — 检测对端关闭/半关闭
EPOLLWAKEUPLinux 3.5 — 防止系统休眠
epoll_pwaitLinux 2.6.19 — 原子信号掩码替换
epoll_pwait2Linux 5.11 — 纳秒级超时精度
busy_poll sysctlLinux 3.11 — 全局 busy poll 配置
EPOLL_IOC_BUSY_POLLLinux 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