Featured image of post Linux epoll I/O 多路复用:水平触发、边缘触发与工程实践
Linux

Linux epoll I/O 多路复用:水平触发、边缘触发与工程实践

Linux epoll I/O 多路复用:水平触发、边缘触发与工程实践

高并发服务里最常见的一类卡顿不是“CPU 不够”,而是 大量连接上只有少量真正可读/可写。若对每个连接开线程阻塞 read/write,上下文切换与内存开销会迅速失控;若用轮询扫所有 fd,又会把 CPU 烧掉。Linux 提供的 epoll 就是为“只在就绪时被唤醒”设计的 I/O 事件通知机制。

本文基于 Linux man-pages 的 epoll(7)epoll_create(2)/epoll_create1(2)epoll_ctl(2)epoll_wait(2),并结合 Python selectors 与 Nginx 连接处理文档,把 interest/ready 两张表、LT/ET 语义、ONESHOT、非阻塞与 EAGAIN、常见坑与排障 讲清楚,方便你在自研事件循环或排查 Nginx/Redis 类服务时按机制定位。

一、问题背景:为什么 select/poll 会“长不大”

传统 select(2) / poll(2) 也能多路复用,但工程上有两个长期痛点:

  1. 每次调用都要反复提交关注集合。调用者把“我关心哪些 fd、关心读还是写”整包传给内核;内核再线性扫描。连接数上来后,用户态↔内核态的拷贝与扫描成本随 N 增长。
  2. 就绪集合表达方式不友好select 还有历史性的 fd 数量上限(与 FD_SETSIZE 相关),大规模长连接场景很难优雅扩展。

epoll 的定位不同:它在内核里维护一个 epoll 实例(本质是“容器 + 回调”),应用 只注册一次兴趣,之后用 epoll_wait 拉取当前就绪事件。man 页明确指出:epoll 既可作 水平触发(level-triggered, LT) 也可作 边缘触发(edge-triggered, ET),并且 能较好扩展到大量被监视的文件描述符

二、核心机制:interest list 与 ready list

从用户视角看,一个 epoll 实例可理解为两张表(epoll(7) 原文概念):

结构含义
interest list(epoll set)进程通过 epoll_ctl 注册、希望监视的 fd 集合
ready listinterest list 的子集(更准确:其中“就绪”的引用集合),由内核因 I/O 活动动态填充

配套系统调用职责清晰:

调用作用
epoll_create / epoll_create1创建 epoll 实例,返回 epoll 自身的 fd
epoll_ctl对 interest list 做 ADD / MOD / DEL
epoll_wait / epoll_pwait从 ready list 取回最多 maxevents 个事件;无事件时可阻塞

2.1 创建实例:优先 epoll_create1

epoll_create(size) 在 Linux 2.6.8 之后 忽略 size(但仍要求 size > 0)。更推荐 epoll_create1(flags):可传 EPOLL_CLOEXEC,避免 fork+exec 时泄漏 epoll fd(与 open(2)O_CLOEXEC 同理)。

2.2 注册兴趣:epoll_ctl 与事件掩码

struct epoll_event {
    uint32_t     events;  /* EPOLLIN | EPOLLOUT | ... */
    epoll_data_t data;    /* 用户数据:常存 fd 或指针 */
};

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

常用 op

  • EPOLL_CTL_ADD:加入 interest list
  • EPOLL_CTL_MOD:修改已有项的事件掩码/用户数据
  • EPOLL_CTL_DEL:从 interest list 删除

常见事件位(epoll_ctl(2)):

标志含义
EPOLLIN可读(含对端数据、监听 socket 有新连接等)
EPOLLOUT可写
EPOLLRDHUP流式套接字对端关闭或关闭写半部(2.6.17+,ET 下检测对端关闭很有用)
EPOLLPRI带外/异常条件(语义见 poll(2)POLLPRI
EPOLLERR错误;无需events 里显式设置,epoll_wait 总会报告
EPOLLHUPhang up;同样 总会报告,不必手动设置
EPOLLET请求 边缘触发(默认是水平触发)
EPOLLONESHOT通知一次后自动禁用该 fd,需 MOD 重新武装
EPOLLEXCLUSIVE多 epoll 监听同一 fd 时的互斥唤醒(4.5+)

工程建议:监听 socket 与连接 socket 都建议设为非阻塞;读写方向若会频繁切换,可一次 ADD 时就挂 EPOLLIN|EPOLLOUT,避免来回 MOD 切换。

2.3 等待就绪:epoll_wait

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

要点:

  • maxevents 必须 > 0,表示输出数组容量。
  • timeout 单位毫秒:0 立即返回;-1 一直阻塞;正数限时等待。时间基准为 CLOCK_MONOTONIC
  • 返回值:就绪事件个数;0 表示超时;-1 出错(常见 EINTR)。
  • 返回的 events[i].events实际发生 的事件掩码;data 原样带回你在 ctl 时写入的用户数据。

epoll_wait 的返回策略还会尽量避免 饥饿:若某次只处理“已经知道就绪”的一小撮 fd,而忽略其他已就绪 fd,系统可能长期“看不见”后者。应用层也应做 就绪队列 + 轮转处理,而不是在单次事件上无限 read 到饿死其它连接(man 页 “Starvation” 小节专门提醒)。

三、水平触发 vs 边缘触发:语义决定写法

这是 epoll 最容易写错的地方。epoll(7) 用管道例子讲得很直白:

  1. 把管道读端 rfd 加入 epoll;
  2. 写端写入 2KB;
  3. 调用 epoll_wait → 报告 rfd 可读;
  4. 应用只读了 1KB,缓冲区仍剩数据;
  5. 再次 epoll_wait
    • LT(默认):只要缓冲区仍“可读”,就会 继续 报告就绪;
    • ET(EPOLLET:只在状态 发生变化 时报告;若你没把数据读到“暂时不可读”,可能 不再通知,下一次 wait 会“卡住”,尽管缓冲里还有数据。

3.1 LT:像 poll,好写,易“吵”

  • 语义接近 poll:条件成立就通知。
  • 适合:业务逻辑简单、可接受重复通知、希望少踩坑。
  • 风险:若某 fd 长期可读但你处理很慢,它会 反复 出现在 ready list,拖慢整个循环。

3.2 ET:变化才通知,必须“抽干 + 非阻塞”

man 页对 ET 的建议可压缩成两条硬规则:

  1. fd 必须非阻塞。否则一次阻塞 read/write 可能饿死处理其它 fd 的任务。
  2. 只有在 read/write 返回 EAGAIN(或等价“暂时不可用”)之后,才回去 epoll_wait。也就是说,事件到来后要 循环读写直到资源暂时耗尽,不能“读一次就回 wait”。

ET 适合:超高连接数、希望减少重复唤醒、团队能严格遵守非阻塞与 drain 协议的场景。很多高性能网络库默认 ET + 非阻塞。

3.3 EPOLLONESHOT:一次性通知与重武装

设置 EPOLLONESHOT 后,某个 fd 被 epoll_wait 报告一次,就会在 interest list 中被 禁用,直到你用 EPOLL_CTL_MOD 重新启用。它常用于:

  • 多线程 worker:保证同一连接同一时刻只被一个线程处理;
  • 防止在处理完成前被其它线程再次取到同一事件。

代价是:处理结束后 必须记得 rearm,否则该连接“静音”。

四、最小可运行实验:管道上的 LT vs ET

下面用 Python 演示 同一场景在 LT/ET 下的不同表现(Linux 上 selectors.EpollSelector;其它平台会落到 kqueue/select 等,行为不完全等价)。目标不是抄生产框架,而是把 man 页语义变成可观测实验。

#!/usr/bin/env python3
"""LT vs ET demo on a pipe (Linux)."""
import os, select, sys

def make_pipe():
    r, w = os.pipe()
    # set non-blocking on read end for ET-safe drain
    os.set_blocking(r, False)
    os.set_blocking(w, False)
    return r, w

def run(edge: bool):
    r, w = make_pipe()
    ep = select.epoll()
    flags = select.EPOLLIN
    if edge:
        flags |= select.EPOLLET
    ep.register(r, flags)

    os.write(w, b"A" * 2048)
    print("mode:", "ET" if edge else "LT")

    # first wait should fire
    ev = ep.poll(timeout=0.2)
    print(" first poll:", ev)

    # only read 1024, leave 1024 in buffer
    data = os.read(r, 1024)
    print(" partial read:", len(data))

    # second wait: LT should still report; ET often does not
    ev2 = ep.poll(timeout=0.2)
    print(" second poll:", ev2)

    # drain remaining
    rest = b""
    while True:
        try:
            chunk = os.read(r, 4096)
            if not chunk:
                break
            rest += chunk
        except BlockingIOError:
            break
    print(" drained rest:", len(rest))
    ep.close(); os.close(r); os.close(w)

if __name__ == "__main__":
    run(edge=False)
    print("---")
    run(edge=True)

预期现象(内核版本与缓冲细节可能略有差异,但方向稳定):

  • LT:partial read 后第二次 poll 仍可能看到 EPOLLIN(缓冲仍可读)。
  • ET:partial read 后第二次 poll 往往 超时为空,直到再次有“新数据写入”或你换回 LT/ONESHOT 策略。

这正是生产代码里 “ET 下偶发假死:连接其实有数据,事件循环却不再被唤醒” 的最小复现。

4.1 监听 socket 的标准骨架(伪代码)

/* listener, conn 均为 nonblocking */
epoll_ctl(epfd, EPOLL_CTL_ADD, listener, &(struct epoll_event){
    .events = EPOLLIN | (use_et ? EPOLLET : 0),
    .data.fd = listener,
});

for (;;) {
    int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {
        int fd = events[i].data.fd;
        if (fd == listener) {
            for (;;) { /* ET 下要循环 accept 到 EAGAIN */
                int c = accept(listener, ...);
                if (c < 0) { if (errno == EAGAIN) break; /* handle error */ }
                set_nonblocking(c);
                epoll_ctl(epfd, EPOLL_CTL_ADD, c, &(struct epoll_event){
                    .events = EPOLLIN | EPOLLRDHUP | (use_et ? EPOLLET : 0),
                    .data.fd = c,
                });
            }
        } else {
            do_use_fd(fd); /* ET: 读写到 EAGAIN;注意 EPOLLERR/HUP */
        }
    }
}

do_use_fd 在 ET 下必须把 可读数据读尽、可写缓冲区写满到 EAGAIN;半包协议要在用户态自己做缓冲拼装。

五、工程映射:语言运行时与 Nginx

5.1 Python selectors

标准库 selectors 把 epoll/kqueue/select 统一成高层 API:

  • DefaultSelector:当前平台最高效实现的别名(Linux 上通常是 EpollSelector)。
  • EVENT_READ / EVENT_WRITE:跨平台事件抽象。
  • register / unregister / modify / select:对应 ctl + wait 的职责拆分。

自研 asyncio 之外的小工具时,优先用 selectors,比直接 select.epoll 更易移植;但若你要精细控制 EPOLLET/ONESHOT,仍需回到平台原生接口。

5.2 Nginx 的连接处理方式

Nginx 文档 Connection processing methods 写明:Linux 2.6+ 使用高效的 epoll 方法,并支持 EPOLLRDHUPEPOLLEXCLUSIVE 等标志。平台支持多种方法时,Nginx 会自动选最高效者,也可用 use 指令显式指定。这解释了为何“同一套配置在 Linux 与 FreeBSD 上行为细节不同”——底层分别是 epoll 与 kqueue。

六、常见坑与排查清单

症状更可能的机制原因排查/修复
ET 下连接“有数据但不再触发”partial read 后未 drain 到 EAGAIN循环读;确认非阻塞;对照 man 管道示例
单连接阻塞拖死整进程阻塞 fd + 同步 read/write全部 O_NONBLOCK;慢业务丢线程池/协程
某连接饿死其它连接单 fd 上无限处理大流量就绪列表 + 轮转;限制单次读写字节/次数
ONESHOT 后永久静音处理完未 EPOLL_CTL_MOD rearm统一在处理结束路径 rearm
对端已关仍不清理只看 EPOLLIN,忽略挂断处理 EPOLLRDHUP/EPOLLHUP/EPOLLERR,读到 0 关闭
epoll_ctl ADDEEXIST重复 ADD 同一 fdMOD 改掩码,或先 DEL
epoll_wait 被信号打断返回 -1 + EINTR循环重试,或使用 epoll_pwait 管理信号掩码
多进程抢同一监听 socket惊群/重复唤醒评估 EPOLLEXCLUSIVESO_REUSEPORT 等策略

排障时建议同时抓三份证据:

  1. fd 是否非阻塞fcntl 标志);
  2. interest 掩码是否含 EPOLLET/ONESHOT/RDHUP
  3. 一次事件处理是否读/写到 EAGAIN,以及是否处理 hangup。

七、和 select/poll 的选型建议

  • 小工具、fd 很少、要极致可移植poll/select 或高层封装足够。
  • Linux 上万级长连接、自研事件循环:epoll(多数生产库默认 ET + 非阻塞)。
  • 跨 Unix 可移植高性能库:用 libuv / netty / Go runtime 等已封装的多路复用层,而不是在业务里手写三套 epoll/kqueue/IOCP。
  • 只做业务、不碰事件循环:理解 LT/ET 与非阻塞,是为了读懂框架文档和 flame graph,而不是 reinvent。

八、总结

  1. epoll 在内核维护 interest list + ready list,用 create/ctl/wait 三件套把“注册”和“等待”拆开,从而在大规模 fd 上更省。
  2. 默认 LT:条件成立就通知,写法像 poll;ET:状态变化才通知,必须 非阻塞 + 读/写到 EAGAIN
  3. EPOLLONESHOT 适合多线程串行化处理,但务必 处理完 rearmEPOLLRDHUP/HUP/ERR 是连接回收的关键信号。
  4. 饥饿、假死、惊群,大多不是“epoll 不灵”,而是 事件语义与读写协议不匹配。先复现 LT/ET 差异,再改生产代码。

掌握这些机制后,再看 Nginx worker、Redis 事件循环、Netty EventLoop 或自研网关,会发现它们都在同一套规则上做工程化封装。

参考资料

  1. Linux man-pages: epoll(7) — interest/ready、LT/ET、ONESHOT、建议用法与陷阱
  2. Linux man-pages: epoll_ctl(2) — 事件标志、ADD/MOD/DEL、EPOLLET/ONESHOT/EXCLUSIVE
  3. Linux man-pages: epoll_wait(2) — timeout、maxevents、就绪返回语义
  4. Linux man-pages: epoll_create(2) / epoll_create1(2) — 创建实例与 EPOLL_CLOEXEC
  5. Python 3 Docs: selectors — High-level I/O multiplexingEpollSelector / DefaultSelector
  6. Nginx Docs: Connection processing methods — Linux 上 epoll 方法及 RDHUP/EXCLUSIVE 支持说明
使用 Hugo 构建
主题 StackJimmy 设计