io_uring 网络编程深度实战:从 IORING_OP_SEND/RECV 到高并发 TCP 服务的零拷贝数据通路

摘要

epoll 在长达十余年的时间里几乎是 Linux 高并发网络编程的唯一答案。但当单机需要维持百万级连接、追求个位数微秒的尾延迟时,就绪通知(readiness)模型本身的系统调用与内存拷贝开销开始变得不可忽略。io_uring 自 5.1 进入主线后,逐步补齐了完整的网络操作码族(ACCEPT / CONNECT / SEND / RECV / SENDMSG / RECVMSG / SEND_ZC),并将"完成(completion)"语义引入网络 I/O。本文从内核提交队列(SQ)与完成队列(CQ)的网络路径出发,系统梳理基于 io_uring 的 TCP 服务构建方式、缓冲区回收策略、多路复用 recv、零拷贝发送,以及与 epoll 在生产环境中的真实取舍。

一、为什么 readiness 模型在高并发下会"漏气"

epoll 的本质是就绪通知:内核告诉你"某个 fd 现在可以读写",但真正的数据搬运仍要由一次 read/write 系统调用完成。对于一个 C10M 级别的接入层,这意味着:

  • 每个 I/O 事件至少一次用户态↔内核态切换;
  • 内核协议栈与用户缓冲区之间的一次 copy_to_user / copy_from_user;
  • 在 ET(边缘触发)模式下,为了把内核 socket 接收缓冲区"抽干",往往要循环 recv 直到 EAGAIN,引入了额外的唤醒—返回往返。

io_uring 的突破在于把"发起 I/O"和"收到结果"彻底解耦:应用程序一次性向 SQ 提交大量 SEND/RECV 请求,内核在后台完成数据搬运后,把结果(含字节数、flags)写入 CQ。用户态只需轮询 CQ,几乎不需要为每一个 I/O 单独陷入内核。

关键心智模型转变:epoll 回答"现在能读写吗",io_uring 回答"你交给我的那批读写,做完了没有"。前者是就绪驱动,后者是完成驱动。

二、io_uring 网络操作码全景

构建一个完整的 TCP 服务,需要用到以下核心 opcode(定义于 / io_uring.h):

Opcode 作用 典型场景
IORING_OP_ACCEPT 异步接受新连接 监听 socket 的 accept 循环
IORING_OP_CONNECT 异步发起连接 客户端 / 反向代理出站
IORING_OP_SEND 发送数据(带缓冲) 回写响应、推送
IORING_OP_RECV 接收数据(带缓冲) 读取请求体
IORING_OP_SENDMSG / RECVMSG 基于 msghdr 的收发 需要 control message 的高级场景
IORING_OP_SEND_ZC 零拷贝发送 大响应、高带宽回源

从 5.18 起,IORING_OP_SEND / IORING_OP_RECV 已经成熟,配合多 shot 接收(IORING_RECV_MULTISHOT)可以一次性为同一个连接注册"持续收包"语义,避免每收一次都重新提交 SQE。

三、Completion 模型下的回声服务骨架

下面给出一个最小可运行的 io_uring TCP 回声服务骨架(基于 liburing),展示 ACCEPT → RECV → SEND 的完成驱动循环:


#include <liburing.h>
#include <netinet/in.h>
#include <string.h>
#include <unistd.h>

#define MAX_EVENTS 1024
#define BUF_LEN     4096

int main() {
    struct io_uring ring;
    io_uring_queue_init(MAX_EVENTS, &ring, 0);

    int srv = socket(AF_INET, SOCK_STREAM, 0);
    int one = 1;
    setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &one, sizeof(one));
    struct sockaddr_in addr = { .sin_family = AF_INET,
                                .sin_port = htons(8080),
                                .sin_addr.s_addr = INADDR_ANY };
    bind(srv, (void*)&addr, sizeof(addr));
    listen(srv, 128);

    // 1) 提交第一个 ACCEPT
    struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
    io_uring_prep_accept(sqe, srv, NULL, NULL, 0);
    io_uring_submit(&ring);

    char *buf = malloc(BUF_LEN);
    while (1) {
        struct io_uring_cqe *cqe;
        io_uring_wait_cqe(&ring, &cqe);
        void *user = io_uring_cqe_get_data(cqe);

        if (user == NULL) {               // ACCEPT 完成
            int conn = cqe->res;
            // 重新提交 ACCEPT,保持监听不中断
            sqe = io_uring_get_sqe(&ring);
            io_uring_prep_accept(sqe, srv, NULL, NULL, 0);
            io_uring_submit(&ring);
            // 为这个新连接提交 RECV
            sqe = io_uring_get_sqe(&ring);
            io_uring_prep_recv(sqe, conn, buf, BUF_LEN, 0);
            io_uring_cqe_set_data(sqe, (void*)(long)conn);
            io_uring_submit(&ring);
        } else {                          // RECV 完成
            int conn = (long)user;
            int n = cqe->res;
            if (n <= 0) { close(conn); }  // 对端关闭
            else {
                sqe = io_uring_get_sqe(&ring);
                io_uring_prep_send(sqe, conn, buf, n, 0);
                io_uring_cqe_set_data(sqe, (void*)(long)conn);
                // 继续为同一连接提交下一次 RECV
                sqe = io_uring_get_sqe(&ring);
                io_uring_prep_recv(sqe, conn, buf, BUF_LEN, 0);
                io_uring_cqe_set_data(sqe, (void*)(long)conn);
                io_uring_submit(&ring);
            }
        }
        io_uring_cqe_seen(&ring, cqe);
    }
}

注意这里没有任何 epoll_wait、没有任何逐连接 recv 循环:所有 I/O 都以 SQE 的形式提前排队,完成事件以 CQE 的形式批量回收。accept 完成与新连接 recv 完成通过 io_uring_cqe_get_data 携带的用户数据区分。

四、缓冲区回收:Provided Buffers 与 Multishot Recv

上面的骨架为每个连接固定复用一个 buf,在生产环境中会带来严重的缓冲区生命周期管理问题:recv 尚未完成时,不能把这个 buf 交给另一个 SQE;高并发下也无法为每个连接长期占用 4KB。

io_uring 提供了两套机制解决它:

  1. Provided Buffers(IOSQE_BUFFER_SELECT):应用向内核注册一组缓冲区(buffer group),提交 RECV 时不再指定具体 buf,而是标记 IOSQE_BUFFER_SELECT 并带上 group id。内核收到数据时自动从 group 中挑一块填充,完成时通过 cqe->flags 的高位回传被选中的 buffer id,应用用完后再把它归还 group。这把"缓冲区池"下沉到了内核,避免了"提交 recv 时必须有空闲 buf"的时序耦合。
  1. Multishot Recv(IORING_RECV_MULTISHOT):为同一个连接提交一次 multishot RECV,内核会为到达的每一个数据包反复产出 CQE,直到连接关闭或出现错误。它彻底消除了"每收一包重新提交一次 SQE"的开销,是 io_uring 网络编程相对 epoll 的最大工程收益点之一。

// 注册缓冲区组(简化示意)
struct io_uring_buf_ring *br;
io_uring_setup_buf_ring(&ring, 1024, 4096, BGID, 0, &br);
// 提交 multishot recv
sqe = io_uring_get_sqe(&ring);
io_uring_prep_recv_multishot(sqe, conn, 0);
sqe->flags |= IOSQE_BUFFER_SELECT;
sqe->buf_group = BGID;
io_uring_submit(&ring);

五、零拷贝发送:把最后一字节拷贝也省掉

即使采用完成驱动模型,传统的 IORING_OP_SEND 仍然会在内核把用户缓冲区拷贝进 sk_buff。对于大响应(如对象存储回源、视频切片),这最后一公里的内存带宽开销可观。

  • MSG_ZEROCOPY(sendmsg 零拷贝):数据直接从用户缓冲区 DMA 到网卡,内核只拷贝元数据。io_uring 通过 IORING_OP_SEND_ZC 将其异步化,完成时通过单独的 notification CQE 告知"该缓冲区可以安全复用"。
  • io_uring zcrx(零拷贝接收,6.x 起实验性):更进一步,让 NIC 的 DMA 缓冲区直接暴露在用户态(基于 struct page 与 io_uring 的 memory registration),recv 路径彻底零拷贝。当前主要面向有硬件支持的 NIC 与特定工作负载。

六、性能对比与适用边界

维度 epoll(ET) io_uring(completion)
每事件系统调用 至少 1 次 批量提交,逼近 0(配合 SQ 轮询)
数据拷贝 必须 copy_to/from_user 可选零拷贝(ZC / zcrx)
连接规模 受 fd 表与就绪列表开销约束 受 CQ/SQ 深度与内存约束
编程模型 就绪 + 状态机 完成 + 缓冲区池
内核版本门槛 全版本 网络 opcode 需 5.18+,ZC 需 6.x

经验法则:

  • 短连接、高建连速率(如接入网关、4/7 层负载均衡):io_uring 的 ACCEPT 批量提交与完成驱动能显著降低建连延迟抖动。
  • 大吞吐回源 / 对象存储:SEND_ZC + 固定缓冲区收益最大。
  • 已经高度优化、CPU 不是瓶颈的纯转发链路:迁移成本可能高于收益,需做 A/B 压测再决定。

七、生产落地的工程注意事项

  1. 连接与缓冲区的绑定关系:multishot recv 下,必须等 notification CQE 确认缓冲区不再被内核引用后,才能归还 buffer group,否则会出现"二次写入"。
  2. 背压与缓冲池耗尽:当 buffer group 被全部借出且没有 CQE 归还时,新到的包会被内核丢弃(或排队,取决于配置)。需要监控 buffer group 水位并实现应用层背压。
  3. 顺序与公平性:io_uring 不保证同一连接上多个 SQE 的完成顺序与提交顺序一致,必要时需显式排序或限制每个连接的在途请求数。
  4. io_uring_enter 的并发:多线程提交建议每个线程独立 ring,或对共享 ring 使用 IORING_SETUP_SQPOLL 并由单线程驱动提交,避免 SQ 锁竞争。
  5. 内核版本与特性探测:在生产镜像中明确锁定的内核版本,并在启动时探测 IORING_OP_SEND_ZC、RECV_MULTISHOT 等 opcode 的可用性,缺失时优雅降级到普通 SEND/RECV。

结语

io_uring 把网络 I/O 从"就绪通知"推进到了"完成驱动 + 零拷贝"的时代。对于追求极致连接规模与尾延迟的接入层、网关和存储服务,它不是 epoll 的简单替代品,而是一套需要重新设计缓冲区与并发模型的新范式。先复用本文的回声骨架跑通 ACCEPT→RECV→SEND 的完成循环,再逐步引入 provided buffers、multishot recv 与 SEND_ZC,是落地这套范式的最低风险路径。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部