3. epoll 与 io_uring:事件驱动模型对比,io_uring 在 QUIC 中的应用

说到 QUIC 的并发瓶颈,很多人第一反应是「CPU 不够快」或者「内存不够大」。但我在几个高并发项目里踩过坑之后,发现真正的瓶颈往往出在 系统调用 上。你想想看,每次收发数据都要切到内核态,这个开销在百万连接场景下会被放大到可怕的程度。

这一章我们就来聊聊两个事件驱动模型——epoll 和 io_uring。我会结合 QUIC 的实际场景,告诉你为什么 io_uring 能成为突破并发瓶颈的关键。

3.1 epoll 的局限:你熟悉的可能正在拖累你

epoll 是 Linux 下高性能网络编程的标配,这没错。但我在优化一个 QUIC 网关时发现,当连接数超过 50 万,epoll 的瓶颈就开始显现了。

核心问题: epoll 本质上还是「事件通知 + 非阻塞 I/O」模型,每次读写操作都需要至少一次系统调用。

具体来说,epoll 的工作流程是这样的:

  1. 调用 epoll_wait 获取就绪事件(系统调用)
  2. 遍历就绪列表,对每个 fd 发起 read 或 write(又是系统调用)
  3. 如果数据没读完,下次 epoll_wait 继续(循环)

这个模式在 QUIC 场景下有个致命问题:QUIC 的每个连接可能有多个 stream 并发,一个数据包可能包含多个 stream 的数据。用 epoll 处理时,你往往需要多次 read 才能把数据收全。每次 read 都是一次系统调用,开销就这么上去了。

指标 epoll io_uring
每次 I/O 操作的系统调用次数 至少 1 次(read/write) 0 次(提交后异步完成)
批量操作支持 不支持 支持批量提交和收割
内存拷贝次数 每次 read 都要拷贝 共享内存区域,零拷贝
适用场景 低并发、低频 I/O 高并发、高频 I/O
注意: 不是说 epoll 不能用。如果你的 QUIC 服务连接数在 10 万以下,epoll 完全够用。但一旦超过这个量级,系统调用开销会呈线性增长,这时候 io_uring 的优势就体现出来了。

3.2 io_uring 的设计哲学:一次提交,批量收割

io_uring 是 Linux 5.1 引入的异步 I/O 框架。它的核心思想很简单:把系统调用从「每次操作」变成「批量提交」。

怎么做到的呢?它用了两个共享内存的环形队列:

  • SQ(Submission Queue):用户态往这里提交 I/O 请求
  • CQ(Completion Queue):内核态完成请求后,把结果放这里

用户程序只需要一次 io_uring_enter 系统调用,就能提交一批请求。内核处理完后,用户程序从 CQ 里收割结果,整个过程不需要额外的系统调用。

我个人习惯: 在 QUIC 服务中,我会把多个连接的数据包打包成一个 batch,一次性提交给 io_uring。这样系统调用次数能减少 90% 以上。

来看一个简单的代码对比:

// epoll 方式:每个连接单独 read
struct epoll_event events[MAX_EVENTS];
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < nfds; i++) {
    int fd = events[i].data.fd;
    read(fd, buf, sizeof(buf));  // 每次 read 都是系统调用
}

// io_uring 方式:批量提交
struct io_uring_sqe *sqe;
for (int i = 0; i < batch_size; i++) {
    sqe = io_uring_get_sqe(ring);
    io_uring_prep_read(sqe, fd_array[i], buf_array[i], len, 0);
}
io_uring_submit(ring);  // 一次系统调用提交所有请求
// 稍后从 CQ 中收割结果

你看,io_uring 把多次系统调用压缩成一次。在 QUIC 这种需要频繁收发小数据包的场景下,这个优化非常关键。

3.3 io_uring 在 QUIC 中的实战应用

我在做一个 QUIC 代理服务时,把 epoll 替换成了 io_uring。效果很明显:CPU 使用率下降了 30%,吞吐量提升了 40%。

具体是怎么做的呢?我总结了三个关键点:

3.3.1 批量处理 QUIC 数据包

QUIC 的每个连接会频繁发送短数据包(几十到几百字节)。如果用 epoll,每个数据包都要触发一次 read 系统调用。而 io_uring 可以一次提交几百个 read 请求,内核批量处理。

// 批量提交 QUIC 数据包读取
struct io_uring_sqe *sqe;
int pending = 0;

while (1) {
    // 从连接池中取出待读取的连接
    while (pending < MAX_BATCH && has_pending_conns()) {
        quic_conn_t *conn = get_next_pending_conn();
        sqe = io_uring_get_sqe(ring);
        io_uring_prep_read(sqe, conn->fd, conn->buf, conn->buf_len, 0);
        sqe->user_data = (unsigned long)conn;
        pending++;
    }

    if (pending > 0) {
        io_uring_submit(ring);  // 一次提交
        // 收割结果
        struct io_uring_cqe *cqe;
        for (int i = 0; i < pending; i++) {
            io_uring_wait_cqe(ring, &cqe);
            quic_conn_t *conn = (quic_conn_t *)cqe->user_data;
            process_quic_packet(conn, conn->buf, cqe->res);
            io_uring_cqe_seen(ring, cqe);
        }
        pending = 0;
    }
}
关键点: 这里的 user_data 字段非常有用。你可以把连接上下文指针传进去,收割时直接拿到对应的连接对象,省去了查找的开销。

3.3.2 零拷贝减少内存开销

QUIC 的数据包需要频繁加解密。如果用 epoll,每次 read 都要把数据从内核态拷贝到用户态,加解密后再拷贝回去发送。io_uring 支持 固定缓冲区,可以提前注册一块共享内存,内核直接往里面写数据,省去了一次拷贝。

// 注册固定缓冲区
struct iovec iov;
iov.iov_base = shared_buf;
iov.iov_len = BUF_SIZE;
io_uring_register_buffers(ring, &iov, 1);

// 使用固定缓冲区读取
sqe = io_uring_get_sqe(ring);
io_uring_prep_read_fixed(sqe, fd, shared_buf, len, 0, 0);
// 数据直接写入 shared_buf,无需拷贝
避坑指南: 我曾经在注册固定缓冲区时没注意对齐要求,导致内核报错。记住:io_uring_register_buffers 要求缓冲区地址和大小都按页对齐(通常是 4096 字节)。

3.3.3 减少连接建立阶段的系统调用

QUIC 的连接建立(握手)涉及多次数据交换。用 epoll 时,每次握手消息都要单独 read/write。而 io_uring 可以把整个握手流程的 I/O 操作一次性提交,内核按顺序执行。

举个例子,QUIC 的 Initial 包和 Handshake 包通常是连续发送的。用 io_uring,你可以这样:

// 一次性提交握手阶段的读写操作
struct io_uring_sqe *sqe;

// 读取 Initial 包
sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, conn->fd, initial_buf, INITIAL_LEN, 0);
sqe->user_data = OP_READ_INITIAL;

// 发送 Handshake 包
sqe = io_uring_get_sqe(ring);
io_uring_prep_write(sqe, conn->fd, handshake_buf, HANDSHAKE_LEN, 0);
sqe->user_data = OP_WRITE_HANDSHAKE;

// 提交
io_uring_submit(ring);

这样,两次 I/O 操作只需要一次系统调用。在大量连接同时建立的场景下,这个优化能显著降低 CPU 开销。

3.4 一张图看懂 epoll vs io_uring

下面这张图展示了两种模型的核心差异:

epoll vs io_uring 对比图 epoll 模型 用户态应用 epoll_wait 内核事件通知 返回就绪列表 遍历 fd,逐个 read() read() 系统调用 read() 系统调用 read() 系统调用 处理数据 io_uring 模型 用户态应用 SQ:批量提交 I/O 请求 一次 io_uring_enter 内核批量处理 CQ:批量收割结果 处理数据 核心差异: epoll:每次 I/O 操作都需要一次系统调用,高并发下开销巨大 io_uring:批量提交 + 批量收割,一次系统调用完成多次 I/O

3.5 什么时候该用 io_uring?

说了这么多 io_uring 的好处,但也不是所有场景都适合。我建议你根据这几个条件来判断:

  • 连接数 > 10 万:系统调用开销开始成为瓶颈
  • 数据包小且频繁:QUIC 的短数据包场景非常适合
  • CPU 是瓶颈:io_uring 能显著降低 CPU 使用率
  • 内核版本 >= 5.1:这是最低要求,建议 5.10+
注意: io_uring 的编程模型比 epoll 复杂。你需要管理 SQ 和 CQ 队列,处理提交和收割的时序。如果项目周期紧、团队经验不足,建议先用 epoll 快速上线,后续再优化。