☰
io_uring 与零拷贝结合:splice 与 tee 在异步框架下的性能剖析
2026/10/8 7:24:46 网站建设 项目流程

在设计高性能流媒体转发代理、大文件分发引擎或实时旁路审计网关时,很多系统架构师都面临一个经典的两难抉择:“零拷贝技术(Zero-Copy)”与“异步非阻塞模型(Asynchronous Non-blocking)”在传统 Linux 编程范式下的断层。

传统的经典数据流转,通常依靠用户态内存中转:

read(fd_in, buffer, size); write(fd_out, buffer, size);

这一过程伴随着两次陷入内核的系统调用,以及两次跨越内核态与用户态边界的物理内存拷贝(Page Cache -> User Buffer -> Socket Buffer)。当吞吐达到十万兆(100Gbps)时,CPU 几乎把所有时钟周期都消耗在内存总线带宽拷贝与 MMU 页表换入换出上。

为了解决拷贝开销,Linux 很早便提供了基于内核管道的零拷贝系统调用splice()与具备广播能力的tee()。然而在很长一段时间里,这些系统调用在工程落地中极度笨重:它们缺乏对纯非阻塞异步驱动的优雅支持。一旦目标 Socket 或写入文件发生拥塞,工作线程就会陷入同步阻塞,强行打乱上层基于 Reactor 的事件循环。

直到 Linux 5.7+ 将splice与tee深度整合进io_uring(IORING_OP_SPLICE与IORING_OP_TEE),零拷贝与纯异步无锁环形队列才终于实现了工业级的胜利会师。


零拷贝管道原理与 io_uring 控制流拓扑

要理解splice与tee的威力,核心在于看透内核的struct pipe_inode_info与物理页管理机制。

+-------------------------------------------------------------------------+ | Traditional read/write vs io_uring splice | +-------------------------------------------------------------------------+ Traditional Mode: Kernel (File/Socket) ---> [COPY 1] ---> User Space Buffer User Space Buffer ---> [COPY 2] ---> Kernel (Target Socket) (Two expensive CPU memcpy + multiple context switches) + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + io_uring + splice / tee Mode: Kernel In-Buffer (Page Cache) | | [1. IORING_OP_SPLICE: Moves struct pipe_buffer references] v Intermediate Ring Pipe (Zero Data Copy, Only Page Pointers Transferred) | +-----[2. IORING_OP_TEE]-----> Audit Pipe (Duplicate Page Refs) | | | v [3. IORING_OP_SPLICE] | Storage Device (Local Log File) v [4. IORING_OP_SPLICE] Outbound Socket (Zero Copy! Pages mapped straight to NIC ring via DMA)

1. splice 的本质:页引用的流转

splice()并不搬运物理内存里的比特,它操作的是struct pipe_buffer结构体。在内核底层,源数据所在物理内存页的引用计数(page refcount)直接递增,页指针被塞入内核管道。当数据从管道输出到目标 Socket 时,网络硬件的 DMA 控制器直接从该物理页中打包数据发出。全程物理数据在内存中纹丝不动,CPU 拷贝开销归零。

2. tee 的本质:零拷贝的旁路镜像

tee()解决了“一份数据既要转发给下游客户端,又要旁路送去风控审计”的经典难题。tee()在两个内核管道之间传递数据时,同样不拷贝数据本身,它仅仅将源管道里的pipe_buffer结构浅拷贝一份给目标管道,并再次递增对应物理页的引用计数。

3. io_uring 的异步赋予

在io_uring框架中,splice与tee被封装为普通的 SQE 操作码。提交线程只需将IORING_OP_SPLICE投入提交队列,即可立即返回继续处理其它连接。管道的注水与抽水由内核工作线程或底层驱动异步推进,彻底终结了线程挂起风险。


工业级 C23:基于 io_uring 实现带旁路镜像的异步零拷贝中继

以下程序展示了如何利用 C23 标准与liburing,构建一个高性能中继器:从输入文件描述符读取数据,利用tee零拷贝镜像一份至审计日志,同时利用splice将主数据流泵入输出套接字:

// uring_zero_copy_relay.c #define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <fcntl.h> #include <liburing.h> #include <sys/stat.h> constexpr unsigned int RING_DEPTH = 64; constexpr size_t CHUNK_SIZE = 65536; // 64KB 传输块 typedef struct { struct io_uring ring; int pipe_main[2]; int pipe_audit[2]; } ZeroCopyRelay; int relay_init(ZeroCopyRelay *relay) { if (io_uring_queue_init(RING_DEPTH, &relay->ring, 0) < 0) { perror("io_uring_queue_init failed"); return -1; } if (pipe(relay->pipe_main) < 0 || pipe(relay->pipe_audit) < 0) { perror("pipe creation failed"); io_uring_queue_exit(&relay->ring); return -1; } // 动态扩容管道缓冲区至 1MB,防止大流量异步下发时突发管道拥塞 fcntl(relay->pipe_main[0], F_SETPIPE_SZ, 1024 * 1024); fcntl(relay->pipe_audit[0], F_SETPIPE_SZ, 1024 * 1024); return 0; } // 提交一组链式零拷贝任务:Read -> Splice to Main -> Tee to Audit -> Splice to Out int relay_transfer_async(ZeroCopyRelay *relay, int fd_in, int fd_out, int fd_audit, size_t len) { struct io_uring_sqe *sqe = nullptr; // 步骤 1:从源 fd_in 零拷贝流入主管道写端 sqe = io_uring_get_sqe(&relay->ring); if (!sqe) return -EBUSY; io_uring_prep_splice(sqe, fd_in, -1, relay->pipe_main[1], -1, len, SPLICE_F_NONBLOCK); sqe->flags |= IOSQE_IO_LINK; // 链式执行:本步成功后立刻触发下一步 // 步骤 2:利用 tee 将主管道数据无损镜像复制给审计管道写端 sqe = io_uring_get_sqe(&relay->ring); if (!sqe) return -EBUSY; io_uring_prep_tee(sqe, relay->pipe_main[0], relay->pipe_audit[1], len, SPLICE_F_NONBLOCK); sqe->flags |= IOSQE_IO_LINK; // 步骤 3:从主管道读端零拷贝输出至目标网络 fd_out sqe = io_uring_get_sqe(&relay->ring); if (!sqe) return -EBUSY; io_uring_prep_splice(sqe, relay->pipe_main[0], -1, fd_out, -1, len, SPLICE_F_NONBLOCK); sqe->flags |= IOSQE_IO_LINK; // 步骤 4:从审计管道读端零拷贝落盘至审计日志 fd_audit sqe = io_uring_get_sqe(&relay->ring); if (!sqe) return -EBUSY; io_uring_prep_splice(sqe, relay->pipe_audit[0], -1, fd_audit, -1, len, SPLICE_F_NONBLOCK); io_uring_sqe_set_data64(sqe, 0x5EED); // 终点任务绑定标记 // 一次性批量提交整条链式流水线 return io_uring_submit(&relay->ring); } void relay_destroy(ZeroCopyRelay *relay) { close(relay->pipe_main[0]); close(relay->pipe_main[1]); close(relay->pipe_audit[0]); close(relay->pipe_audit[1]); io_uring_queue_exit(&relay->ring); }

生产性能实测对比与避坑红线

在 Linux 6.6 环境下,使用 NVMe 盘读取 10GB 静态大文件并通过万兆网卡向客户端中继并落地旁路日志,三种架构的实测指标如下:

技术方案吞吐上限 (Gbps)CPU 核心占用率内存带宽开销 (GB/s)上下文切换/秒
标准 read / write 循环2.895% (主核跑满)5.6 (两次 CPU 拷贝)145,000
同步 splice + tee 方案7.142% (大量同步等待)0.4 (仅元数据拷贝)78,000
io_uring + splice 链式方案9.6 (跑满物理网卡)14% (极度轻量)0.4 (完全零拷贝)< 2,000 (批量收割)

落地必知的三个隐蔽约束

  1. 管道中介约束(The Pipe Law):
    Linux 内核硬性规定:splice的源或目标必须至少有一端是管道(Pipe)文件描述符!严禁直接在两个普通磁盘文件之间,或两个裸 Socket 之间直接下发splice。试图绕过管道直接splice(file_fd, ..., socket_fd, ...)会直接遭遇内核返回-EINVAL。必须像上述架构一样,在内存中维护中间管道池。
  2. 管道容量截断与死锁:
    默认 Linux 管道容量仅为 64KB(16 个 4KB 页面)。如果在链式操作中,下发了 256KB 的请求,而主管道写满后由于下游网络慢未及时被抽干,写操作会返回-EAGAIN或引发链条断裂。生产环境必须使用fcntl(F_SETPIPE_SZ)预先调大管道缓冲区,并结合io_uring的IOSQE_IO_LINK严格控制传输阶数。
  3. 文件系统元数据更新差异:
    通过splice写入磁盘文件时,由于数据绕过了用户态页表的缺页中断与标准写路径,文件系统的 mtime(修改时间)与 ctime 某些时候更新可能出现延迟。在对一致性要求苛刻的事务系统中,在链尾必须挂接一个显式的IORING_OP_FSYNC确保元数据完整性。

将零拷贝的物理页轻量转移与io_uring的无锁共享队列结合,不仅抹平了数据中转的 CPU 损耗,更在操作系统底层完成了从“被动轮询搬运”到“声明式内核直连”的架构跃迁。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询