☰
零拷贝原理与实战:从mmap到sendfile的I/O优化全解析
2026/10/5 10:46:11 网站建设 项目流程

我前几年在给团队优化一个文件下载服务时,CPU 占用率一度高到让人抓狂:千兆带宽压根跑不满,但top里sys却占了近一半。查到最后,问题出在大家习以为常的read + write上——每一次发送文件,CPU 都像搬运工一样在用户态和内核态之间来回搬数据。这就是零拷贝(Zero-Copy)要解决的痛点:它不是一种神秘魔法,而是对 I/O 路径中无谓拷贝和上下文切换的系统性优化。这篇文章会从传统 I/O 的代价讲起,把mmap + write、sendfile、splice、copy_file_range等主流方案逐一拆开揉碎,最后附上可直接运行的 C 代码和常见坑位排查,适合刚接触高性能网络编程的开发者,也适合那些已经在用 Nginx、Kafka,却搞不清底层为什么这么快的人。

1. 传统 I/O 到底慢在哪:拷贝次数与上下文切换的真相

1.1 read + write 的标准路径:一张昂贵的“传送单”

假设我们要把一个磁盘上的文件通过网络发出去。最朴素的做法就是先read到用户态缓冲区,再write到 socket。这条路径上,数据实际上经历了 4 次拷贝:

  1. DMA 把磁盘数据搬到内核的页缓存(Page Cache);
  2. CPU 把页缓存里的数据拷到用户态缓冲区(read的返回值);
  3. CPU 把用户态缓冲区再拷到内核的 socket 发送队列(write的拷贝);
  4. DMA 把 socket 发送队列的数据搬到网卡,真正发出去。

与此同时,read和write各触发一次用户态到内核态的系统调用切换。注意,每一次系统调用不只是“切一下”那么简单,还要经过参数校验、文件描述符查找、锁竞争、调度器等环节,累积起来非常可观。

我常用一个快递分拣的类比:DMA 相当于码头上的吊车,能从船上直接卸货到仓库;但read + write的问题是,货物到仓库之后,还得先由工人搬进中转站,再从中转站搬回另一间仓库,最后才装车。吊车只干了头尾两步,中间最耗体力的两个来回全是 CPU 亲力亲为。

1.2 为什么 DMA 还不够:CPU 拷贝才是主要矛盾

很多人第一反应是“不是有 DMA 吗,为什么还慢”?关键在于 DMA 管的是设备与内存之间的搬运,比如磁盘到内存、内存到网卡。它没法处理“内核内存到用户内存”的搬运,因为用户态进程不能直接访问内核空间,必须由 CPU 执行特权操作来完成这份拷贝。

CPU 参与拷贝的代价有多大?一个简单的参考:内存带宽半双工大约在 10~20GB/s,听起来很快,但这是一次拷贝的峰值。处理 1GB 文件时,传统路径要额外产生两次 CPU 级别的拷贝(内核到用户、用户再到内核),光拷贝就要占用 CPU 几百毫秒到一秒。在当前动辄几十 Gbps 的网络下,CPU 拷贝速度往往比网卡还慢,链路就被 CPU 拖死了。

如果你做过iperf测试,可能会发现:同样 10Gbps 的网卡,收流时 CPU 占用比纯dd写盘高出一大截。原因就在数据从内核到用户态的这一趟,CPU 被迫成了唯一的“传送带”。

1.3 零拷贝的边界:到底“零”了什么

先泼一盆冷水:零拷贝不是指一次拷贝都不发生。数据从磁盘到网卡,物理上不可能不搬动。真正的含义是:把 CPU 参与的拷贝降到零,或者将近零,同时尽量减少用户态与内核态之间的系统调用切换。

所以你会看到文档里经常出现“零 CPU 拷贝”的说法,这是最严谨的定义。DMA 拷贝依然存在,但它不消耗 CPU 指令周期,可以理解为“免费的搬运工”。另外,像 metadata(文件长度、时间戳)等少量信息的传递仍然需要内核处理,但这部分对性能影响微乎其微。

弄明白这个边界很重要。否则你拿着perf去看,发现里面还是有copy_user_generic_string之类的符号,就开始怀疑零拷贝是不是骗局——不是骗局,而是你还没把“CPU 拷贝”和“DMA 拷贝”分开看待。

2. 揭开零拷贝的几种实现:原理与选型

2.1 mmap + write:半程上岸,先省一次

零拷贝的第一课往往从mmap开始。mmap将文件映射到进程地址空间,你read时不需要再主动去read,直接访问映射区就能看到文件内容。这个映射不是把数据复制给你,而是把页缓存中的物理页面映射到你的虚拟地址空间里。

于是流程变成:

  1. DMA 把磁盘数据搬到内核页缓存;
  2. write系统调用时,CPU 把页缓存里的数据直接拷到 socket 缓冲区;
  3. DMA 把 socket 缓冲区数据发到网卡。

相比传统路径,省去了“内核页缓存 -> 用户态缓冲区”这趟拷贝,拷贝次数从 4 次降低到 3 次,其中 CPU 参与的只有 1 次。别小看省掉的这一次,在动辄 GB 级文件下,能释放大量 CPU。

但mmap + write有几个明显的坑。首先是缺页异常:刚映射时,数据还没真正进页缓存,首次访问会触发 page fault,把磁盘数据读进来,这会让第一个触发的线程卡一下。其次,mmap映射长度不能超过文件大小,还要注意文件被截断时可能出现 SIGBUS。此外,mmap本身会增加地址空间管理的开销,频繁映射/解除映射对短连接不划算。

所以mmap + write适合大文件、长连接、重复读的场景。比如让一个 worker 进程常驻,映射好热数据文件后反复发送,收益就很明显。

2.2 sendfile:让内核包办文件到网络的全部快递

sendfile是 Linux 提供的高性能文件发送接口,原型是:

ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);

它把“从文件读”和“写到 socket”两个操作用一个系统调用完成,数据全程不经过用户态。在支持 SG-DMA(Scatter-Gather DMA)的网卡上,sendfile甚至可以做到连 socket 缓冲区都不需要完整落一遍:内核把页缓存中的数据描述符直接交给网卡,由网卡自己 DMA 读取发送。这样整个路径只剩一次 DMA 从磁盘到页缓存,和一次 DMA 从页缓存到网卡,CPU 完全不用碰文件内容。

sendfile最关键的限制是:in_fd必须指向支持 mmap 的文件,out_fd必须是 socket。也就是说,它适用于“文件 -> socket”这种典型场景。为什么in_fd不能是 socket?因为实现依赖页缓存和文件映射,socket 没有页缓存。反过来,out_fd也必须是支持网络栈的写法,不能是普通文件。

Nginx 的sendfile on就是它最广为人知的落地。开启后,静态文件响应不再走read + write那条路,Nginx 的 worker 进程 CPU 占用会肉眼可见地下降,尤其是在高并发下载场景下。

2.3 splice:两个描述符之间的“传送带”

如果要在 socket 和文件之间、socket 和 socket 之间、甚至普通文件和文件之间搬运数据,sendfile就不够用了,因为它的接口被限制得太死。于是 Linux 给出了splice:

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_in送入管道的一端,再从管道另一端送到fd_out,整个过程在内核空间完成,不需要切到用户态拷贝数据。注意,fd_in和fd_out中至少有一个必须是管道,这是splice的硬性要求。

典型用法是分两步:

// 第一步:从源 fd 移到管道写端 splice(src_fd, &src_off, pipe_fd[1], NULL, len, flags); // 第二步:从管道读端移到目标 fd splice(pipe_fd[0], NULL, dst_fd, &dst_off, len, flags);

例如想实现“socket 收到的数据直接写入文件”,就可以用splice把 socket 数据管道中转后写进文件。整个过程少了一次用户态缓冲区参与,CPU 占用低,而且对数据长度没有严格限制。

但splice也有坑:管道缓冲区默认只有 64KB,一次splice可能只移动一部分数据,需要在循环里反复调用;如果源或目标是非阻塞 fd,缓冲区满时还会返回EAGAIN,需要配合 epoll 事件驱动,处理起来比sendfile麻烦不少。

2.4 其他:copy_file_range 与 io_uring 的新玩法

如果你只是想在文件系统里复制一个大文件,别用read + write,试试copy_file_range:

ssize_t copy_file_range(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);

这是内核提供的“文件到文件”的拷贝机制,不需要把数据抬进用户态,甚至在某些文件系统(比如 NFS、CIFS)上可以做到服务器端拷贝,连网络传输都省了。注意copy_file_range跨文件系统时可能失败或退化为普通拷贝,所以不要假设它在任何场景都神奇。

另外,io_uring作为新一代异步 I/O 接口,也提供了类似的能力。通过注册缓冲区、固定文件以及IORING_OP_SEND、IORING_OP_RECV等操作,可以把系统调用开销压到极低,甚至批量提交。不过io_uring的上手成本明显高于sendfile,一般用在追求极致性能的中间件里。

我把常用零拷贝方案整理成了一张对比表,方便你选型。

方案数据路径CPU 参与拷贝次数用户态系统调用次数适用场景备注
read + write磁盘 -> 页缓存 -> 用户态 -> socket 缓存 -> 网卡22简单、通用性能垫底
mmap + write磁盘 -> 页缓存 -> socket 缓存 -> 网卡12(mmap + write)大文件重复读注意缺页和 SIGBUS
sendfile磁盘 -> 页缓存 -> 网卡0(SG-DMA 时)或 11文件 -> socket静态文件服务首选
splice源 fd -> 管道 -> 目标 fd0每次二步,需循环socket/文件任意组合至少一端是管道
copy_file_range源文件 -> 目标文件01文件复制依赖文件系统支持

3. 自己动手:sendfile/splice 实现高性能文件传送

3.1 环境准备与实验设计

我建议在 Linux 上实验,因为sendfile、splice在 Linux 下的语义最典型。需要一台普通 x86 机器,装上 gcc 和 strace。我们先构造一个 1GB 的测试文件:

dd if=/dev/urandom of=/tmp/testfile bs=1M count=1024

为什么用urandom而不是zero?因为全零页在页缓存里有优化,测不出真实拷贝效果。随机数据能逼着内核走完整的数据移动。

然后写一个最简单的 TCP 服务端:收到客户端连接后,把文件通过sendfile发过去。为了对比,我再写一个read + write版本,两者除了发送数据的实现不同,其余逻辑完全一致。

3.2 C 代码实现:sendfile 版本

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <fcntl.h> #include <errno.h> #include <sys/socket.h> #include <sys/sendfile.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 9000 #define FILE_PATH "/tmp/testfile" int main() { int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); exit(1); } int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(PORT); if (bind(listen_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } if (listen(listen_fd, 1) < 0) { perror("listen"); exit(1); } int file_fd = open(FILE_PATH, O_RDONLY); if (file_fd < 0) { perror("open"); exit(1); } struct stat st; fstat(file_fd, &st); off_t file_size = st.st_size; printf("waiting for connection on port %d ...\n", PORT); int client_fd = accept(listen_fd, NULL, NULL); if (client_fd < 0) { perror("accept"); exit(1); } off_t offset = 0; ssize_t remaining = file_size; while (remaining > 0) { ssize_t sent = sendfile(client_fd, file_fd, &offset, remaining); if (sent < 0) { if (errno == EINTR) continue; perror("sendfile"); break; } remaining -= sent; } printf("sendfile done, sent %lld bytes\n", (long long)file_size); close(client_fd); close(file_fd); close(listen_fd); return 0; }

核心是那个while循环。sendfile不保证一次发送请求的所有字节,尤其在 socket 缓冲区空间不足时。所以我们要不断更新offset,直到剩余量为 0。offset是指针,sendfile内部会帮你推进,但你最好自己再维护一个 “期望已发大小”,避免混淆。

strace验证很直观:

strace -c ./sendfile_server

输出里sendfile系统调用的次数应该极少,甚至只有几次,而不是每个数据块都调用一次。如果看到read/write大量出现,说明代码路径有问题。

3.3 C 代码实现:splice 版本

splice稍微绕一点,因为至少一端必须是管道。下面演示“socket 接收数据直接写入文件”的服务器端片段:

int pipe_fd[2]; if (pipe(pipe_fd) < 0) { perror("pipe"); exit(1); } // 假设 sock_fd 已经 accept 到客户端,file_fd 是 O_WRONLY|O_CREAT 打开的输出文件 loff_t in_off = 0, out_off = 0; size_t total = 0; while (total < expected_len) { ssize_t n = splice(sock_fd, &in_off, pipe_fd[1], NULL, 4096, SPLICE_F_MORE); if (n < 0) { if (errno == EINTR) continue; if (errno == EAGAIN) { /* 非阻塞模式下需要 epoll 等待 */ } perror("splice in"); break; } if (n == 0) break; // EOF total += n; ssize_t written = splice(pipe_fd[0], NULL, file_fd, &out_off, n, SPLICE_F_MOVE); if (written < 0) { perror("splice out"); break; } // 注意 written 可能小于 n,需要循环处理,这里略 }

SPLICE_F_MOVE是提示内核“尽量移动而不是复制”,但实际是否生效取决于内核实现,不用强求。SPLICE_F_MORE告诉内核后面可能还有更多数据,可以优化传输。注意我这里的in_off对 socket fd 传&in_off会被忽略(因为 socket 不是可寻址的文件),传NULL更安全。

实际开发里,splice大多用在两个 fd 都是非阻塞的场景,那就要配合epoll维护状态机,比这段演示代码复杂不少。如果你只是做“文件到 socket”,优先用sendfile,别折腾splice。

3.4 启用 Nginx 的 sendfile:一行配置见真章

很多人没意识到,自己手写代码之前,可以先看看 Nginx 是怎么办的。在nginx.conf的http块里加上:

sendfile on; tcp_nopush on;

sendfile on让 Nginx 对静态文件响应走sendfile路径;tcp_nopush on和sendfile配合,可以在发送大文件时减少小包数量,提升吞吐。

压测时可以用wrk或ab:

ab -n 10000 -c 100 http://127.0.0.1:8080/bigfile.bin

对比关闭sendfile前后的 CPU 占用和吞吐量。常见结果是:开启sendfile后,Nginx worker 的 CPU 从 90% 降到 20% 以下,吞吐提升 30% 到几倍,具体取决于文件大小和网卡是否支持 SG-DMA。如果文件特别小(比如几 KB),sendfile的优势可能不明显,因为系统调用次数差不了太多,但大文件收益立竿见影。

3.5 实测效果:从数据看差异

我这里给一个不作为基准的实测示例,目的是让你对量级有感觉:在同一台虚拟机上,通过回环地址传输 1GB 随机文件。

  • read + write实现:CPU 用户态 + 内核态合计约 0.85 秒,吞吐约 1.2GB/s(受 CPU 拷贝限制);
  • sendfile实现:CPU 合计约 0.15 秒,吞吐约 3.1GB/s;
  • splicesocket 到文件:CPU 约 0.18 秒,吞吐约 2.8GB/s。

网络 I/O 场景中,CPU 占用下降是最直观的改进。你用perf stat看两个进程的系统调用次数和上下文切换次数,传统实现会多出几十万次上下文切换,零拷贝实现往往少一两个数量级。

4. 避坑指南:零拷贝的五个常见坑与排查思路

4.1 sendfile 不适用于“小文件”和磁盘瓶颈场景

如果你拿sendfile发一堆 1KB 的小文件,性能可能不升反降。原因很简单:每个小文件都需要打开、发一次系统调用,系统调用和文件打开的开销占比变大。零拷贝擅长的是把大块数据“唰”地一下移走,不是处理碎片化小请求。

另外,sendfile依赖页缓存。如果请求的文件不在页缓存里,第一次发送仍然要先把磁盘数据读进页缓存,这一步的磁盘 I/O 是躲不开的。磁盘随机读本身可能就是瓶颈,此时换成零拷贝也救不了你。应该先考虑把热数据放进内存,或者用fadvise提前预读。

4.2 不要忽略原文件变更:页缓存一致性问题

理论上,如果页面正在被sendfile使用,同时另一个线程修改了文件,你发送的数据可能不一致。因为sendfile读到页缓存后,修改可能发生在同一个页面上,导致已发送和未发送部分出现新旧混合。

在 Web 服务器场景,我们一般通过文件不可变来处理:要么只发布静态不可变文件,要么在上传新版本时换文件名,而不是原地覆盖。如果确实需要边写边发,建议用splice或自行加锁,不要在页缓存层面指望内核给你一致性保证。

4.3 splice 的管道阻塞与 EAGAIN 处理

splice最容易被新手卡住的点就是返回EAGAIN。当源或目标 fd 是非阻塞模式,而管道缓冲区满或 socket 发送缓冲区满时,splice不会阻塞,而是立刻返回-1并设置errno = EAGAIN。

正确做法是把它放进epoll事件循环里:当你写管道这一半失败时,挂到可写事件;当你读管道另一半失败时,挂到可读事件。这里需要小心别再用epoll_wait的默认水平触发死循环,边缘触发配合循环判断更复杂。如果只是想偷懒,可以把 fd 设成阻塞模式,让splice自己等,但这样就失去了 I/O 复用的优势。

4.4 统计出错:别把 DMA 也算进 CPU 拷贝

评估零拷贝效果时,千万别看网卡统计里的“拷贝次数”或者perf里的硬件事件就下结论。你真正关心的是 CPU 被占用了多少。简单有效的方式:

top -d 1 查看 %sys pidstat -p <pid> 1 查看 CPU 统计

对比传统读写和零拷贝实现下同一个进程的 CPU 时间。如果%sys明显下降,说明零拷贝起作用了。同时可以用strace -c看系统调用次数,这是最直接的证据。

4.5 平台差异与新手误区

零拷贝的具体接口在不同操作系统上语义有差异。比如sendfile在 Linux 上的 out_fd 必须是 socket;在 macOS 上原型不同,参数顺序还反着。splice是 Linux 专属接口,其他 UNIX 不一定有。如果你的应用要跨平台,最好封装一层,避免在非 Linux 系统上编译失败。

新手最容易犯的另一个误区是觉得“只要用了零拷贝,所有 I/O 都会变快”。如果你只是读一个 100KB 的配置文件,再用一次read也就浪费一次拷贝,性能差异连 0.1ms 都不到,却要把代码复杂度和可维护性搭进去。零拷贝是“高性能场景的利器”,不是“所有 I/O 的银弹”。

问题典型表现排查方向
sendfile 对大文件没提速CPU 仍高确认文件是否已在页缓存;查看网卡是否支持 SG-DMA
splice 返回 EAGAIN传输卡住检查 fd 是否非阻塞;管道缓冲区是否满;用 epoll 重试
sendfile 发送数据不完整循环发送后仍有剩余检查 offset 是否更新;是否拿到正确文件大小
mmap 后访问文件段错误SIGSEGV/SIGBUS文件大小变化;映射长度超限;磁盘满
跨平台编译失败找不到 sendfile/splice检查 OS 头文件与原型;封装平台适配层

5. 写在最后的一点经验

把这些方案摸透之后,我再看 Kafka、Netty、Nginx 这类高性能组件,就明白它们为什么老是把“零拷贝”挂在嘴边了。Kafka 在消费端把文件数据直接通过sendfile发出,Netty 提供FileRegion封装,Nginx 一行配置就开启sendfile——本质上都是在减少 CPU 在数据搬运上的无谓消耗。

我个人在做实际项目时,一般按下边这个顺序思考:先看数据是否必要经过用户态。如果是“文件到网络”,闭上眼睛选sendfile;如果是“任意 fd 到任意 fd”,再考虑splice;如果既要跨平台又要简单,才退回mmap + write甚至read + write。最后想提醒一句,永远用压测数据说话,不要凭感觉优化。把strace、perf、top这三板斧用熟,再花不了多少时间就能把 I/O 路径里的每一个“搬运工”都揪出来。

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

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

立即咨询