现代高性能 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 多路复用机制在连接数增长时,性能急剧恶化。

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

当调用 select() 时,内核需要遍历用户传入的 所有 fd(文件描述符),逐个检查它们是否有数据可读或可写。假设你维护了 10,000 个连接,而某个时刻只有 100 个活跃,内核依然要扫描全部 10,000 个。每一次 select 调用都是一次全量扫描,并且每次调用前都需要重新构建 fd 集合(因为 select 会修改传入的数组)。poll 虽然取消了 1024 的硬限制(selectFD_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_readableon_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_READIORING_OP_WRITEIORING_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 服务,中断驱动模式(默认)通常性价比更高。

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


总结

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

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

继续阅读

探索更多技术文章

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

全部文章 返回首页

「os」更多文章

  1. 进程与线程:从 PCB 到内核调度实体
  2. 虚拟内存与分页机制:从 MMU 到 TLB
  3. 系统性能诊断与调优:strace、perf、bpftrace