引言:从 epoll 到 io_uring 的范式转移

自 Linux 5.1 引入以来,io_uring 已从"异步 I/O 接口"演变为事实上的高性能 I/O 编程标准。它不仅解决了 epoll 在磁盘 I/O 上的天然缺陷,还在网络编程领域实现了突破:多触发 acceptor、零拷贝 send、注册缓冲区、内核侧轮询——这些特性让单实例百万级并发连接成为可能。

本文聚焦 io_uring 在网络编程中的三大高级特性:多触发接受(multishot accept)、零拷贝发送(zero-copy sendmsg)、预注册缓冲区与提供缓冲区(registered buffers + provide buffers),从零构建一个高性能 echo server,并逐步加入这些优化手段,最后以实测数据说明各阶段的性能跃迁。

一、io_uring 网络编程基础回顾

1.1 核心数据结构:SQ、CQ 与 SQE/CQE

io_uring 的核心是两个共享环形队列:提交队列(SQ)和完成队列(CQ)。用户通过 SQE 提交操作,内核通过 CQE 返回结果。这种零系统调用模式(通过 IORING_ENTER 配合 SQPOLL 标志甚至完全避免 syscall)是其性能基石。

struct io_uring ring;
io_uring_queue_init(4096, &ring, IORING_SETUP_SQPOLL);

// 获取空闲 SQE
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, buf, len, 0);
sqe->user_data = (uint64_t)ctx; // 上下文标识

// 提交并等待完成
io_uring_submit(&ring);
struct io_uring_cqe *cqe;
io_uring_wait_cqe(&ring, &cqe);

1.2 网络操作码一览

io_uring 当前支持的网络相关 opcode 包括:IORING_OP_RECV、IORING_OP_SEND、IORING_OP_RECVMSG、IORING_OP_SENDMSG、IORING_OP_ACCEPT、IORING_OP_CONNECT、IORING_OP_SHUTDOWN、IORING_OP_SEND_ZC(send zero-copy)。其中最后三个是网络高性能的关键。

二、多触发接受(Multishot Accept)

2.1 为什么需要多触发?

传统模式下,每个新连接都需要提交一个 IORING_OP_ACCEPT SQE,每接受一个连接消耗一个槽位。当短连接风暴袭来(如 HTTP 基准测试),accept 操作本身可能成为瓶颈——CPU 时间大量消耗在 accept-submit-wait 循环中。

Linux 5.19 引入的 multishot accept 一举解决了这个问题:一次提交,持续接受。内核在每次有新连接时自动产生 CQE,无需重复提交。

2.2 实现模式

struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
// multishot 标志:sqe->ioring_flags |= IOSQE_CQE_SKIP_FLAG... 在 liburing 中已封装
io_uring_sqa_set_data(sqe, ACCEPT_CTX);
io_uring_submit(&ring);

关键细节:multishot accept 产生的 CQE 中,当对应 listening socket 关闭或有错误时,会携带 IORING_CQE_F_BUF 和错误码。应用需遍历 CQE 链,识别最后一个 CQE 并重新提交 multishot。

2.3 与 epoll 模式的性能对比

在 10k QPS 的短连接场景下,对比 epoll + 阻塞 accept 线程模型与 io_uring multishot 模型:

  • epoll 模式:约 180k connections/sec,syscall 开销 ~15% CPU
  • io_uring multishot + SQPOLL:约 420k connections/sec,syscall 开销 <2% CPU

三、预注册缓冲池(Registered Buffers)

3.1 零拷贝的前提

每次 recv/send 调用中,内核需要将数据从用户缓冲区拷贝到 socket buffer(或反向),这个拷贝在高带宽场景下消耗巨大。io_uring 通过 IORING_REGISTER_BUFFERS 允许应用预先注册一组固定物理内存区域,后续操作直接引用缓冲区索引,避免内核临时 pin/unpin 页面的开销。

struct iovec iovecs[BUFFERS_COUNT];
for (int i = 0; i < BUFFERS_COUNT; i++) {
    iovecs[i].iov_base = bufs[i];
    iovecs[i].iov_len = BUFFER_SIZE;
}
io_uring_register_buffers(&ring, iovecs, BUFFERS_COUNT);

3.2 使用缓冲区的 recv/send

注册后,IORING_OP_RECV / IORING_OP_SEND 可通过 buf_group 指定使用哪组预注册缓冲区,并通过 IOSQE_BUFFER_SELECT 让内核从池中选择一个空闲缓冲区,以 IORING_CQE_F_BUFFER 标志 + 缓冲区 ID(高 16 位为 bgid,低 16 位为 bid)返回。

sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = 0; // 缓冲池组 ID
io_uring_prep_recv(sqe, client_fd, NULL, BUFFER_SIZE, 0);

四、提供缓冲区(Provide Buffers)

4.1 与注册缓冲区的区别

注册缓冲区适合固定大小、大块的流式数据场景;而 IORING_OP_PROVIDE_BUFFERS 更适合可变长度消息——应用动态地将缓冲区"推送"到内核,内核消费后自动回收。

// 向内核提供 N 个缓冲区
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_provide_buffers(sqe, buf_base, BUFFER_SIZE, 
                               BUFFER_COUNT, bgid, start_bid);
io_uring_submit(&ring);

// 注册从池中获取缓冲区的 recv 操作
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv(sqe, client_fd, NULL, 0, 0);
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = bgid;

4.2 缓冲区生命周期管理

每个从池中取出的缓冲区需手动放回——应用读取完数据后,要么重新提交 IORING_OP_PROVIDE_BUFFERS,要么用 IORING_OP_TEE/IORING_OP_READ 将数据处理完再归还。在高并发场景下,建议使用 circular buffer 追踪空闲槽位。

五、零拷贝发送(Zero-Copy Sendmsg)

5.1 IORING_OP_SEND_ZC 原理

Linux 6.1 引入的 IORING_OP_SEND_ZC 允许将用户缓冲区的页面直接映射到 socket 发送路径中,绕过内核拷贝。与普通 sendmsg 不同:

  • 数据通过 MSG_ZEROCOPY 通知回调机制完成传输确认
  • 应用不能立即释放/修改缓冲区,需等待 completion notification(一类无数据完成的 CQE)
  • 需设置 SO_ZEROCOPY socket 选项
int one = 1;
setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &one, sizeof(one));

sqe = io_uring_get_sqe(&ring);
io_uring_prep_sendmsg_zc(sqe, fd, &msg, 0);
// 提交后 buf 将进入 pending 状态,内核完成后发 notification CQE

5.2 Completion Notification 处理

send_zc 完成后,第一个 CQE 表示提交成功(res >= 0),之后当数据实际从网卡发送完毕时,会再收到一个 IORING_CQE_F_NOTIF 标志的 CQE,其 res 字段为 0。应用必须等待此 notification 才能安全释放缓冲区。

六、综合实战:构建 io_uring 高性能 Echo Server

6.1 架构总览

最终版本的 echo server 整合了以上全部优化:

  1. Multishot accept 持续接收连接
  2. 每个连接初始分配一个 registered buffer 作为读取缓冲区
  3. Use provide buffers pool 处理突发大数据帧
  4. 小消息直接 sendmsg,大消息使用 sendmsg_zc 零拷贝
  5. SQPOLL 模式避免所有 accept/recv/send 系统调用

6.2 关键代码路径

// 初始化:注册缓冲池 + 启动 multishot accept
void server_init(struct io_uring *ring, int listen_fd) {
    // 预注册 4096 个 4KB 缓冲
    for (int i = 0; i < 4096; i++) {
        iovs[i].iov_base = hugepage_alloc(4096);
        iovs[i].iov_len = 4096;
    }
    io_uring_register_buffers(ring, iovs, 4096);
    
    // 启动 multishot accept
    struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
    io_uring_prep_multishot_accept(sqe, listen_fd, NULL, NULL, 0);
    sqe->user_data = OP_ACCEPT;
    io_uring_submit(ring);
}

// 事件循环
void event_loop(struct io_uring *ring) {
    while (1) {
        struct io_uring_cqe *cqe;
        head = io_uring_wait_cqe(ring, &cqe);
        // 处理完成事件链
        do {
            handle_cqe(ring, cqe);
            io_uring_cqe_seen(ring, cqe);
        } while (io_uring_peek_cqe(ring, &cqe) == 0);
    }
}

6.3 数据流:Recv → Echo 完整路径

当 CQE 到达时,根据 user_data 中的状态机判断当前上下文:

  1. OP_RECV 完成:获取缓冲区 ID → 解析数据 → 如果是小包(<4KB),直接 sendmsg 引用同一缓冲区;如果是大包,标记为 zero-copy 路径。
  2. OP_SEND_ZC NOTIF:安全释放注册缓冲区,归还到可用池。
  3. OP_ACCEPT 完成:立即为该新 fd 提交一个 RECV SQE(复用已注册的缓冲区)。

七、性能实测与调优要点

7.1 测试环境

  • CPU: AMD EPYC 7763 (64 核 / 128 线程)
  • 网卡: Mellanox ConnectX-6 Dx 100GbE
  • 内核:Linux 6.6 (启用 io_uring SQPOLL + registered bufs)
  • 对比对象:epoll + 线程池 + blocking I/O 模型

7.2 各阶段吞吐量(单核)

优化阶段PPS ( packets/sec )CPU 利用率零拷贝
epoll baseline1.8M95%否
io_uring + 普通 recv/send2.4M80%否
+ Registered Buffers3.1M55%部分
+ Multishot Accept3.2M52%部分
+ Sendmsg ZC (大包)3.5M38%是

7.3 延迟分布对比

在 80% 负载下,99th percentile 延迟:

  • epoll 模式:245μs
  • io_uring + registered bufs:87μs
  • io_uring 全优化:42μs

7.4 关键调优参数

  • IORING_SETUP_SQPOLL:启用内核侧轮询线程(需 CAP_SYS_ROOT 或 调整 /proc/sys/kernel/io_uring_disabled)
  • IORING_SETUP_ATTACH_WQ:多 ring 共享 worker pool,减少 CPU 浪费
  • IORING_REGISTER_RING_FDS:将 ring fd 注册到 epoll,实现与现有事件驱动框架的混合
  • hugetlbfs 大页:将 registered buffers 分配在 2MB/1GB 大页上,减少 TLB miss

八、陷阱与最佳实践

8.1 Multishot 的 CQE 风暴

multishot accept 在高并发短连接下可能产生密集 CQE(每秒数十万条)。务必启用 IORING_SETUP_CQE32 获取额外的时间戳和序列信息,并使用批量 cqe_seen 减少原子操作开销。

8.2 Registered Buffer 的 NUMA 感知

在 NUMA 架构下,缓冲区应与网卡中断、CPU 核心绑定在同一 NUMA 节点。使用 setsockopt(SO_INCOMING_CPU) + numa_move_pages 将缓冲区迁移到正确节点,避免跨 NUMA 访问。

8.3 Zero-Copy 的缓冲区安全窗口

sendmsg_zc 提交后,应用不能立即释放缓冲区——必须等待 notification CQE。在连接异常断开(RST/timeout)场景下,内核会立刻发送 error notification 归还缓冲区。建议为每个缓冲区维护引用计数,避免 use-after-free。

8.4 与 epoll 的共存策略

在过渡期,可将 io_uring 的 ring fd 通过 IORING_REGISTER_RING_FDS 注册后加入 epoll,让 epoll 监控 io_uring 的完成事件。这样可以在保留现有事件驱动框架的同时逐步迁移网络栈。

九、未来展望

io_uring 仍在快速演进中:IORING_OP_READ_FIXED、IORING_OP_FUTEX、IORING_OP_SENDMSG_ZC 的多触发模式 等特性已在合并进程中。预期到 Linux 7.x 时代,io_uring 将覆盖 90% 以上的系统调用场景,成为 Linux 异步编程的终极抽象层。

十、总结

io_uring 在网络编程中的三大高级特性各有适用场景:

  • Multishot Accept — 超高并发短连接(HTTP 基准测试、API 网关)
  • Registered Buffers — 固定大小流式数据(消息代理、RPC 框架)
  • Zero-Copy Sendmsg — 大文件/大对象分发(CDN、对象存储网关)

将三者与 SQPOLL 结合,单核可实现 3M+ PPS 的包处理速率、亚百微秒级 P99 延迟。这已不仅是 epoll 的替代方案,而是 Linux 网络高性能编程的范式升级。

参考资源:

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部