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 提供了两套机制解决它:
- Provided Buffers(
IOSQE_BUFFER_SELECT):应用向内核注册一组缓冲区(buffer group),提交 RECV 时不再指定具体 buf,而是标记IOSQE_BUFFER_SELECT并带上 group id。内核收到数据时自动从 group 中挑一块填充,完成时通过cqe->flags的高位回传被选中的 buffer id,应用用完后再把它归还 group。这把"缓冲区池"下沉到了内核,避免了"提交 recv 时必须有空闲 buf"的时序耦合。
- 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 压测再决定。
七、生产落地的工程注意事项
- 连接与缓冲区的绑定关系:multishot recv 下,必须等 notification CQE 确认缓冲区不再被内核引用后,才能归还 buffer group,否则会出现"二次写入"。
- 背压与缓冲池耗尽:当 buffer group 被全部借出且没有 CQE 归还时,新到的包会被内核丢弃(或排队,取决于配置)。需要监控 buffer group 水位并实现应用层背压。
- 顺序与公平性:io_uring 不保证同一连接上多个 SQE 的完成顺序与提交顺序一致,必要时需显式排序或限制每个连接的在途请求数。
io_uring_enter的并发:多线程提交建议每个线程独立 ring,或对共享 ring 使用IORING_SETUP_SQPOLL并由单线程驱动提交,避免 SQ 锁竞争。- 内核版本与特性探测:在生产镜像中明确锁定的内核版本,并在启动时探测
IORING_OP_SEND_ZC、RECV_MULTISHOT等 opcode 的可用性,缺失时优雅降级到普通 SEND/RECV。
结语
io_uring 把网络 I/O 从"就绪通知"推进到了"完成驱动 + 零拷贝"的时代。对于追求极致连接规模与尾延迟的接入层、网关和存储服务,它不是 epoll 的简单替代品,而是一套需要重新设计缓冲区与并发模型的新范式。先复用本文的回声骨架跑通 ACCEPT→RECV→SEND 的完成循环,再逐步引入 provided buffers、multishot recv 与 SEND_ZC,是落地这套范式的最低风险路径。

发表评论 取消回复