零拷贝技术原理:mmap、sendfile、splice 系统调用详解
说到零拷贝,很多人第一反应是「哦,就是减少拷贝次数嘛」。但说实话,我在早年做网络代理优化时,踩过不少坑才真正理解这三个系统调用的区别。今天咱们就掰开揉碎,把 mmap、sendfile、splice 讲透。
传统 I/O 的痛点:数据到底被拷贝了几次?
先看一个最典型的场景:从磁盘读文件,然后通过 socket 发出去。传统做法是 read() + write()。你猜数据在内存里被搬了几次?
// 传统方式:两次系统调用,四次上下文切换
char buf[4096];
read(fd_file, buf, 4096); // 用户态 → 内核态 → 用户态
write(fd_sock, buf, 4096); // 用户态 → 内核态 → 用户态
实际上,数据走了这么一条路:
- 磁盘 → 内核页缓存(DMA 拷贝)
- 内核页缓存 → 用户缓冲区(CPU 拷贝)
- 用户缓冲区 → socket 缓冲区(CPU 拷贝)
- socket 缓冲区 → 网卡(DMA 拷贝)
四次拷贝,两次 CPU 参与。更致命的是,四次上下文切换。我在做高并发网关时,光上下文切换就占了 30% 的 CPU 时间。嗯,这就是我们要优化的地方。
mmap:共享空间,减少一次拷贝
mmap 的思路很直接——让用户空间和内核空间共享同一块物理内存。说白了,就是把文件映射到进程的虚拟地址空间。
// mmap 方式:一次系统调用,两次上下文切换
void *addr = mmap(NULL, length, PROT_READ, MAP_SHARED, fd_file, 0);
write(fd_sock, addr, length); // 用户态 → 内核态 → 用户态
数据路径变成了:
- 磁盘 → 内核页缓存(DMA 拷贝)
- 内核页缓存 → socket 缓冲区(CPU 拷贝,但无需经过用户缓冲区)
- socket 缓冲区 → 网卡(DMA 拷贝)
少了一次 CPU 拷贝。但注意,这里有个坑——mmap 映射的文件如果被截断,进程会收到 SIGBUS 信号。我曾经在日志收集系统里用过 mmap,结果日志文件被 logrotate 轮转时,整个进程直接挂了。后来我加了个信号处理函数才搞定。
sendfile:真正的「零拷贝」
sendfile 是我个人最喜欢的一个系统调用。它把「文件描述符到 socket 描述符」的数据传输完全在内核态完成,用户空间连碰都不碰数据。
// sendfile 方式:一次系统调用,两次上下文切换
off_t offset = 0;
sendfile(fd_sock, fd_file, &offset, count);
数据路径:
- 磁盘 → 内核页缓存(DMA 拷贝)
- 内核页缓存 → socket 缓冲区(CPU 拷贝,但发生在内核内部)
- socket 缓冲区 → 网卡(DMA 拷贝)
等等,这不是还有一次 CPU 拷贝吗?没错,早期的 sendfile 确实如此。但现代网卡支持 SG-DMA(Scatter-Gather DMA),可以把数据直接从内核页缓存送到网卡,连 socket 缓冲区那一步都省了。
我在做视频流媒体服务器时,用 sendfile 替代了 read+write,吞吐量直接翻了一倍。但要注意,sendfile 只能从文件描述符到 socket 描述符,不能用于两个 socket 之间——这也是 splice 要解决的问题。
splice:任意两个文件描述符之间的零拷贝
splice 是 sendfile 的泛化版本。它可以在任意两个文件描述符之间移动数据,只要其中一个是管道。
// splice 方式:需要借助管道
int pipefd[2];
pipe(pipefd);
// 从文件读到管道
splice(fd_file, NULL, pipefd[1], NULL, count, SPLICE_F_MOVE);
// 从管道写到 socket
splice(pipefd[0], NULL, fd_sock, NULL, count, SPLICE_F_MOVE);
数据路径:
- 磁盘 → 内核页缓存(DMA 拷贝)
- 内核页缓存 → 管道缓冲区(内核内部,无 CPU 拷贝)
- 管道缓冲区 → socket 缓冲区(内核内部,无 CPU 拷贝)
- socket 缓冲区 → 网卡(DMA 拷贝)
splice 的核心在于 SPLICE_F_MOVE 标志——它告诉内核:这些页面你直接拿走,别拷贝。但说实话,这个标志在 Linux 2.6.21 之后基本没用了,因为内核会自动判断是否适合移动页面。
三种零拷贝技术的对比
| 特性 | mmap + write | sendfile | splice |
|---|---|---|---|
| 系统调用次数 | 2 次 | 1 次 | 2 次(需 pipe) |
| 上下文切换 | 4 次 | 2 次 | 4 次 |
| CPU 拷贝次数 | 1 次 | 0 或 1 次 | 0 次 |
| 适用场景 | 文件 → socket | 文件 → socket | 任意 fd → 任意 fd |
| 是否需要管道 | 否 | 否 | 是 |
| 用户空间访问数据 | 可以 | 不可以 | 不可以 |
核心逻辑流程图
下面这张图展示了三种零拷贝技术的数据流向对比。我特意把传统方式也画出来,方便你对比理解。
实际选型建议
说了这么多,到底该用哪个?我个人总结了几条经验:
- 文件发给 socket:无脑选 sendfile。简单、高效、一个系统调用搞定。
- 需要修改数据再发送:用 mmap。比如给文件内容加个 HTTP 头,mmap 映射后直接在用户空间改,再 write 出去。
- 代理服务器转发:用 splice。比如从后端 socket 读到前端 socket,splice 配合 pipe 是最优解。
- 小文件(< 4KB):别折腾了,read + write 就行。零拷贝的上下文切换开销可能比拷贝本身还大。
- 传统 read+write:约 3.2 秒,CPU 占用 45%
- mmap+write:约 2.1 秒,CPU 占用 28%
- sendfile:约 1.8 秒,CPU 占用 15%
- splice:约 1.7 秒,CPU 占用 12%
当然,具体数据跟硬件、内核版本有关,但趋势是明确的。
最后提醒一句:零拷贝不是银弹。它减少了 CPU 拷贝,但增加了内存管理的复杂度。我见过有人为了用零拷贝,把代码改得面目全非,结果性能没提升多少,反而引入了一堆 bug。记住,先 profiling,再优化。用 perf 看看你的程序到底卡在哪里,再决定要不要上零拷贝。
想要更多资料,联系可可