现代高性能 IO:epoll、io_uring 与异步 IO

The C10K Problem:为什么 poll/select 不具扩展性 在 1999 年,开发者 Dan Kegel 提出了著名的 C10K Problem:如何让一台单机同时处理 10,000 个并发连接?

The C10K Problem:为什么 poll/select 不具扩展性

在 1999 年,开发者 Dan Kegel 提出了著名的 C10K Problem:如何让一台单机同时处理 10,000 个并发连接?这个问题在当时看似遥不可及,但它深刻揭示了一个核心矛盾 —— 传统的 IO 多路复用机制在连接数增长时,性能急剧恶化。

select 和 poll 的致命缺陷在于 O(n) 复杂度的事件通知模型:

当调用 select() 时,内核需要遍历用户传入的 所有 fd(文件描述符),逐个检查它们是否有数据可读或可写。假设你维护了 10,000 个连接,而某个时刻只有 100 个活跃,内核依然要扫描全部 10,000 个。每一次 select 调用都是一次全量扫描,并且每次调用前都需要重新构建 fd 集合(因为 select 会修改传入的数组)。poll 虽然取消了 1024 的硬限制(select 在 FD_SETSIZE 不变编译时默认最大 1024),但其底层依然是基于 pollfd 数组的 O(n) 线性扫描。

这种设计在并发数较低时无伤大雅,但一旦面临 Web 服务器、反向代理、消息队列这种需要维持海量长连接的场景,CPU 大量时间被浪费在检查"无事可做"的 fd 上。C10K Problem 的出现并非意味着单机无法处理 10K 并发,而是要求操作系统提供更高效的事件通知机制,于是 epoll 诞生。


epoll:内核态事件驱动的基石

epoll 从 Linux 2.6 起成为默认的高性能 IO 多路复用方案。它的核心思想是将"关注哪些 fd"与"哪些 fd 就绪了"分离,避免每次调用都全量扫描。

核心数据结构

epoll 在内核中维护两个关键结构:

  1. 红黑树(rbtree):存储所有已注册的 fd(通过 epoll_ctl(EPOLL_CTL_ADD) 加入)。红黑树保证了插入、删除、查找的时间复杂度为 O(log n)。
  2. 就绪链表(ready list):当某个 fd 上有事件发生时(比如 socket 收到数据),内核回调 ep_poll_callback,将对应的 epitem 挂到就绪链表中。

因此,当用户调用 epoll_wait() 时,内核不需要遍历所有 fd,而是直接查看就绪链表,将活跃事件拷贝到用户空间。无论注册了多少 fd,epoll_wait 的时间复杂度仅与实际就绪的 fd 数量成正比。

LT(水平触发) vs ET(边缘触发)

epoll 提供两种触发模式,这是理解和正确使用 epoll 的关键:

  • LT(Level-Triggered,水平触发):只要 fd 仍处于就绪状态(比如 socket 接收缓冲区还有未读数据),每次 epoll_wait 都会返回该 fd。这是默认行为,与 select/poll 逻辑一致,编程友好但可能产生多余的事件通知。
  • ET(Edge-Triggered,边缘触发):仅在 fd 状态发生变化时触发一次。比如 socket 从空变为有数据时,epoll 通知一次。之后无论你读没读、读了多少,epoll_wait 都不会因为这个 fd 再次返回,直到新的数据到达导致状态再次变化。

ET 模式必须将 fd 彻底排空。原因很直观:如果你只读了部分数据,缓冲区里还有残留,但由于状态没有从"有数据"变为"无数据"再变回"有数据",epoll 不会再通知你,这部分数据将被"遗忘"直到下一次新数据到达。因此,ET 模式下通常要用非阻塞 fd 配合循环 read() 直到返回 EAGAIN。

ET 的优势在于减少了 epoll 回调次数,在高吞吐场景下能降低内核态与用户态的切换开销。

EPOLLONESHOT

EPOLLONESHOT 是一个容易被忽略但有用的标志。当一个 fd 被设置了这个标志后,epoll 会将其从就绪链表中自动禁用(不是从红黑树中删除),直到用户显式地通过 epoll_ctl(EPOLL_CTL_MOD) 重新启用。在多线程环境中,这确保了某个 fd 的读写事件只会被一个工作线程获取和处理,避免竞态。

Reactor 模式映射

epoll 天然契合 Reactor(反应堆)模式:

  • 事件多路分解器:epoll_wait 扮演这个角色,阻塞等待 IO 事件。
  • 事件处理器:当事件就绪后,Reactor 将事件分发给注册的回调(如 on_readable、on_writable)。
  • 同步 IO:epoll 本身只负责通知"数据已就绪",真正的 read()/write() 还是由用户线程同步执行。

Netty、Nginx、Redis (单线程 Reactor)、Node.js 都是基于 epoll(或平台等价的 kqueue)构建的 Reactor 架构。

epoll echo server 示例

#define MAX_EVENTS 1024
#define PORT 8080

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <fcntl.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>

static void set_nonblocking(int fd) {
    int flags = fcntl(fd, F_GETFL, 0);
    fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}

static int create_listen_socket(int port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    set_nonblocking(fd);

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(port);
    bind(fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(fd, 128);
    return fd;
}

int main(void) {
    int listen_fd = create_listen_socket(PORT);
    int epoll_fd = epoll_create1(0);
    struct epoll_event ev, events[MAX_EVENTS];

    ev.events = EPOLLIN;
    ev.data.fd = listen_fd;
    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev);

    printf("epoll server listening on port %d\n", PORT);

    while (1) {
        int nfds = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
        for (int i = 0; i < nfds; i++) {
            int fd = events[i].data.fd;

            if (fd == listen_fd) {
                struct sockaddr_in client_addr;
                socklen_t len = sizeof(client_addr);
                int conn_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &len);
                set_nonblocking(conn_fd);
                ev.events = EPOLLIN | EPOLLET;
                ev.data.fd = conn_fd;
                epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &ev);
            } else if (events[i].events & EPOLLIN) {
                char buf[4096];
                int n = read(fd, buf, sizeof(buf));
                if (n > 0) {
                    write(fd, buf, n);  // echo back
                } else {
                    close(fd);
                }
            }
        }
    }
    close(epoll_fd);
    return 0;
}

io_uring:Linux 5.1+ 的异步 IO 革命

如果说 epoll 解决了"如何高效知道 fd 就绪"的问题,那么 io_uring 解决的是更深层的"如何高效执行 IO"的问题。由 Jens Axboe 主导引入 Linux 5.1,io_uring 彻底重构了用户态与内核态的交互方式。

核心架构:SQ 与 CQ 环形缓冲区

io_uring 的关键创新在于共享内存环形队列:

  • Submission Queue(SQ):用户态将 IO 请求(如 read、write、accept)以 Submission Queue Entry(SQE)的形式放入此队列。
  • Completion Queue(CQ):内核完成 IO 后,将结果以 Completion Queue Entry(CQE)的形式放入此队列。

这两个队列通过 mmap 映射到用户空间,用户态与内核态直接读写共享内存,无需传统系统调用中的参数拷贝。更关键的是,在 fast path 上(初始化之后),用户态不需要发出任何系统调用即可提交请求和收割结果,这消除了传统 epoll_wait + read/write 路径上的 syscall 开销。

主要优势

  1. 零系统调用 fast path:批量提交和批量收割全部通过内存操作完成。
  2. 网络与磁盘 IO 统一:epoll 对网络 IO 友好但对文件系统 IO 无能为力(传统 Linux AIO 存在诸多限制,如 O_DIRECT 要求)。io_uring 统一了两者。
  3. 批量处理:一次提交成百上千个 SQE,内核可以更高效地调度和合并请求。
  4. Polling 模式:开启 IORING_SETUP_IOPOLL(用于块设备)或 IORING_SETUP_SQPOLL(内核线程持续轮询 SQ),可将延迟压到微秒级,完全绕过中断开销。

SQE 与 CQE

  • SQE(Submission Queue Entry):描述一个具体操作,包括 opcode(操作类型,如 IORING_OP_READ、IORING_OP_WRITE、IORING_OP_ACCEPT 等)、fd、buffer 地址、偏移量、flags 等。
  • CQE(Completion Queue Entry):包含操作的完成结果,主要是 res(返回值,可能为成功字节数或负的错误码)和 user_data(用户传入的标识,通常用于关联请求与回调)。

io_uring echo server 示例

#define QUEUE_DEPTH 256
#define PORT 8080

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <liburing.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define TYPE_ACCEPT 0
#define TYPE_READ   1
#define TYPE_WRITE  2

struct conn_info {
    int fd;
    int type;
};

static int create_listen_socket(int port) {
    int fd = socket(AF_INET, SOCK_STREAM, 0);
    int opt = 1;
    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
    fcntl(fd, F_SETFL, fcntl(fd, F_GETFL, 0) | O_NONBLOCK);

    struct sockaddr_in addr = {0};
    addr.sin_family = AF_INET;
    addr.sin_addr.s_addr = INADDR_ANY;
    addr.sin_port = htons(port);
    bind(fd, (struct sockaddr *)&addr, sizeof(addr));
    listen(fd, 128);
    return fd;
}

static void add_accept(struct io_uring *ring, int fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct conn_info *ci = malloc(sizeof(struct conn_info));
    ci->fd = fd;
    ci->type = TYPE_ACCEPT;
    io_uring_prep_accept(sqe, fd, NULL, NULL, 0);
    io_uring_sqe_set_data(sqe, ci);
}

static void add_read(struct io_uring *ring, int fd) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct conn_info *ci = malloc(sizeof(struct conn_info));
    ci->fd = fd;
    ci->type = TYPE_READ;
    void *buf = malloc(4096);
    io_uring_prep_read(sqe, fd, buf, 4096, 0);
    io_uring_sqe_set_data(sqe, ci);
}

static void add_write(struct io_uring *ring, int fd, void *buf, size_t len) {
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    struct conn_info *ci = malloc(sizeof(struct conn_info));
    ci->fd = fd;
    ci->type = TYPE_WRITE;
    io_uring_prep_write(sqe, fd, buf, len, 0);
    io_uring_sqe_set_data(sqe, ci);
}

int main(void) {
    struct io_uring ring;
    struct io_uring_params params = {0};
    io_uring_queue_init_params(QUEUE_DEPTH, &ring, &params);

    int listen_fd = create_listen_socket(PORT);
    add_accept(&ring, listen_fd);
    io_uring_submit(&ring);

    printf("io_uring server listening on port %d\n", PORT);

    while (1) {
        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(&ring, &cqe);

        struct conn_info *ci = io_uring_cqe_get_data(cqe);
        if (ci->type == TYPE_ACCEPT) {
            int conn_fd = cqe->res;
            if (conn_fd >= 0) {
                fcntl(conn_fd, F_SETFL, fcntl(conn_fd, F_GETFL, 0) | O_NONBLOCK);
                add_read(&ring, conn_fd);
            }
            add_accept(&ring, listen_fd);
        } else if (ci->type == TYPE_READ) {
            int bytes = cqe->res;
            if (bytes > 0) {
                add_write(&ring, ci->fd, io_uring_cqe_get_data(cqe), bytes); /* 简化:实际需要保存 buf 指针 */
            } else {
                close(ci->fd);
            }
        } else if (ci->type == TYPE_WRITE) {
            add_read(&ring, ci->fd);
        }

        free(ci);
        io_uring_cqe_seen(&ring, cqe);
        io_uring_submit(&ring);
    }

    io_uring_queue_exit(&ring);
    return 0;
}

(注:完整生产级代码需管理 buffer 生命周期与错误边界。)

与 epoll 的对比

对于服务器工作负载,两者并非简单的替代关系:

  • epoll 的编程模型简单直观,在 CPU 不是绝对瓶颈时已经足够高效。
  • io_uring 在高吞吐、低延迟极限场景下更具优势,尤其是需要同时处理大量磁盘和网络 IO 时。
  • io_uring 的成熟度正在快速提高(Linux 5.10+ 已较为稳定),但相比 epoll 二十年的生态积累,第三方库和调试工具仍在完善中。

select / poll / epoll / io_uring 对比

机制复杂度可扩展性典型适用场景
selectO(n) 全量 fd 扫描约 1024 fds(受 FD_SETSIZE 限制)遗留代码,极度简单的程序
pollO(n) pollfd 数组扫描无硬性上限,但随 fd 数增长线性变慢中等并发量,兼容 POSIX
epollO(1) 仅活跃 fd10万+ fds高并发网络服务器
io_uring零系统调用 fast path10万+ fds极限性能、网络+磁盘统一异步 IO

Reactor vs Proactor 模式

理解这两个经典 IO 设计模式,有助于在 epoll 和 io_uring 之间做出架构决策。

Reactor(反应堆)

  • 核心:同步等待事件就绪,然后同步执行 IO。
  • 流程:epoll_wait 发现 socket 可读 → 线程池中的工作线程执行 read() → 业务处理 → write()。
  • IO 的"实际搬运"由用户线程完成。
  • 适合:epoll + 线程池模型。Nginx、Redis、Netty 均属于此列。

Proactor(前摄器)

  • 核心:由操作系统异步执行 IO,完成后再通知用户态。
  • 流程:用户提交一个 read 请求给内核 → 内核负责将数据从网卡拷贝到用户 buffer → 完成后通过 CQE 通知用户"IO 已完成,直接使用数据"。
  • IO 的"实际搬运"由内核完成,用户态看到的是"已经完成了的"结果。
  • 适合:io_uring、Windows IOCP、POSIX AIO。

如何选择

  • Reactor 的编程心智模型更直接,调试容易,生态最成熟,适用于绝大多数互联网服务端场景。
  • Proactor 在极限性能、尤其是超高 QPS 且 CPU 核心数充裕时表现更优,因为内核可以被更精细地调度来执行数据搬运。io_uring 使 Linux 首次拥有了真正可用的 Proactor 能力。

实际性能分析与调优

使用 wrk / wrk2 进行压测

# wrk:测试最大吞吐
wrk -t12 -c400 -d30s http://localhost:8080/

# wrk2:固定吞吐,测量延迟分布(更适合发现尾延迟)
wrk -t12 -c400 -d30s -R200000 http://localhost:8080/

关注两个维度:

  1. 吞吐量(Requests/sec):系统每秒能处理多少请求。
  2. 延迟分布(Latency Distribution):p50、p99、p999 分别代表 50%、99%、99.9% 的请求耗时。对于用户体验和 SLA,p99 有时比平均值更重要。一个服务如果 p50 是 1ms 但 p99 是 500ms,说明存在严重的尾延迟问题。

io_uring polling 模式的适用场景

io_uring 支持内核轮询模式(IORING_SETUP_SQPOLL),此时内核线程会持续检查 SQ 是否有新请求。这带来两个影响:

  • 降低延迟:无需用户态进入内核的 syscall 开销,也无需等待中断,整体延迟可以压到几个微秒。
  • CPU 占用上升:轮询线程会占用一个 CPU 核心持续运行(即使无事可做)。

因此,polling 模式最适合延迟敏感型、低并发但超高频、CPU 资源充足的场景,如高频交易、游戏服务器、缓存中间件。对于一般的 Web 服务,中断驱动模式(默认)通常性价比更高。

此外,在分析性能瓶颈时,善用 perf、eBPF(bpftrace)、/proc/<pid>/status 中的 voluntary_ctxt_switches 等工具,能帮你判断是 syscall 开销、上下文切换、还是 lock contention 在拖后腿。


总结

Linux IO 模型经历了从同步阻塞到多路复用、再到真正异步的演进。select 和 poll 因 O(n) 扫描而受限于 C10K 量级;epoll 通过红黑树和就绪链表将事件通知提升到 O(1) 级别,支撑了现代互联网基础设施;io_uring 则进一步通过共享内存环形队列消除了系统调用开销,统一了网络与磁盘 IO,将 Linux 推向了 Proactor 模式的新高度。

对于大多数后端开发者,掌握 epoll 与 Reactor 模式仍是基本功。而当业务需要榨干单机极限性能时,io_uring 是不可忽视的下一代武器。选择什么,取决于你要解决的是 C10K,还是 C1000K。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「os」更多文章

  1. 44. 并发模型对比:Actor、CSP 与数据并行
  2. 42. 垃圾回收算法:标记清除、分代与增量回收
  3. 41. 无锁数据结构与内存序:CAS、ABA 与安全内存回收