我大学刚学操作系统那会儿,看到“零拷贝”三个字的第一反应是:数据从磁盘到网卡,怎么可能一次都不拷贝?后来在网关项目里被线上问题按在地上摩擦,才发现自己之前的理解有多浅。零拷贝不是“不拷贝”,而是把 CPU 从搬运工的岗位上解放出来——DMA 照样在搬,但 CPU 不必再亲自参与用户态和内核态之间的来回倒腾。这篇就当是给自己做个总结,把零拷贝这条进化线从 4 次搬运讲到 0 次 CPU 拷贝,原理、代码、性能对比、踩坑全给你捋一遍。
如果你正在写文件服务器、网关、代理转发,或者被“为什么我用 XX 语言传大文件 CPU 直接飙满、吞吐却上不去”折磨过,这篇文章应该能给你不少启发。看不懂内核源码也没关系,我会尽量用大白话配合实际例子把它讲透。
1. 为什么说传统 I/O 是一次体力活?
1.1 一次文件传输背后发生了什么
假设我们要把磁盘上的 package.tar.gz 通过网络发给客户端,最朴素的做法就是 read + write 两个系统调用循环处理:
c复制char buf[BUFSIZ];
while ((n = read(fd, buf, sizeof(buf))) > 0) {
write(sockfd, buf, n);
}
这段代码几乎在每本网络编程教材里都出现过,看起来人畜无害,但你把数据流拆开看,内部的水非常深。read 一次,数据要从磁盘挪到内核缓冲区,再从内核缓冲区复制到用户态缓冲区;write 一次,数据要从用户态缓冲区复制到 socket 的内核发送缓冲区,最后由网卡发出去。整个过程涉及 4 次搬运,其中有 2 次是 CPU 亲手把数据从一个内存区域复制到另一个内存区域。
很多人觉得“内存拷贝”很快,确实,一次 memcpy 的延迟在纳秒级,但你架不住量大。一个 GB 级别的文件,每个字节都要被 CPU 在用户态和内核态之间倒腾两回,再加上 4 次上下文切换的开销,高并发下 CPU 全都耗在搬运上,真正的业务逻辑反而分不到时间片。这也是为什么有些框架一压静态文件就露馅,CPU 跑满、QPS 上不去,其实问题不在代码,而在数据通路。
1.2 4 次搬运的账单到底怎么算
把一次完整的文件发送流程拆开看,传统路径的 4 次搬运分别是这样的:
- 磁盘 → 内核读缓冲区:这是 DMA 拷贝,由 DMA 控制器负责,CPU 只需要在发起和结束时介入,不参与数据的逐字节搬运。
- 内核读缓冲区 → 用户空间缓冲区:这是 CPU 拷贝。
read系统调用返回之前,内核必须把数据从内核态复制到用户态缓冲区,这是copy_to_user的活。 - 用户空间缓冲区 → socket 发送缓冲区:这还是 CPU 拷贝。
write系统调用内部会调用copy_from_user,把用户态的数据复制到内核态 socket 缓冲区。 - socket 发送缓冲区 → 网卡:又是 DMA 拷贝,网卡控制器直接把数据从内存取走发到链路上去。
所以传统路径的完整账单是:2 次 DMA 拷贝 + 2 次 CPU 拷贝 = 4 次拷贝,外加 4 次用户态/内核态上下文切换——第一次 read 调用进去一次、回来一次,第二次 write 调用进去一次、回来一次。
这里要强调一个概念:为什么一定要切换上下文?因为用户态程序不能直接操作磁盘和网卡这类硬件资源,必须通过系统调用进入内核态,由内核去执行真正的 I/O 操作。每次切换都要保存和恢复寄存器、栈指针等现场信息,这不是免费的。数据量小的时候无所谓,一旦进入高并发大规模传输场景,上下文切换的 CPU 开销会高到让你怀疑人生。
1.3 用 strace 看传统路径的真实开销
光看理论不够直观,我建议你自己动手验证一下。在 Linux 上写一个小程序,用传统的 read + write 拷贝文件,然后用 strace 跟踪系统调用:
bash复制gcc -O2 -o cp_trad cp_trad.c
strace -c -f -e trace=read,write ./cp_trad package.tar.gz /tmp/out.tar.gz
-c 参数会汇总每个系统调用的次数、耗时和错误数。你会发现 read 和 write 交替出现,次数等于文件大小除以你设置的缓冲区大小,耗时集中在数据量大的那几次调用上。
有个细节需要注意:strace 跟踪不到 DMA 拷贝,你看到的只是系统调用返回,但 CPU 拷贝就发生在 read/write 的系统调用内部——这部分 CPU 时间会计到进程头上,但你看不到是哪一行代码产生的。所以排查性能问题时,单看代码是看不出来的,得靠 perf 或者压测工具才能定位。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. mmap + write:绕过用户态的第一层优化
2.1 mmap 的原理和取舍
既然问题出在“内核态和用户态之间那一次 CPU 拷贝”,那自然而然的思路就是:能不能让用户态直接访问内核的缓冲区,省掉这趟搬运?mmap 就是干这个的。它把文件映射到进程的虚拟地址空间,映射区域背后的物理内存就是内核的 page cache,用户态读写这块内存时不再需要 copy_to_user 把数据复制到专门的应用缓冲区。
c复制void *addr = mmap(NULL, file_size, PROT_READ, MAP_PRIVATE, fd, 0);
// addr 指向的内存直接落在内核 page cache 上
write(sockfd, addr, file_size);
munmap(addr, file_size);
用上 mmap 之后,数据路径变成:
- 磁盘 → 内核缓冲区:DMA 拷贝。
- 内核缓冲区 → socket 缓冲区:CPU 拷贝,但这次是内核直接从 page cache 复制到 socket 缓冲区,不再经过用户态中转。
- socket 缓冲区 → 网卡:DMA 拷贝。
总拷贝次数从 4 次降到 3 次,其中 CPU 拷贝从 2 次减到 1 次。别小看这一次的减少,在 GB 级别文件的传输场景里,省掉的是一次全量数据的 CPU 亲力亲为的复制。
2.2 为什么 mmap 没有彻底解决问题
但 mmap 的代价也不小。首先是缺页中断,第一次访问映射区域时,触发 page fault,内核要把对应页从磁盘加载到内存,这个过程有额外开销。其次,mmap 需要修改进程页表,频繁 mmap/munmap 会产生大量 TLB flush,影响内存访问性能。
还有个更隐蔽的问题:write 系统调用的语义是“从用户态缓冲区写入”,内核并不知道你传入的地址来自 mmap 区域,它还是要做一次 copy_from_user,把数据从用户态虚拟地址复制到内核 socket 缓冲区。所以 mmap 只是把第二次拷贝省了,第三次拷贝依然存在。
实际工程里,mmap 更适合“大文件多次读”的场景,比如数据库缓冲池、配置文件加载,而不是一次性传输。Java 里的 FileChannel.map 底层就是 mmap,很多人用它加速大文件读取,但忽略了它带来的内存占用、文件截断、并发安全等问题——这些都是踩过坑的人用血泪换来的经验。
3. sendfile:内核帮你把活干了
3.1 sendfile 的工作方式
mmap 的思路是“让用户态直接访问内核数据”,那更进一步的想法是:既然最终目的就是把文件内容发给 socket,为什么不干脆让内核直接把文件内容送到 socket 缓冲区,用户态全程不碰数据?这就是 sendfile 系统调用的由来:
c复制ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
一次调用,搞定整个传输。以 Linux 的 sendfile(out_fd= socket, in_fd= 文件) 为例,它的数据路径是:
- 磁盘 → 内核缓冲区:DMA 拷贝。
- 内核缓冲区 → socket 缓冲区:CPU 拷贝。
- socket 缓冲区 → 网卡:DMA 拷贝。
等等,这不还是 3 次拷贝吗?没错,如果网卡不支持 SG-DMA(Scatter-Gather DMA),sendfile 的 CPU 拷贝依然存在。但注意,上下文切换从 4 次降到了 2 次——一次调用进入内核,完成全部工作后返回,不需要你再发一次 write。这个收益在 CPU 密集场景下非常明显。
Nginx 的静态文件响应默认就开 sendfile,这也是它为什么能用很低的 CPU 处理海量静态请求的原因之一。你可以去查一下自己的 Nginx 配置,sendfile on; 这行基本都会开着。
3.2 SG-DMA 与真正的 0 次 CPU 拷贝
现代网卡普遍支持 SG-DMA 特性。所谓 Scatter-Gather,字面意思是“分散-聚集”:网卡能够根据内核提供的缓冲区描述符列表,直接从多个不连续的内存页里读取数据,而不用先把它们合并成连续缓冲区。有了这个硬件能力,sendfile 的第二步就彻底不需要 CPU 了:
- 磁盘 → 内核缓冲区:DMA 拷贝。
- 内核缓冲区 → 网卡:SG-DMA 直接读取,网卡控制器自己把数据从 page cache 取走。
此时 CPU 拷贝次数是 0,只剩 2 次 DMA 拷贝。这才是真正意义上的零拷贝——注意措辞,是 0 次 CPU 拷贝,不是 0 次拷贝。DMA 搬运仍然存在,但 DMA 不占用 CPU 的执行指令,CPU 可以转身去处理别的请求。
我见过很多人把这个概念搞混,一上来就说“零拷贝就是一次拷贝都没有”。真不是,就算到了极限,磁盘到内存、内存到网卡这两段 DMA 也省不掉——除非哪天磁盘和网卡直接互联,那就是硬件层面的新玩法了。
3.3 sendfile 的限制和适用边界
sendfile 不是万能的,它的限制非常明确。
首先,它只适用于“文件到 socket”的传输。文件句柄必须是支持 mmap 的(也就是常规文件),输出句柄必须是 socket 类型。如果你想从 socket 读到数据再发给另一个 socket,sendfile 无能为力。
其次,如果要发送的内容不是纯文件,而是需要动态拼接的数据(比如 HTTP 响应行、Header 加文件内容),sendfile 一次只能发文件部分,Header 需要额外写一次。好在顺序写不影响正确性,实际使用中通常是 write(sock, header, header_len) 再加 sendfile(sock, file_fd, ...),虽然多一次系统调用,但文件内容本身依然可以走零拷贝路径。
最后,sendfile 依赖文件系统页缓存。如果文件不在 page cache 里,第一次读还是要把数据从磁盘搬上来,这个磁盘 I/O 的等待时间是省不掉的。零拷贝优化的是 CPU 和内存带宽,不是磁盘延迟——这个认知要摆正。
4. 还有哪些零拷贝武器
4.1 splice:管道里的零拷贝
splice 是 Linux 2.6 引入的系统调用,它的定位比 sendfile 更底层:在两个文件描述符之间搬数据,不经过用户态。基于 Linux 管道的 pipe_buffer 机制,splice 可以通过传递页引用来完成数据移动,而不是真正的复制内存。
c复制ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
splice 要求其中一个 fd 必须是管道。典型的用法是把文件内容搬进管道,再从管道搬进 socket:
c复制int p[2];
pipe(p);
splice(file_fd, NULL, p[1], NULL, file_size, 0);
splice(p[0], NULL, sock_fd, NULL, file_size, 0);
第一次 splice 把文件页挂到管道的缓冲区上,第二次 splice 把管道里的页引用转交给 socket,全程没有 CPU 参与的数据复制。代价是需要两次系统调用,吞吐可能不如一次 sendfile,但 splice 真正厉害的地方在于:它打通了任意 fd 到任意 fd 的路径。socket 到 socket、socket 到文件,都能用。
我自己的观察是,生产环境里直接用 splice 写业务代码的不多,更多是网络代理、负载均衡这类中间件在用。它配合 tee 使用可以实现多路分发,不过那是另一个话题,这里不展开。
4.2 writev 与协议头的叠加技巧
回到实际场景:假设你要返回一段动态生成的 JSON,再跟着一个文件内容,怎么处理最合适?
一个常见方案是 writev,它可以把多个不连续的内存缓冲区一次性提交给内核:
c复制struct iovec iov[2];
iov[0].iov_base = header; // 动态生成的 HTTP 头
iov[0].iov_len = header_len;
iov[1].iov_base = file_data; // 文件内容(来自 mmap 或 read)
iov[1].iov_len = file_data_len;
writev(sock_fd, iov, 2);
注意,writev 并没有偷懒地消除 CPU 拷贝,它只是把多次 write 合并成一次系统调用,减少上下文切换。如果你 mmap 了文件再 writev,文件内容部分依然有一次 CPU 拷贝。
所以更合理的组合是:动态 Header 用 write 或 writev 先发,文件本体用 sendfile 再发。这样可以把真正的大块数据压在零拷贝路径上,动态小数据走普通写路径也无所谓。这是我在做网关转发时最常用的组合拳。
4.3 io_uring:新一代异步零拷贝
聊到现代高性能 I/O,绕不开 io_uring。它是 Linux 5.1 引入的异步 I/O 框架,和传统 read/write 走系统调用的模型完全不同:io_uring 在初始化时创建两个内核和用户态共享的环形队列,SQ(提交队列)和 CQ(完成队列)。用户把 I/O 请求填到 SQ,内核取走执行,完成后把结果写到 CQ,整个交互过程几乎不需要系统调用。
io_uring 在零拷贝层面做了几件很实在的事:
- 固定文件(fixed files):打开文件后把 fd 注册到内核,后续请求直接用索引引用,省掉每次 I/O 的
fget/fput引用计数开销。 - 注册缓冲区(registered buffers):提前锁定一块内存的物理页并映射好,避免每次 I/O 都做页表映射和取消映射,也让内核可以直接在固定缓冲区上做 DMA。
- 提供缓冲区(provided buffers):用户预先放一批缓冲区到内核,I/O 完成时内核选择可用的缓冲区直接填充数据,进一步减少数据搬运。
- 网络零拷贝发送:配合
sendmsg的零拷贝标志或者 io_uring 的IORING_OP_SEND_ZC,网络发送路径上可以做到真正的零 CPU 拷贝。
io_uring 是未来高性能网络服务、数据库、消息队列这些领域的重要方向。不过它上手门槛不低,而且对内核版本有硬性要求。我的建议是:如果你的业务还是常规的 Web 服务,sendfile 已经够用了;真到了需要压榨每一分 CPU 的阶段,再投入精力学 io_uring 不迟。
5. 零拷贝实操与性能对照
5.1 写一个文件传输程序做对比
光讲原理总觉得不过瘾,我习惯跑一个最小对比例子说服自己。这里写两个 C 小程序,一个是传统 read + write,一个是 sendfile,都在相同条件下拷贝同一个文件。
先看传统版本:
c复制// cp_trad.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
#include <errno.h>
#define BUF_SIZE (1024 * 1024)
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "usage: %s <src> <dst>\n", argv[0]);
return 1;
}
int in = open(argv[1], O_RDONLY);
int out = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (in < 0 || out < 0) {
perror("open");
return 1;
}
char *buf = malloc(BUF_SIZE);
ssize_t n;
while ((n = read(in, buf, BUF_SIZE)) > 0) {
ssize_t off = 0;
while (off < n) {
ssize_t w = write(out, buf + off, n - off);
if (w < 0) {
perror("write");
free(buf);
return 1;
}
off += w;
}
}
free(buf);
close(in);
close(out);
return 0;
}
再看 sendfile 版本:
c复制// cp_sendfile.c
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/sendfile.h>
#include <sys/stat.h>
int main(int argc, char *argv[]) {
if (argc != 3) {
fprintf(stderr, "usage: %s <src> <dst>\n", argv[0]);
return 1;
}
int in = open(argv[1], O_RDONLY);
int out = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
if (in < 0 || out < 0) {
perror("open");
return 1;
}
struct stat st;
fstat(in, &st);
off_t offset = 0;
ssize_t written = sendfile(out, in, &offset, st.st_size);
if (written < 0) {
perror("sendfile");
} else {
printf("written: %zd, offset: %lld\n", written, (long long)offset);
}
close(in);
close(out);
return 0;
}
编译的时候注意,如果文件大小可能超过 2GB,建议加上 -D_FILE_OFFSET_BITS=64,否则 off_t 默认 32 位会溢出变成负数,这个坑我踩过不止一次。
5.2 实测结果怎么看
对比测试不要只比墙钟时间,因为磁盘缓存和系统负载会干扰结果。我更推荐看 CPU 开销和上下文切换,用 perf 和 strace 辅助分析:
bash复制# 统计任务级 CPU 事件
perf stat -e task-clock,context-switches,page-faults ./cp_trad package.tar.gz /tmp/out1.tar.gz
perf stat -e task-clock,context-switches,page-faults ./cp_sendfile package.tar.gz /tmp/out2.tar.gz
我测试时一个比较典型的观察是:sendfile 版本的 task-clock(进程占用 CPU 的时间)会明显低于传统版本,上下文切换次数也少。如果文件足够大,差异会更明显。当然,如果你的磁盘是瓶颈,两个版本的墙钟时间可能差不多,但 CPU 的富余程度一眼就能看出来——这在高并发场景下才是关键。
还有个间接观察方法,用 strace -c 看系统调用统计,sendfile 版本只有一次 sendfile 调用,传统版本则是成千上万次 read/write。系统调用越少,内核态的开销越低,这也是为什么 sendfile 在高 QPS 服务里是标配。
5.3 生产环境选型建议
我把不同场景下的零拷贝方案整理成一张表,你可以直接对着选:
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 静态文件下载、Nginx 静态资源 | sendfile |
一次系统调用,CPU 拷贝降到 0(配合 SG-DMA) |
| 大文件反复读取、数据库缓冲 | mmap |
用户态直接访问 page cache,适合多次访问同一文件 |
| 动态 Header + 文件本体 | write/writev + sendfile |
大块数据走零拷贝路径,小块动态数据正常写 |
| socket 到 socket 转发、代理 | splice |
支持任意 fd 方向,不经过用户态 |
| 极致性能、高并发异步场景 | io_uring |
共享队列免系统调用,固定缓冲区 + 零拷贝发送 |
选方案的核心原则是:先确认瓶颈在哪。如果 CPU 已满,优先考虑零拷贝;如果磁盘 I/O 已经饱和,零拷贝解决不了问题,你要优化的是存储层,而不是数据通路。
6. 常见问题与排查技巧
6.1 零拷贝 = 0 拷贝?很多人理解错了
最常见的误解就是把“零拷贝”理解成“完全没有拷贝”。前面反复强调过,零拷贝真正的含义是 0 次 CPU 拷贝,DMA 拷贝始终存在。你应该关注的是:CPU 是不是还在给每个字节做搬运工。如果 CPU 不做这件事,它就腾出手去处理更多请求,吞吐自然就上去了。
引申一下,很多人面试时被问“零拷贝为什么快”,光回答“少了几次拷贝”是不够的。完整的答案应该包含两点:一是减少了 CPU 拷贝节省了 CPU 时间,二是减少了用户态/内核态切换节省了上下文切换开销。两者都会直接影响性能。
6.2 sendfile 的坑:偏移量、文件截断与 SG-DMA
sendfile 有几个实际使用中容易踩的坑,我单独拿出来说。
第一,offset 参数是输入输出参数。传入时指定从文件的哪个偏移开始读,调用结束后会被内核更新为实际发送结束的位置。如果你传 NULL,则从文件当前偏移开始,内核也会更新文件偏移。并发场景下要小心这个副作用。
第二,发送过程中文件被截断或追加内容,sendfile 的行为会变得不可预期。内核会尽最大努力把“当时能看到的数据”发完,但如果你依赖它发送精确的字节数,最好先 fstat 锁定文件长度,并在调用结束后检查返回值和 offset 的差。
第三,SG-DMA 有没有启用,直接决定 sendfile 是“0 次 CPU 拷贝”还是“1 次 CPU 拷贝”。可以用 ethtool 确认网卡能力:
bash复制ethtool -k eth0 | grep scatter-gather
输出里 scatter-gather: on 就是支持。现代虚拟机默认都开,但如果你跑在很老的硬件或者半虚拟化环境里,可能就没这个能力,sendfile 会退回普通内存拷贝路径,性能就没那么惊艳了。
6.3 splice 报 EINVAL?先检查文件描述符类型
splice 最常见的报错是 EINVAL,原因通常是文件描述符类型不符合要求。协议规定两个 fd 中至少有一个是管道,而且不支持某些方向(比如把数据 splice 到一个不支持的目标)。排查时先确认两个 fd 的类型,再用 strace 看系统调用返回的错误码,基本都能快速定位。
另外,splice 的 len 参数是最大传输字节数,实际传输量以返回值或输出的 off_in/off_out 为准。不要假设传多少就搬多少,尤其当源文件是非阻塞 socket 时,要处理部分写的情况,否则数据会静默丢失。
6.4 小文件别硬上零拷贝
最后说个反直觉的经验:小文件不要迷信零拷贝。对于几 KB 甚至几十 KB 的文件,sendfile 和 read + write 的差距可能很小,甚至 sendfile 还慢一点。原因很简单:零拷贝省的是“大量数据的搬运开销”,而小文件搬运本身消耗就不大,系统调用的固定开销和上下文切换反而占了大头。加上 sendfile 在内核里还要做一些额外检查,在小文件场景下收益不明显。
我个人的经验阈值大概在 64KB 以上,sendfile 的优势才会真正体现出来。但这个阈值会随内核版本、网卡能力、文件系统类型变化,最靠谱的做法是拿自己的真实流量做压测,用 perf 统计数据说话,而不是背诵别人的经验。
最后分享一点实操体会
零拷贝这玩意儿,光是“懂原理”和“能上手”之间真的差着一层窗户纸。我第一次在网关项目里把 read + write 改成 sendfile 时,CPU 使用率肉眼可见地降了一截,那一刻才真正明白,操作系统层面的每一点优化,落到生产环境里都是真金白银。
分享一个调优小技巧:当你怀疑某个应用没有走上零拷贝路径时,用 strace -c -f 跑一小段时间,看 read/write/sendfile/splice 的调用次数和耗时分布。如果发现大量 read/write 占主导,说明数据还在走老式的搬运路线,这就是优化的突破口。反过来,如果 sendfile 调用很多但 CPU 还是高,那问题大概率不在拷贝上,而是磁盘、网络或锁竞争,别在零拷贝这条路上死磕。
零拷贝是一条很有代表性的优化路径:从无到有,从有到精,每一小步的推进都是对硬件能力和软件设计边界的重新审视。希望这篇梳理,能帮你少走点弯路。
