跳转至

epoll

是什么 / 解决什么问题

epoll 是 Linux 特有的 I/O 多路复用机制,用一个线程高效监听数千到数百万个文件描述符的就绪事件。解决了 select/poll 在大量 fd 场景下 O(n) 扫描的性能问题。

核心 API

#include <sys/epoll.h>

// 创建 epoll 实例,返回 epoll fd
int epfd = epoll_create1(EPOLL_CLOEXEC);

// 注册/修改/删除 fd 的监听
struct epoll_event ev = {
    .events = EPOLLIN | EPOLLET,  // 关注的事件 + 触发模式
    .data.fd = sock_fd,           // 用户数据,事件就绪时原样返回
};
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ev);
epoll_ctl(epfd, EPOLL_CTL_MOD, sock_fd, &ev);
epoll_ctl(epfd, EPOLL_CTL_DEL, sock_fd, NULL);

// 等待事件就绪
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, timeout_ms);
// timeout_ms: -1 永久阻塞, 0 立即返回, >0 超时毫秒

使用模式

基本事件循环

#define MAX_EVENTS 1024

int epfd = epoll_create1(EPOLL_CLOEXEC);
struct epoll_event events[MAX_EVENTS];

// 注册 listen_fd
struct epoll_event ev = { .events = EPOLLIN, .data.fd = listen_fd };
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);

for (;;) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        if (events[i].data.fd == listen_fd) {
            // 新连接到来
            int conn_fd = accept4(listen_fd, NULL, NULL, SOCK_NONBLOCK | SOCK_CLOEXEC);
            struct epoll_event cev = { .events = EPOLLIN | EPOLLET, .data.fd = conn_fd };
            epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &cev);
        } else {
            handle_client(events[i].data.fd, events[i].events);
        }
    }
}

存储自定义上下文

epoll_event.data 是一个 union,可以存指针而非 fd:

struct connection {
    int fd;
    char *write_buf;
    size_t write_len;
    // ...
};

struct connection *conn = malloc(sizeof(*conn));
conn->fd = client_fd;

struct epoll_event ev = {
    .events = EPOLLIN | EPOLLET,
    .data.ptr = conn,  // 存指针
};
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);

// 事件就绪时取出
struct connection *c = (struct connection *)events[i].data.ptr;

行为与语义

LT (Level-Triggered) — 水平触发(默认)

  • 只要 fd 处于就绪状态,每次 epoll_wait 都会返回它
  • 行为类似 poll:有数据可读就一直通知
  • 你可以不一次读完,下次 epoll_wait 还会再通知
时间线:
  数据到达 ─────────────────────────────────
  epoll_wait:  返回  返回  返回  返回  ... (每次都返回,直到你读完)

ET (Edge-Triggered) — 边缘触发

  • 只在状态变化时通知一次(从不可读变为可读)
  • 后续不再通知,直到新数据到达引起新的状态变化
  • 必须一次读完所有数据(读到 EAGAIN)
时间线:
  数据到达 ─────────────────── 新数据到达 ────
  epoll_wait:  返回                返回       (只在边缘通知)

ET 模式的读取模板

// ET 模式下必须循环读到 EAGAIN
void handle_read_et(int fd) {
    char buf[4096];
    for (;;) {
        ssize_t n = read(fd, buf, sizeof(buf));
        if (n > 0) {
            process_data(buf, n);
        } else if (n == 0) {
            // 对端关闭
            close(fd);
            return;
        } else {
            if (errno == EAGAIN || errno == EWOULDBLOCK) {
                break;  // 读完了,正常退出
            }
            // 真正的错误
            perror("read");
            close(fd);
            return;
        }
    }
}

LT vs ET 对比

特性 LT ET
通知频率 重复通知直到处理完 只通知一次
编程复杂度 低(不读完也行) 高(必须读完/写满)
epoll_wait 调用次数 多(会被同一 fd 反复唤醒)
适用 逻辑简单的场景 高性能服务器(Nginx 用 ET)
是否需要非阻塞 fd 建议用,但不强制 必须用非阻塞

EPOLLONESHOT

注册时加 EPOLLONESHOT,事件触发一次后自动禁用,需要手动 EPOLL_CTL_MOD 重新激活:

// 适合多线程场景:防止多个线程同时处理同一个 fd
ev.events = EPOLLIN | EPOLLET | EPOLLONESHOT;

性能考量

为什么 epoll 比 select/poll 快

机制 每次调用开销
select O(n) — 拷贝整个 fd_set 到内核,内核遍历所有 fd
poll O(n) — 同上,只是数据结构不同
epoll_wait O(活跃 fd 数) — 内核维护就绪链表,只返回就绪的

epoll_ctl 注册时内核用红黑树管理 fd,事件就绪时通过回调加入就绪链表。epoll_wait 只需拷贝就绪链表。

最佳实践

  1. MAX_EVENTS 设合理值:太小导致频繁调用 epoll_wait,太大浪费栈空间。通常 256~1024。
  2. 用 EPOLL_CLOEXEC:避免 fd 泄漏到子进程。
  3. ET 模式减少 epoll_wait 唤醒:高吞吐场景首选。
  4. 避免 thundering herd:多线程共享同一 epfd 时用 EPOLLEXCLUSIVE(4.5+)。
// EPOLLEXCLUSIVE:多线程只唤醒一个(避免惊群)
ev.events = EPOLLIN | EPOLLEXCLUSIVE;

epoll 的开销

  • epoll_create1:分配内核数据结构(一次性)
  • epoll_ctl:红黑树操作 O(log n)(仅注册/修改时)
  • epoll_wait:O(就绪数),但涉及用户态/内核态切换

如果连接数少(< 几十个),epoll 的优势不明显,poll 甚至更快(少了注册开销)。

常见陷阱与 FAQ

1. ET 模式忘记读完

// 错误:ET 模式只读一次
n = read(fd, buf, sizeof(buf));
// 如果内核缓冲区有 8KB 数据,你只读了 4KB
// 剩下的 4KB 不会再触发通知 → 数据"丢失"(实际还在缓冲区但不被处理)

2. 关闭 fd 后忘记 epoll_ctl DEL

实际上 close(fd) 会自动从 epoll 移除(内核处理)。但如果 fd 被 dup 过,close 一个副本不会移除

int fd2 = dup(fd);
close(fd);       // epoll 仍在监听!因为 fd2 还引用着同一文件描述
close(fd2);      // 这时才真正移除

最佳实践:显式调用 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL) 再 close。

3. EPOLLHUP 和 EPOLLERR 不需要注册

这两个事件始终会被报告,无论你是否在 events 中设置它们:

// 即使你只注册了 EPOLLIN,也会收到 EPOLLHUP
if (events[i].events & (EPOLLHUP | EPOLLERR)) {
    close(events[i].data.fd);  // 必须处理
}

4. epoll_wait 被信号中断

int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (n == -1) {
    if (errno == EINTR) continue;  // 被信号打断,重试
    perror("epoll_wait");
    break;
}

5. 存指针时的生命周期问题

// 危险:conn 被释放后 epoll 仍持有野指针
free(conn);
// 下次 epoll_wait 返回时 data.ptr 指向已释放内存
// 正确顺序:先 DEL 再 free
epoll_ctl(epfd, EPOLL_CTL_DEL, conn->fd, NULL);
close(conn->fd);
free(conn);

观测与调试

# 查看进程持有的 epoll fd
ls -la /proc/<pid>/fd/ | grep eventpoll

# strace 观察 epoll 调用
strace -e trace=epoll_create1,epoll_ctl,epoll_wait -p <pid>

# 查看 epoll 监听了哪些 fd(需要 root)
cat /proc/<pid>/fdinfo/3
# 输出示例:
# pos: 0
# flags: 02
# mnt_id: 1
# tfd: 5 events: 19 data: 7f8a3c000b60

# bpftrace 统计 epoll_wait 返回的事件数分布
bpftrace -e 'tracepoint:syscalls:sys_exit_epoll_wait { @ret = hist(args->ret); }'

延伸阅读

  • man 7 epoll — 最权威的语义说明
  • man 2 epoll_ctl / man 2 epoll_wait — 参数细节
  • Nginx 的 src/event/modules/ngx_epoll_module.c — 工业级 epoll 使用范例
  • Redis 的 src/ae_epoll.c — 极简 epoll 封装