土法炼钢兴趣小组的算法知识备份

Zero Copy 的肮脏真相:它什么时候反而更慢

目录

“用 zero copy,性能直接翻倍!”——每个系统程序员都听过这种说法。Nginx 用 sendfile,Kafka 靠 zero-copy 实现百万级吞吐,这些故事被反复传播,以至于 zero copy 在很多人心中等于”免费的性能提升”。

但我要告诉你的是:在相当多的真实场景下,zero copy 反而比老老实实 read + write 更慢。

先把结论摆出来:

  1. Zero copy 不是消除了拷贝成本,它是把成本从 CPU 转移到了别处——页面钉住、DMA 映射、完成通知、TLB 刷新,这些隐藏成本在小数据场景下远超 memcpy 本身。
  2. 当数据量小于 64 KB 时,传统的 read + write 几乎总是更快——实测数据显示,在 64 字节场景下 io_uring SEND_ZC 比 read+write 慢 21 倍(5 MB/s vs 110 MB/s)。
  3. sendfile 是最被误解的系统调用——它的”零拷贝”在很多情况下是有条件的,取决于网卡是否支持 scatter-gather DMA。不支持时,内核会静默回退到 copy。
  4. 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 四次拷贝路径

read+write 四次拷贝路径

完整路径:

步骤 方向 执行者 说明
磁盘 → 页面缓存 DMA 磁盘控制器搬数据到内核页面缓存,CPU 不参与
页面缓存 → 用户缓冲区 CPU read() 系统调用,copy_to_user(),这是一次真金白银的 memcpy
用户缓冲区 → Socket 缓冲区 CPU write() 系统调用,copy_from_user(),又一次 memcpy
Socket 缓冲区 → 网卡 DMA 协议栈构造好数据包后,网卡 DMA 取走数据

两次 CPU 拷贝(②和③)加上两次 DMA 拷贝(①和④),共四次数据搬运,外加两次用户态/内核态上下文切换(readwrite 各一次)。

对于大文件传输,CPU 把大量时间花在毫无意义的内存搬运上——数据从内核搬到用户空间,应用程序看都不看一眼,又搬回内核。这就是 zero copy 要解决的问题。

1.2 但”拷贝”真的那么贵吗?

这是一个关键问题,也是本文的核心论点之一。

现代 CPU 的 memcpy 实现已经被优化到令人发指的程度:

换句话说,对于小数据(< 几十 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);

sendfileread + write 合并成一个系统调用,数据在内核空间内部从页面缓存直接传输到 socket 缓冲区,不经过用户空间:

sendfile 零拷贝路径

看起来完美——从四次拷贝减少到了……等等,中间那步到底是不是零拷贝?

这取决于你的网卡。

你可以用 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 的特化版本。理解这一点很重要——它意味着 sendfilesplice 共享同样的底层机制和限制。

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)的引用计数页面来实现。

splice / vmsplice / tee 数据流

管道缓冲区 (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 声称是”零拷贝”,但在某些路径下:

  1. 页面必须是完整的:如果数据不是页面对齐的,或者长度不是页面大小的整数倍,内核可能需要分配新页面并拷贝数据来满足对齐要求。
  2. 管道容量有限:默认 16 个页面(64KB)。如果一次 splice 的数据量大于管道容量,需要多次循环,每次都有系统调用开销。
  3. 某些文件系统不支持 splice:当文件系统的 file_operations 中没有实现 splice_read/splice_write 时,内核会回退到通过中间缓冲区的拷贝路径。
  4. 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 安全复用

这个两阶段语义带来了严重的编程复杂度:

// 处理两阶段通知的典型代码
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)

接收方向的零拷贝更加激进。它要求:

这个方案在 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);

这个操作的成本:

  1. 页表遍历:内核必须遍历进程的页表,查找每个虚拟页面对应的物理页面。对于大量小缓冲区,这个遍历开销会很显著。
  2. 引用计数更新:每个被钉住的页面需要增加引用计数(原子操作),传输完成后再减少。在多核系统上,原子操作涉及缓存行的跨核同步。
  3. 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 引擎不是万能的:

最坑的是,这些限制通常不会导致错误——内核会悄悄 fallback 到 copy 路径。你以为你在用零拷贝,其实你只是多了一次系统调用的开销。

3.3 完成通知的延迟与内存压力

MSG_ZEROCOPYIORING_OP_SEND_ZC 的两阶段完成机制意味着:

  1. 缓冲区生命周期延长:从 send 调用到 DMA 完成通知的窗口期内,发送缓冲区不能被释放或修改。在高吞吐场景下,可能有成百上千个缓冲区同时处于”等待完成”状态,显著增加内存使用。
  2. 背压传导:如果应用发送速度快于网络速度,未完成的缓冲区会积压。传统的 send() 返回后缓冲区立即可用(因为已经拷贝了),但零拷贝下你必须等待完成通知,这会导致更复杂的流控逻辑。
  3. 完成通知本身的开销: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 服务:

在这种场景下,如果你盲目启用 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 零拷贝发送

控制变量

口径说明

测试环境

项目
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

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 的工程师知道:

  1. 看数据大小:小于 64 KB,默认优先用 read + write——尤其是请求型、小包、延迟敏感的场景,我们的实测显示它最快最稳。大于 64 KB,值得测一测 sendfile。在真实网卡(非 loopback)上传输 MB 级数据时,sendfile 的 CPU 节省才真正有意义。
  2. 看数据路径:如果数据需要经过 CPU 处理(加密、压缩、修改),zero copy 帮不了你——老老实实拷贝反而因为 cache 预热更快。
  3. 看你的系统:虚拟化、IOMMU、网卡型号、内核版本——所有这些都会影响 zero copy 的实际效果。不测不知道。
  4. 看你的指标:你在乎吞吐量、发送方完成时间,还是端到端请求尾延迟?zero copy 在不同指标维度上的表现可能截然相反。本文实测主要验证吞吐、请求速率和发送方完成语义;至于 CPU 占用节省与真实请求尾延迟,需在真实 NIC 和真实业务路径上单独评估。

最后一个忠告:不要因为”zero copy”这个名字就默认它更好。拿你的数据、在你的环境上跑 benchmark,用数据说话。 这篇文章的所有 benchmark 代码都在 examples/zero-copy/ 目录——去跑吧。


相关阅读

站内文章

站外参考


By .