“用 zero copy,性能直接翻倍!”——每个系统程序员都听过这种说法。Nginx 用 sendfile,Kafka 靠 zero-copy 实现百万级吞吐,这些故事被反复传播,以至于 zero copy 在很多人心中等于”免费的性能提升”。
但我要告诉你的是:在相当多的真实场景下,zero copy
反而比老老实实 read + write
更慢。
先把结论摆出来:
- Zero copy 不是消除了拷贝成本,它是把成本从 CPU 转移到了别处——页面钉住、DMA 映射、完成通知、TLB 刷新,这些隐藏成本在小数据场景下远超
memcpy本身。- 当数据量小于 64 KB 时,传统的
read+write几乎总是更快——实测数据显示,在 64 字节场景下 io_uring SEND_ZC 比 read+write 慢 21 倍(5 MB/s vs 110 MB/s)。sendfile是最被误解的系统调用——它的”零拷贝”在很多情况下是有条件的,取决于网卡是否支持 scatter-gather DMA。不支持时,内核会静默回退到 copy。- io_uring 的
IORING_OP_SEND_ZC引入了两阶段完成语义,小数据下”缓冲区可复用时间”比传统方法高两个数量级(120 μs vs 0.55 μs)。但要注意:这衡量的是发送方完成语义,不是 RPC 的端到端尾延迟。
如果你只想记住一句话:Zero copy 是一种权衡(tradeoff),不是优化(optimization)。
1. 传统 I/O 路径:到底拷贝了几次?
在讨论 zero copy 之前,我们需要精确理解传统路径的成本。以”从磁盘读文件并发送到 socket”这个经典场景为例:
1.1 四次拷贝路径
完整路径:
| 步骤 | 方向 | 执行者 | 说明 |
|---|---|---|---|
| ① | 磁盘 → 页面缓存 | DMA | 磁盘控制器搬数据到内核页面缓存,CPU 不参与 |
| ② | 页面缓存 → 用户缓冲区 | CPU | read()
系统调用,copy_to_user(),这是一次真金白银的
memcpy |
| ③ | 用户缓冲区 → Socket 缓冲区 | CPU | write()
系统调用,copy_from_user(),又一次 memcpy |
| ④ | Socket 缓冲区 → 网卡 | DMA | 协议栈构造好数据包后,网卡 DMA 取走数据 |
两次 CPU 拷贝(②和③)加上两次 DMA
拷贝(①和④),共四次数据搬运,外加两次用户态/内核态上下文切换(read
和 write 各一次)。
对于大文件传输,CPU 把大量时间花在毫无意义的内存搬运上——数据从内核搬到用户空间,应用程序看都不看一眼,又搬回内核。这就是 zero copy 要解决的问题。
1.2 但”拷贝”真的那么贵吗?
这是一个关键问题,也是本文的核心论点之一。
现代 CPU 的 memcpy
实现已经被优化到令人发指的程度:
- x86-64:glibc 的
__memcpy_avx_unaligned_erms使用 Enhanced REP MOVSB (ERMS) 或 AVX-512 指令,在对齐的内存上可以跑满内存带宽。 - 缓存命中时的速度:当源和目标都在 L1 cache(~32KB)中时,4KB 数据的 memcpy 通常在 数十到一百纳秒 量级。在 L2 cache(~256KB-1MB)中约 数百纳秒 量级(具体取决于 CPU 微架构和 glibc 版本)。
- 对比上下文切换:一次系统调用的开销通常在 数百纳秒到微秒 量级(取决于内核版本和 Spectre 缓解措施)。
换句话说,对于小数据(< 几十 KB),两次 memcpy 的成本可能还不到一次额外系统调用的开销。记住这个事实,后面我们会反复用到。
2. Linux 的三代 Zero-Copy 机制
2.1 sendfile(2):最广为人知的”零拷贝”
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);sendfile 把 read +
write
合并成一个系统调用,数据在内核空间内部从页面缓存直接传输到
socket 缓冲区,不经过用户空间:
看起来完美——从四次拷贝减少到了……等等,中间那步到底是不是零拷贝?
这取决于你的网卡。
- 支持 scatter-gather DMA 的网卡:内核只需要把页面缓存中数据页的描述符(物理地址 + 长度)写入 socket 缓冲区,网卡 DMA 引擎直接从页面缓存取数据。这是真正的零 CPU 拷贝——只有两次 DMA。
- 不支持 scatter-gather
的网卡:内核必须把页面缓存的数据 CPU 拷贝到
socket 缓冲区的连续内存中,然后网卡才能 DMA
取走。这种情况下,
sendfile只省了一次 CPU 拷贝(从用户空间那次),并没有实现完全的零拷贝。
你可以用 ethtool -k eth0 | grep scatter
检查网卡是否支持
scatter-gather,但现代服务器网卡基本都支持。
问题在于——虚拟化环境(virtio-net、容器 overlay
网络)的行为就不一定了。
sendfile 的内核实现
sendfile 在现代 Linux 内核中实际上是通过
splice 管道机制实现的。内核路径大致是:
// fs/read_write.c (简化)
SYSCALL_DEFINE4(sendfile64, ...)
{
// ...
do_splice_direct(in_file, &pos, out_file, &out_pos, count, fl);
// ...
}也就是说,sendfile 本质上就是
splice 的特化版本。理解这一点很重要——它意味着
sendfile 和 splice
共享同样的底层机制和限制。
sendfile 的局限
- 只能从文件到 socket:
in_fd必须支持mmap(通常是普通文件),out_fd必须是 socket。不能反向,不能 socket 到 socket。 - 不能修改数据:数据直接从页面缓存到网卡,你完全没有机会在中间加密、压缩或修改内容。如果你需要在传输前处理数据(比如
TLS 加密),
sendfile帮不了你。 - 大文件时可能阻塞:虽然数据传输是零拷贝的,但如果文件不在页面缓存中,
sendfile会触发同步磁盘读取,阻塞调用线程。
2.2 splice(2) / tee(2) / vmsplice(2):管道为核心的通用零拷贝
splice 系列是 Linux 2.6.17
引入的更通用的零拷贝框架:
ssize_t splice(int fd_in, off_t *off_in, int fd_out, off_t *off_out,
size_t len, unsigned int flags);
ssize_t tee(int fd_in, int fd_out, size_t len, unsigned int flags);
ssize_t vmsplice(int fd, const struct iovec *iov, unsigned long nr_segs,
unsigned int flags);核心思想:一切数据传输都通过内核管道(pipe)的引用计数页面来实现。
管道缓冲区 (struct pipe_buffer)
不存储实际数据,而是持有对数据页面的引用。splice
操作本质上是在移动页面引用,而不是复制数据。
// include/linux/pipe_fs_i.h (简化)
struct pipe_buffer {
struct page *page; // 指向数据页面
unsigned int offset; // 页内偏移
unsigned int len; // 数据长度
const struct pipe_buf_operations *ops;
unsigned int flags;
};splice 的”伪零拷贝”问题
这是一个重要的细节:splice
声称是”零拷贝”,但在某些路径下:
- 页面必须是完整的:如果数据不是页面对齐的,或者长度不是页面大小的整数倍,内核可能需要分配新页面并拷贝数据来满足对齐要求。
- 管道容量有限:默认 16 个页面(64KB)。如果一次 splice 的数据量大于管道容量,需要多次循环,每次都有系统调用开销。
- 某些文件系统不支持 splice:当文件系统的
file_operations中没有实现splice_read/splice_write时,内核会回退到通过中间缓冲区的拷贝路径。 vmsplice+splice的陷阱:vmsplice把用户空间页面”零拷贝”地推入管道,但这些页面在管道消费之前不能被修改或释放。管道消费的时机是不确定的。如果你在vmsplice之后立即复用缓冲区,你会得到数据损坏——而且不会有任何错误提示。
2.3 io_uring 零拷贝:新一代方案
io_uring 在 Linux 6.0 引入了
IORING_OP_SEND_ZC(零拷贝发送),在 6.15 引入了
Zero-Copy Rx(零拷贝接收)。
IORING_OP_SEND_ZC:两阶段完成
传统的 send()
系统调用返回后,用户可以立即复用发送缓冲区——因为数据已经被拷贝到了
socket 缓冲区。但零拷贝发送不拷贝数据,网卡直接从用户缓冲区
DMA 取数据,这意味着:在 DMA
传输完成之前,用户不能修改缓冲区。
io_uring 通过两阶段完成通知解决这个问题:
// 准备零拷贝发送
struct io_uring_sqe *sqe = io_uring_get_sqe(&ring);
io_uring_prep_send_zc(sqe, sockfd, buf, len, 0, 0);
sqe->user_data = MY_REQUEST_ID;
io_uring_submit(&ring);
// 第一阶段 CQE:请求已提交(但 buffer 还不能复用!)
// cqe->flags & IORING_CQE_F_MORE 为 true 表示还有后续通知
// 第二阶段 CQE(IORING_CQE_F_NOTIF):DMA 完成,buffer 安全复用这个两阶段语义带来了严重的编程复杂度:
- 你必须追踪每个缓冲区的生命周期
- 在高并发场景下,大量未完成的缓冲区会占用内存
- 两个 CQE 意味着完成队列的压力翻倍
- 如果你的发送频率很高,等待第二阶段通知会成为瓶颈
// 处理两阶段通知的典型代码
void handle_cqe(struct io_uring_cqe *cqe) {
if (cqe->flags & IORING_CQE_F_NOTIF) {
// 第二阶段:释放 buffer
struct send_ctx *ctx = (void *)(uintptr_t)cqe->user_data;
free_buffer(ctx->buf);
free(ctx);
return;
}
if (cqe->res < 0) {
// 发送失败,buffer 已经安全
handle_error(cqe);
return;
}
// 第一阶段:发送成功,但 buffer 还不能动
// 如果 !(cqe->flags & IORING_CQE_F_MORE),则没有第二阶段
// (比如内核决定回退到 copy 模式)
}MSG_ZEROCOPY:socket 层的零拷贝
io_uring 的 SEND_ZC 底层依赖的是
MSG_ZEROCOPY 机制(Linux 4.14+)。即使不用
io_uring,你也可以通过普通的 send() 使用:
// 启用 MSG_ZEROCOPY
int val = 1;
setsockopt(fd, SOL_SOCKET, SO_ZEROCOPY, &val, sizeof(val));
// 发送时带 MSG_ZEROCOPY 标志
send(fd, buf, len, MSG_ZEROCOPY);
// 通过 errqueue 接收完成通知
struct msghdr msg = {};
struct sock_extended_err *serr;
recvmsg(fd, &msg, MSG_ERRQUEUE);完成通知通过 socket error queue 传递——这本身就是一个额外的系统调用和同步点。
Zero-Copy Rx(Linux 6.15)
接收方向的零拷贝更加激进。它要求:
- 网卡支持 header/data split:TCP 头由协议栈正常处理,payload 直接 DMA 到用户预分配的内存
- Flow steering / RSS 配置正确:确保流量命中配置了零拷贝的硬件队列
- 用户必须预注册接收缓冲区:通过 io_uring 的 provided buffer 机制
这个方案在 400 Gbps 量级的网络场景下效果显著——但它的适用范围极其有限,大多数应用根本用不到。
详细的 io_uring 零拷贝接收机制,参见本站 io_uring 系列第一篇 中的 Zero-Copy Rx 章节。
3. Zero Copy 的隐藏成本:核心章节
现在我们来到本文最重要的部分。Zero copy 看起来省掉了 CPU 的 memcpy 工作,但它引入了一系列经常被忽略的成本。让我逐一拆解。
3.1 页面钉住(Page Pinning)
零拷贝发送需要 DMA 引擎直接访问用户空间的内存页面。为了防止这些页面在 DMA 传输期间被内核换出(swap out)或重新映射,内核必须”钉住”它们。
核心函数是 get_user_pages()(或其变体
pin_user_pages()):
// 内核内部(简化)
long get_user_pages(unsigned long start, unsigned long nr_pages,
unsigned int gup_flags, struct page **pages);这个操作的成本:
- 页表遍历:内核必须遍历进程的页表,查找每个虚拟页面对应的物理页面。对于大量小缓冲区,这个遍历开销会很显著。
- 引用计数更新:每个被钉住的页面需要增加引用计数(原子操作),传输完成后再减少。在多核系统上,原子操作涉及缓存行的跨核同步。
- TLB shootdown(最昂贵的部分):如果页面之前不在 TLB 中,需要填充 TLB。更糟的是,如果内核需要修改页表项(比如设置 dirty bit),它必须向所有使用该页表的 CPU 发送 IPI(Inter-Processor Interrupt)来刷新 TLB。在大型 NUMA 系统上,一次 TLB shootdown 可能耗时数微秒。
对于大数据块(> 数百 KB),页面钉住的开销可以被传输量分摊。但如果你频繁发送小数据包(比如 RPC 响应,通常只有几百字节到几 KB),每个包都要走一遍 get_user_pages + 引用计数 + 潜在的 TLB shootdown,这个固定开销远超过一个简单的 memcpy。
3.2 DMA 对齐与 Scatter-Gather 限制
零拷贝依赖网卡的 DMA 引擎直接读取内存。但 DMA 引擎不是万能的:
- 对齐要求:很多 DMA 引擎要求缓冲区的起始地址和长度满足特定对齐条件(通常是 cache line 大小 64 字节,或更严格的 4KB 页面对齐)。
- scatter-gather 表大小有限:网卡的 scatter-gather 描述符表不是无限大的。如果数据分散在太多不连续的页面中,超过 scatter-gather 表容量,内核会 静默回退到拷贝模式。
- IOMMU 映射开销:在启用 IOMMU(或 Intel VT-d / AMD-Vi)的系统上,每个 DMA 缓冲区都需要在 IOMMU 页表中建立映射。这又是一笔固定开销。
最坑的是,这些限制通常不会导致错误——内核会悄悄 fallback 到 copy 路径。你以为你在用零拷贝,其实你只是多了一次系统调用的开销。
3.3 完成通知的延迟与内存压力
MSG_ZEROCOPY 和
IORING_OP_SEND_ZC 的两阶段完成机制意味着:
- 缓冲区生命周期延长:从
send调用到 DMA 完成通知的窗口期内,发送缓冲区不能被释放或修改。在高吞吐场景下,可能有成百上千个缓冲区同时处于”等待完成”状态,显著增加内存使用。 - 背压传导:如果应用发送速度快于网络速度,未完成的缓冲区会积压。传统的
send()返回后缓冲区立即可用(因为已经拷贝了),但零拷贝下你必须等待完成通知,这会导致更复杂的流控逻辑。 - 完成通知本身的开销:io_uring 的通知通过
CQE 传递(相对高效),而
MSG_ZEROCOPY的通知通过 socket error queue 传递,需要额外的recvmsg(MSG_ERRQUEUE)系统调用。
3.4 Cache Locality:最被忽视的因素
这是本文最重要的一节。
Zero copy 的核心卖点是”避免 CPU 拷贝数据”。但它忽略了一个事实:在另一个维度上,CPU 拷贝数据恰恰是有益的。
memcpy 的缓存预热效应
当 read()
把数据从内核页面缓存拷贝到用户空间时,这次 memcpy
有一个重要的副作用:数据被加载到 CPU 的 L1/L2 cache
中。
如果你的应用在发送数据之前需要读取或处理数据(比如计算校验和、添加 HTTP 头、日志记录),这些后续操作可以直接从 cache 中读取,速度极快。
而 zero copy 路径下,数据从未经过 CPU——它要么在页面缓存中(可能不在 CPU cache 中),要么直接从 DMA 到网卡。如果你之后需要访问这些数据,你会遭遇 cache miss,每次 miss 的代价在 DRAM 访问延迟量级(~80-100 纳秒)。
小数据的 memcpy 有多快?
让我们算一笔账。在现代 x86-64 处理器上:
以下为常见量级(不同 CPU 微架构、glibc 版本、缓存命中情况会显著影响实际数值):
| 数据大小 | memcpy 耗时(数据在 L1 cache) | memcpy 耗时(数据在 L2 cache) | memcpy 耗时(数据在 DRAM) |
|---|---|---|---|
| 64 B | ~数 ns | ~10 ns 级 | ~80 ns 级 |
| 512 B | ~十数 ns | ~数十 ns | ~百 ns 级 |
| 4 KB | ~50 ns | ~100 ns | ~300 ns |
| 64 KB | ~1 μs | ~2 μs | ~5 μs |
| 1 MB | - | ~30 μs | ~80 μs |
而 zero copy 的固定开销(页面钉住 + DMA 映射 + 完成通知处理)大约在 1-5 微秒 量级。
这意味着: - 64 B 数据:memcpy 5 纳秒 vs zero-copy 固定开销 1-5 微秒 → zero copy 慢了 200-1000 倍 - 4 KB 数据:memcpy 50-100 纳秒 vs zero-copy 固定开销 1-5 微秒 → zero copy 慢了 10-100 倍 - 64 KB 数据:memcpy 1-2 微秒 vs zero-copy 固定开销 1-5 微秒 → 大致持平,zero copy 开始有优势 - 1 MB+ 数据:memcpy 30-80 微秒 vs zero-copy 固定开销 1-5 微秒 → zero copy 明显胜出
这就是交叉点(crossover point):大约在 64 KB 附近,zero copy 才开始追平传统路径。 我们的实测数据精确验证了这个预测——64 字节场景下 io_uring SEND_ZC 吞吐量仅为 read+write 的 4.7%,而在 64 KB 处四种方法几乎完全收敛(180-191 MB/s)。具体交叉位置取决于硬件、内核版本和网络路径(loopback vs 真实网卡)。
真实世界的影响
考虑一个典型的 gRPC 或 HTTP/2 服务:
- 平均响应大小:2-8 KB(JSON/Protobuf)
- 每秒请求数:10 万+
- 每个请求的数据传输完全在 zero-copy 的”亏损区”
在这种场景下,如果你盲目启用
MSG_ZEROCOPY,你会发现: 1.
吞吐量没有提升,甚至下降——我们的实测显示 4 KB 下 io_uring
SEND_ZC 吞吐量仅为 read+write 的 1/3 2.
发送方的缓冲区生命周期显著拉长——4 KB 场景下 io_uring
的”可复用时间”P50 是 120 μs,read+write 仅 0.55 μs(详见
§4.4) 3. 内存使用增加——因为缓冲区不能立即释放 4.
代码复杂度大增——两阶段完成的状态管理
相反,Kafka 和 Nginx 使用 sendfile 效果好,恰恰是因为它们传输的是大文件或大数据块——日志 segment 动辄几百 MB,静态文件通常也在 KB 到 MB 量级。在这个尺度上,省掉的 CPU memcpy 远大于零拷贝的固定开销。
4. 实测 Benchmark
说了这么多理论,让我们用数据说话。以下 benchmark
的完整代码在 examples/zero-copy/
目录,你可以在自己的机器上复现。
4.1 实验设计
测试场景:从一个进程通过 TCP socket 发送数据到另一个进程(loopback 接口),测量发送端的吞吐量和 CPU 利用率。
参赛选手:
| 方法 | 实现 |
|---|---|
read +
write |
传统 4 次拷贝路径 |
sendfile |
文件到 socket 零拷贝 |
splice |
通过管道的零拷贝 |
io_uring SEND_ZC |
io_uring 零拷贝发送 |
控制变量:
- 数据大小:64 B, 512 B, 4 KB, 16 KB, 64 KB, 256 KB, 1 MB, 10 MB
- 每组预热 2 秒 + 测量 5 秒
- 输出 CSV 格式
口径说明:
read+write/sendfile/splice测的是文件或页缓存到 socket 的发送路径。io_uring SEND_ZC不能直接从文件描述符发起零拷贝发送,所以 benchmark 先把测试数据预加载到用户态缓冲区,再测 steady-state 的发送阶段。- §4.4 的延迟数据测的是发送方完成语义:也就是“什么时候源数据可以安全复用”,不是端到端网络延迟,也不是 RPC 请求尾延迟。
测试环境:
| 项目 | 值 |
|---|---|
| CPU | Intel Core i9-12900K (12th Gen) |
| 内存 | 32 GB DDR5 |
| 内核 | 6.6.87 (WSL2) |
| 网络 | TCP loopback |
| 编译器 | GCC 15.2.1 -O2 |
以下数据来自作者的测试环境。不同硬件和内核版本会有显著差异——特别是在真实网卡(vs loopback)上,zero-copy 的 DMA 优势会更明显。完整代码和运行方法见 examples/zero-copy/README.md,运行
make && ./run_bench.sh即可在你的机器上复现。
4.2 吞吐量对比
实测吞吐量数据(单位 MB/s):
| 数据大小 | read+write | sendfile | splice | io_uring SEND_ZC |
|---|---|---|---|---|
| 64 B | 110.30 | 110.52 | 90.12 | 5.15 |
| 512 B | 187.64 | 150.35 | 141.90 | 38.34 |
| 4 KB | 186.38 | 186.94 | 183.25 | 60.67 |
| 16 KB | 186.02 | 193.30 | 152.83 | 167.40 |
| 64 KB | 180.69 | 190.91 | 188.18 | 188.11 |
| 256 KB | 191.11 | 173.22 | 191.78 | 193.68 |
| 1 MB | 196.04 | 139.30 | 191.50 | 190.60 |
| 10 MB | 188.15 | 187.93 | 184.16 | 186.69 |
(加粗=该行最快)
让我一行一行说:
64 字节——io_uring SEND_ZC 慢了 21 倍。 没有写错,5.15 MB/s 对 110.52 MB/s。两阶段完成通知的固定开销,分摊到 64 字节的数据上,简直是碾压式的惩罚。同时 splice 也因为管道操作的额外系统调用损失了 ~18%。而 sendfile(110.52)和 read+write(110.30)在这个量级上可视为持平——因为 64 字节的 memcpy 本身就是个位数纳秒,零不零拷贝根本无所谓。
512 字节——read+write 一骑绝尘(187 MB/s),sendfile 和 splice 分别掉到 150 和 141。 这个结果初看反直觉:sendfile 不是应该更快吗?在 loopback 上,sendfile 的”零拷贝”需要走内核的 splice 管道路径(参见第 2.1 节),这个间接层在小数据下反而成了累赘。而 read+write 的路径最短、最直接。
4 KB——四者趋近,但 io_uring 仍然只有 1/3 的速度。 read+write 和 sendfile 几乎打平(186 vs 187),splice 紧随其后(183)。io_uring 的 60.67 MB/s 依然惨不忍睹——两阶段完成的开销还没被分摊掉。
16 KB——sendfile 开始领先。 sendfile 跑到 193 MB/s,超过 read+write 的 186。io_uring 猛追到 167 MB/s,但 splice 意外回退到 152——可能是管道默认 64KB 容量造成的分页效率损失。这是 sendfile 开始展示其内核路径短的地方。
64 KB——交叉点。 四种方法几乎完全收敛:180-191 MB/s。io_uring SEND_ZC 终于追平(188 MB/s)。从这个点开始,零拷贝的固定开销被数据量分摊到可以忽略。
256 KB 及以上——触及 loopback 带宽天花板。 所有方法的吞吐量都在 ~186-196 MB/s 的窄带内,这就是本环境 TCP loopback 的极限。有趣的是 sendfile 在 1 MB 时意外掉速到 139 MB/s——这可能与 sendfile 内部的 splice 管道在大文件时的管理开销有关(需要多次循环填充 pipe buffer)。
核心结论:在 loopback 上,不存在 zero-copy “吞吐量翻倍”的神话——所有方法最终都被 loopback 的内存拷贝带宽封顶。Zero-copy 的真正价值在真实网卡上,那里 DMA 才真正能省下 CPU 周期。 以上结论基于 WSL2 + loopback 环境;在物理机 + 真实 NIC(尤其是 25/100 Gbps 级别)上,zero-copy 的 DMA 路径节省会被放大,交叉点可能左移。如果你的场景是高带宽物理网卡,务必在目标环境重新测量。
4.3 操作速率(ops/sec)
ops/sec 提供了另一个视角——每秒能完成多少次完整的发送操作:
| 数据大小 | read+write | sendfile | splice | io_uring SEND_ZC |
|---|---|---|---|---|
| 64 B | 1,807,181 | 1,810,726 | 1,476,585 | 84,422 |
| 512 B | 384,284 | 307,919 | 290,612 | 78,514 |
| 4 KB | 47,714 | 47,856 | 46,913 | 15,532 |
| 16 KB | 11,905 | 12,371 | 9,781 | 10,713 |
| 64 KB | 2,891 | 3,054 | 3,011 | 3,083 |
在小包场景下(64B-4KB),read+write 和 sendfile 的 ops/sec 比 io_uring SEND_ZC 高出 3-21 倍。如果你的服务追求的是最大请求吞吐量,io_uring 零拷贝在小包下没有优势。至于 §4.4,请把它理解成“发送方完成语义”的分布,而不是服务端到端尾延迟。
运行
perf stat -e cache-misses,cache-references,context-switches ./bench_readwrite 65536可以进一步对比不同方法的 cache miss 和上下文切换差异。在真实网卡环境下,建议用perf record -g+ flamegraph 来观察 CPU 时间分布。
4.4 发送方完成延迟(缓冲区可复用时间)
为了量化发送方需要等待多久才能安全复用源数据,我们用
bench_latency
对每次发送操作单独计时(单飞模式,一次只有一个 inflight
操作),然后统计百分位数。对于 io_uring SEND_ZC,测量的是从
io_uring_submit() 到 NOTIF CQE(DMA
完成、缓冲区可复用)的全程延迟;而对 read +
write / sendfile /
splice,测量的是系统调用路径返回为止。
这不是端到端网络延迟,也不是 RPC/HTTP 请求尾延迟。它回答的问题更接近:如果我是发送方生产者,提交这次发送后要多久才能安全复用我的源数据?
4 KB:
| 方法 | P50 (μs) | P99 (μs) | P999 (μs) | Max (μs) | samples |
|---|---|---|---|---|---|
| read+write | 0.55 | 942.47 | 1115.41 | 2488.31 | 238,052 |
| sendfile | 0.42 | 860.46 | 1372.01 | 2502.65 | 244,922 |
| splice | 0.52 | 895.24 | 1135.55 | 2396.13 | 242,572 |
| io_uring SEND_ZC | 119.53 | 371.63 | 643.28 | 918.03 | 38,560 |
64 KB:
| 方法 | P50 (μs) | P99 (μs) | P999 (μs) | Max (μs) | samples |
|---|---|---|---|---|---|
| read+write | 26.07 | 1165.08 | 1800.50 | 2217.15 | 14,765 |
| sendfile | 3.81 | 1827.66 | 2046.92 | 2356.66 | 13,354 |
| splice | 6.06 | 1812.62 | 1956.05 | 2485.48 | 13,150 |
| io_uring SEND_ZC | 442.35 | 604.74 | 738.55 | 1434.91 | 11,138 |
1 MB:
| 方法 | P50 (μs) | P99 (μs) | P999 (μs) | Max (μs) | samples |
|---|---|---|---|---|---|
| read+write | 5170.56 | 6910.23 | 9137.19 | 9137.19 | 967 |
| sendfile | 5203.77 | 7737.87 | 10040.34 | 10040.34 | 955 |
| splice | 5184.72 | 7833.87 | 10189.52 | 10189.52 | 954 |
| io_uring SEND_ZC | 2243.39 | 3023.04 | 3944.46 | 4179.76 | 2,220 |
这组数据真正说明的是:
io_uring SEND_ZC 的”源数据可复用时间”分布更平,但绝对值更高。 在 4 KB 场景下,read+write 的 P50 仅 0.55 μs,因为它只要把数据拷进 socket buffer 就能返回;io_uring 的 P50 却有 120 μs,因为它必须等到 NOTIF CQE,确认 DMA 已完成、buffer 真正可复用。两者回答的问题不同,所以不能把这张表直接解读成”io_uring 的 RPC 尾延迟更低”。
不过,这个测试仍然有工程价值。
它揭示了发送方背压的传播方式:传统同步路径在大多数时候返回极快,但偶尔会在
socket 发送缓冲区打满时出现毫秒级阻塞;io_uring SEND_ZC
的等待更连续、更可预期,但每次操作都要承担 NOTIF
的固定成本。也就是说,bench_latency
测到的是生产者侧 stall 模式,不是网络侧
tail latency。
1 MB 时 io_uring 在这个指标上明显占优。 P50 只有其他方法的 43%(2.2 ms vs 5.2 ms),P99 也显著更低。这说明当数据块足够大时,异步提交 + 零拷贝带来的背压平滑效果开始压过两阶段完成的固定成本。
注意:以上是 loopback 上的单飞、发送方完成语义测试。在真实网卡上,io_uring SEND_ZC 的 NOTIF CQE 延迟会受 NIC 中断合并(interrupt coalescing)和 NAPI 轮询周期影响;如果你关心的是端到端请求尾延迟,需要在真实业务路径上另做应用层测量,而不是直接拿这里的 P99/P999 下结论。
5. 决策框架:什么时候用 Zero Copy
简明对照表
| 场景 | 推荐 | 原因 |
|---|---|---|
| 静态文件服务器 (Nginx) | sendfile ✅ |
大文件 + 文件来源 + 不需修改 |
| Kafka 日志段传输 | sendfile ✅ |
百 MB 级连续数据 |
| CDN / 视频流 | sendfile /
splice ✅ |
大数据块 + CPU 释放关键 |
| gRPC / HTTP API | read +
write ✅ |
小响应体 + 需要序列化/加密 |
| 数据库协议 | read +
write ✅ |
小包 + 需要协议封装 |
| TLS 连接 | read +
write ✅ |
数据必须经过加密处理 |
| 400 Gbps 高频交易 | io_uring ZC Rx ✅ | 极端带宽优化,值得投入复杂度 |
| 微服务间 RPC | read +
write ✅ |
小包 + 简单直接,buffer 生命周期最短 |
6. 总结
Zero copy 的名字本身就是最好的营销——谁不想”零拷贝”呢?但系统编程没有银弹,每一个”省掉”的操作背后,都有新的成本在别处冒出来。
真正理解 zero copy 的工程师知道:
- 看数据大小:小于 64 KB,默认优先用
read+write——尤其是请求型、小包、延迟敏感的场景,我们的实测显示它最快最稳。大于 64 KB,值得测一测sendfile。在真实网卡(非 loopback)上传输 MB 级数据时,sendfile的 CPU 节省才真正有意义。 - 看数据路径:如果数据需要经过 CPU 处理(加密、压缩、修改),zero copy 帮不了你——老老实实拷贝反而因为 cache 预热更快。
- 看你的系统:虚拟化、IOMMU、网卡型号、内核版本——所有这些都会影响 zero copy 的实际效果。不测不知道。
- 看你的指标:你在乎吞吐量、发送方完成时间,还是端到端请求尾延迟?zero copy 在不同指标维度上的表现可能截然相反。本文实测主要验证吞吐、请求速率和发送方完成语义;至于 CPU 占用节省与真实请求尾延迟,需在真实 NIC 和真实业务路径上单独评估。
最后一个忠告:不要因为”zero
copy”这个名字就默认它更好。拿你的数据、在你的环境上跑
benchmark,用数据说话。 这篇文章的所有 benchmark
代码都在 examples/zero-copy/ 目录——去跑吧。
相关阅读
站内文章:
- io_uring 核心原理:Linux 异步 I/O 的新纪元 — Zero-Copy Rx 的详细机制
- io_uring 高级特性:榨干性能极限 — Fixed Buffers 和 Provided Buffers
- 反思与打破神话:为何特定场景 epoll 比 io_uring 更高效 — io_uring 不总是赢的更多场景
- Libevent evbuffer 深度剖析 — evbuffer 的零拷贝优化与 sendfile 集成
- Linux 文件 I/O 深度解析 — 内核文件表与 splice 数据结构
站外参考:
- Jens Axboe, “Zero-copy networking with io_uring” — io_uring 零拷贝发送的原始设计文档
- Jonathan Corbet, “Zero-copy TCP receive” (LWN.net) — Zero-Copy Rx 的内核实现讨论
- Linux
sendfile(2)man page — 官方文档中关于 scatter-gather 要求的说明 - Efficient data transfer through zero copy (IBM developerWorks) — 经典的零拷贝原理介绍