大家好,我是内核喵。今天我们来死磕一个老生常谈、但绝大多数后端开发者依然只知其名不知其内核细节的硬核主题——Linux 零拷贝(Zero-Copy)。
很多同学简历上动不动就写“精通零拷贝”,一追问 sendfile 和 splice 的实现差异,或者问一句 mmap 崩掉怎么排查,往往就支支吾吾。今天我不讲空话,直接从内核缓冲区、页缓存(Page Cache)流转、系统调用链路切入,带大家看看从传统 I/O 到现代高性能后端的完整演进,并聊聊我踩过的那些深坑。
1. 传统 I/O:为什么它慢得像蜗牛?
回顾最基础的文件下发服务:
read(file_fd, buf, 4096);
write(socket_fd, buf, 4096);
用 strace 看这一对系统调用,平平无奇:
read(3, "GIF89a...", 4096) = 4096
write(4, "GIF89a...", 4096) = 4096
但在内核底层,这一轮操作发生了 4 次上下文切换 和 4 次数据拷贝:
- DMA 拷贝 1:磁盘 -> 内核页缓存(Page Cache)。
- CPU 拷贝 1:Page Cache -> 用户空间缓冲区
buf(触发上下文切换,陷入内核再返回)。 - CPU 拷贝 2:用户空间
buf-> 套接字内核缓冲区(Socket Buffer,sk_buff)。 - DMA 拷贝 2:Socket Buffer -> 网卡缓冲区(NIC Buffer)。
跑个 perf top -g,你会发现大量的 CPU 时钟周期都浪费在了 copy_user_enhanced_fast_string 或 __memcpy_chk 这类内存搬砖操作上:
Samples: 20K of event 'cycles', 4000 Hz, Event count (approx.): 5192842100
Overhead Shared Object Symbol
+ 38.42% [kernel] [k] copy_user_enhanced_fast_string
+ 12.18% libc.so.6 [.] __memcpy_chk
在 10GbE 或更高带宽的场景下,CPU 光搬运内存就能把核打满,网络吞吐根本上不去。
2. 第一代优化:mmap + write
为了干掉那次用户态与内核态之间冗余的拷贝,最先登场的是 mmap。
void *addr = mmap(NULL, file_size, PROT_READ, MAP_SHARED, file_fd, 0);
write(socket_fd, addr, file_size);
munmap(addr, file_size);
内核逻辑
mmap() 直接将文件映射到进程的虚拟地址空间(VMA),底层共享同一份 Page Cache。
- 拷贝次数:3 次(2 次 DMA,1 次 CPU 拷贝:Page Cache -> Socket Buffer)。
- 上下文切换:4 次(
mmap1 次,write2 次,munmap1 次)。
避坑指南:致命的 SIGBUS
当年有同事在生产环境用 mmap 做静态文件加速,进程偶尔会莫名其妙崩溃,甚至没有 core dump(配置问题)。我拿 strace 一跟,满屏的:
--- SIGBUS (Bus error) @ 0 (0) ---
+++ killed by SIGBUS +++
原因分析:
当你的进程刚 mmap() 了一个 10MB 的文件,另一个线程或外部进程执行了 truncate 或 ftruncate 把它缩减到了 1KB。此时你调用 write(socket_fd, addr, 10MB),访问超出截断后偏移量的内存区域,MMU 无法完成缺页异常转换,内核直接发 SIGBUS 信号将进程干掉。
解法:
- 注册
SIGBUS的信号处理函数,直接忽略或安全退出。 - 使用文件租约(File Leasing,
fcntl(fd, F_SETLEASE, ...)),在其他进程试图修改文件前收到SIGIO信号抢先处理。 - 但这太反人类了,维护成本极高,所以纯下发场景基本不再推荐
mmap。
3. 第二代优化:sendfile 的进化与真正的“零拷贝”
Linux 2.1 引入了 sendfile,将读写合二为一:
#include <sys/sendfile.h>
ssize_t sendfile(int out_fd, int in_fd, off_t *offset, size_t count);
内核演进与 SG-DMA
- 早期 sendfile:仍然有 1 次 CPU 拷贝(Page Cache -> Socket Buffer)。此时上下文切换直接降为 2 次。
- 支持 Scatter-Gather DMA(SG-DMA)的现代体系: 从 Linux 2.4 开始,只要网卡支持硬件校验和与分片聚簇(Scatter-Gather),Socket Buffer 就不再拷贝 Page Cache 的数据内容,而仅仅拷贝文件描述符、内存地址和长度等描述元数据(Buffer Descriptor)。
此时的拷贝链路变成了:
- 磁盘 -> Page Cache(DMA 拷贝)。
- Page Cache 的物理页地址 -> Socket Buffer(CPU 仅写入少量指针描述信息,代价极小,忽略不计)。
- Page Cache 直接由网卡 DMA 引擎抓取 -> 网卡(DMA 拷贝)。
真正意义上的 0 次 CPU 拷贝,2 次上下文切换 达成!
局限性
sendfile() 的 out_fd 早期只能是 socket,不能做数据加工(例如你想要做动态对称加密 AES-GCM,或者打数据压缩包,sendfile 就歇菜了)。
4. 第三代通用流转:splice 与管道的黑魔法
Linux 2.6.17 引入了 splice。它的目标是:在任意两个文件描述符之间转移数据,且全程不经由用户态(前提是其中至少有一个是 pipe)。
#define _GNU_SOURCE
#include <fcntl.h>
ssize_t splice(int fd_in, loff_t *off_in, int fd_out, loff_t *off_out, size_t len, unsigned int flags);
如果我们想从 file_fd 零拷贝到 socket_fd,可以借由内核管道中转:
int pfd[2];
pipe(pfd); // 建立管道
// 1. file -> pipe (只传 struct pipe_buffer 页面引用,不拷贝数据)
splice(file_fd, NULL, pfd[1], NULL, 4096, SPLICE_F_MOVE | SPLICE_F_MORE);
// 2. pipe -> socket
splice(pfd[0], NULL, socket_fd, NULL, 4096, SPLICE_F_MOVE | SPLICE_F_MORE);
内核实现刨根问底
翻看内核 fs/splice.c 源码,你会发现 splice 的核心是 struct pipe_buffer:
struct pipe_buffer {
struct page *page; // 指向真实的 Page Cache 物理页
unsigned int offset;
unsigned int len;
const struct pipe_buf_operations *ops;
unsigned int flags;
struct page_cache_entry *cache;
};
splice() 在把文件数据传给 pipe 时,根本没有把磁盘数据往管道里灌,而是直接把 Page Cache 物理页的指针 struct page *page 填进了 pipe_buffer,并增加页的引用计数(get_page)。后续写网卡时,网卡 DMA 直接从该 page 读数据。
这就是纯粹的页指针交接,彻底解除了 sendfile 对源/目的类型的强限制。
避坑指南:管道容量与死锁
在极端高并发网络吞吐下,如果你使用 splice 且 pipe 没设置 O_NONBLOCK:
- 管道默认大小在 Linux 上通常是 65536 字节(16 个 pages)。
- 如果你的
flags没配好,网络对端滑动窗口缩死导致 socket 缓冲区满,第二个splice会阻塞;而前端由于管道满也在阻塞,极易引发应用层工作线程雪崩。 - 务必使用非阻塞管道,并处理
EAGAIN错误。
5. 各零拷贝方案全维度对比
这里整理一份系统层面对比表格,建议直接截图保存:
| 机制 | 拷贝次数 (DMA + CPU) | 上下文切换 | 优势 | 劣势 / 坑点 |
|---|---|---|---|---|
| 传统 read/write | 2 + 2 | 4 次 | 极其通用,可任意修改缓冲区 | CPU 拷贝打满,性能最低 |
| mmap + write | 2 + 1 | 4 次 | 适合多线程共享只读大文件 | 并发 truncate 引发 SIGBUS 崩溃 |
| sendfile (SG-DMA) | 2 + 0 | 2 次 | 极致性能,开箱即用(Nginx 最爱) | 不可修改数据内容,源与目的受限 |
| splice | 2 + 0 | 2 次(单次流转) | 通用性强,解耦源与目标 | 必须借由 pipe 中转,存在管道死锁风险 |
6. 现代演进:io_uring 治愈一切
虽然 splice 已经将 CPU 拷贝砍掉了,但系统调用本身的上下文切换成本(Meltdown/Spectre 补丁引入的 KPTI 更加剧了这一开销)依然存在。
现代高性能后端(如 Rust 的 Tokio、C++ 的 Seastar)正在向 Linux 5.x 的 io_uring 靠拢:
- SQE / CQE 环形缓冲区:用户态和内核态共享内存,连进入内核的上下文切换都省了。
IORING_OP_SPLICE:异步的 splice,不需要再写复杂的 epoll 驱动非阻塞状态机。IORING_REGISTER_BUFFERS(Fixed Buffers):预先将用户空间内存页pin在内核中,彻底避免运行时页表映射开销。
零拷贝的演化史,本质就是一部**“如何把数据留在原地,只传递指针与控制信息”**的减法史。下次再搞文件分发或流媒体代理,别上来就手写一个 4KB 缓冲区的 read/write 循环了——用好内核为你准备的快车道,性能提升往往是立竿见影的。
许可协议:CC BY-NC 4.0
更新于 12 小时前
觉得文章有帮助?点个赞吧!
0 条评论


