别再 read 一遍 write 一遍了:文件传输的零拷贝,Linux 帮你省掉的那几个 GB
我敢打赌,各位写过文件上传下载、静态资源代理、日志转发、文件同步的兄弟们,代码里大概率都躺着一段这样的东西:
for {
n, err := src.Read(buf)
if err != nil { break }
dst.Write(buf[:n])
}
这段代码没有任何 bug,它也老老实实地完成了工作。但它偷偷让 CPU 干了四份不该干的活,而且数据在内核和用户态之间来回跑了四趟。文件越大,这笔账越贵——一个 10GB 的文件,能让你的机器白白烧掉几秒 CPU,还顺带把内存总线堵得水泄不通。
今天咱们就把这笔账算清楚,然后聊聊 Linux 怎么把这四份活砍到只剩一份。零拷贝(Zero-Copy)不是营销词,它是一个非常具体的内核机制。
一、先把账算明白:一次 write 到底拷了几次
假设你用用户态代码把磁盘文件送到网卡。传统路径是这样的:
- 第 1 次拷贝:磁盘 → 内核页缓存(Page Cache)。这是 DMA 拷贝,CPU 不参与搬运数据,只负责发指令。
- 第 2 次拷贝:页缓存 → 用户态缓冲区。CPU 亲自逐字节搬,这个最贵。
- 第 3 次拷贝:用户态缓冲区 → 页缓存。刚才读出来的数据原封不动又写回去,CPU 再搬一遍,纯粹冤。
- 第 4 次拷贝:页缓存 → 网卡的 DMA 缓冲区。还是 DMA,CPU 只发指令。
所以一共 4 次拷贝,其中 2 次是 CPU 搬数据、2 次是 DMA 搬数据。而且第 2、3 次拷贝意味着数据在用户态和内核态之间来回横跳了两次,缓存还白刷了两遍——你在用户态读过的那几十 KB 数据,几乎瞬间就被内核页缓存冲出去了。
零拷贝的目标很朴素:让数据尽可能只在内核态内部流动,别让 CPU 参与搬运。而且除了「少拷」,还有个隐藏福利:不再经过用户态,就意味着不再有用户态缓冲区,也就不再有「这份数据该放哪儿」这个恼人的问题——这是很多内存映射和零拷贝 bug 的根源。
二、sendfile:老牌零拷贝,简单粗暴
sendfile(out_fd, in_fd, offset, count) 是 Linux 2.0 时代就有的系统调用,它做的事情只有一个:把 in_fd 的内容搬到 out_fd,全程留在内核里。
/* C 伪代码 */
sendfile(client_fd, file_fd, &offset, file_size);
效果:4 次拷贝降到 3 次(省掉用户态那一来一回),CPU 只需要做少量页表操作,吞吐直接翻倍都不夸张。Nginx 的 sendfile on; 就是干这个的,配合 tcp_nopush on; 和 tcp_nodelay on;,静态文件服务的 P99 能砍掉一大截。
但 sendfile 有两个硬伤:
- 它只会「复制」,不会「转换」。如果中间要做 TLS 加密、HTTPS 响应、或者对内容做改写,sendfile 就用不上了,因为数据必须回到用户态被程序过一遍。这也是为什么很多站点开 HTTPS 之后 sendfile 的收益就缩水了——后面会说怎么绕过。
- 「零拷贝」其实不是零拷贝。sendfile 内部仍然从页缓存拷到了 socket 缓冲区,只是省掉了用户态那一段。名字骗人,别真信。
三、mmap:把文件直接映射进内存,省掉的那次拷贝更彻底
mmap 的思路是:把文件的某一段直接映射到进程的虚拟地址空间。于是「读文件」这个动作,从「把数据拷贝进缓冲区」变成了「让 CPU 直接访问这块内存」。
fd = open("big.bin", O_RDONLY);
addr = mmap(NULL, size, PROT_READ, MAP_PRIVATE, fd, 0);
/* 现在 addr 就是文件内容,直接当内存用 */
munmap(addr, size);
内核页缓存和进程虚拟地址空间从此共享同一份物理页,第 2、3 次拷贝直接消失了,只剩磁盘 → 页缓存 → 用户空间(映射)→ socket 这条更短的路。而且因为你访问的就是页缓存本身,文件改动还能被实时看到。
mmap 的代价也很实在:
- 对 SSD 之类的现代存储,mmap 相比 read 未必更快,甚至因为缺页中断(page fault)而更慢——你可能以为在顺序读,实际上在疯狂触发软缺页;
- 文件被 truncate 之后,访问已映射区域会直接 SIGBUS,进程当场暴毙,这个坑非常经典;
- 映射一个超大的文件会让虚拟内存暴涨,
vm.max_map_count限制你只能开有限数量的映射。
我的经验:mmap 适合「随机访问 + 大文件 + 多次复用」,read 适合「顺序流式 + 小文件 + 一次性」。拿 mmap 去处理用户上传的 multipart 流,基本是自己找罪受。
四、splice:管道里的零拷贝,Linux 独有的浪漫
splice() 专门解决「两个 pipe/file 之间搬数据」的场景,而且连页缓存都不用碰——它直接在页与页之间做引用转移(page stealing)。
/* 典型用法:把一个 socket 的数据零拷贝地送进另一个 socket */
splice(client_fd, NULL, pipe_w, NULL, len, SPLICE_F_MOVE);
splice(pipe_r, NULL, backend_fd, NULL, len, SPLICE_F_MOVE);
这招最经典的用途就是 零拷贝代理:Nginx 的 proxy_pass 在 Linux 上底层就会走这套路径,所以用 Nginx 做 TCP 转发(数据库代理、协议网关)时,性能可以接近裸转发。而 tee() 更狠,能在转发的同时把数据复制一份给第三方(比如旁路抓包、流量镜像)。
代价是:splice 只能配 pipe 使用,写起来很别扭;而且一旦数据离开内核(比如要做 TLS),又得老老实实退回 read/write。所以 Nginx 处理 HTTPS 请求时,零拷贝基本失效。
五、Go 里到底用哪个?
好消息是,Go 标准库帮你把大部分决策藏起来了。
import (
"io"
"net/http"
"os"
)
func serve(w http.ResponseWriter, r *http.Request) {
f, _ := os.Open("/data/movie.mp4")
defer f.Close()
/* 关键点:io.Copy 会自动探测底层能力 */
io.Copy(w, f)
}
io.Copy 内部会先问一句:源是不是实现了 io.WriterTo?目标是是不是实现了 io.ReaderFrom?
*net.TCPConn实现了ReadFrom,在 Linux 上它会尝试 sendfile,失败则回落到通用路径;- Go 1.22 之后,
os.File在目标支持时也会走copy_file_range(内核 4.5+)——文件到文件的服务器端拷贝,这个在备份和同步场景里非常香; - 所以
io.Copy基本就是「能零拷贝就零拷贝,不能也不报错」的自动挡。
但如果你想显式控制,就得知道它有两条平行的路:
/* 传统路径:数据进用户态 */
io.CopyBuffer(dst, src, make([]byte, 32*1024))
/* 显式零拷贝路径:绕过用户态 */
linux.Sendfile(dstFd, srcFd, offset, count)
Go 里还有两个第三方库值得知道:github.com/panjf2000/gnet 和 github.com/valyala/fasthttp 都做了 sendfile 支持;而 fasthttp 的 FS 处理器在 Linux 上会真的调用 sendfile。
六、必须吐槽的几件事
第一,TLS 把零拷贝打得几乎体无完肤。数据要从内核出来,加密完再回去。业界真正的解法是 kTLS(Kernel TLS):把对称加密直接下放到内核,密钥通过 SSL_KEYS 结构交给内核,让数据在页缓存里就完成加密。这样 sendfile 就能重新跑起来。但 kTLS 只支持部分密码套件和 TLS 1.3,需要较新的内核和 OpenSSL 3.x,兼容性劝退一大半人。
第二,零拷贝不等于高性能。如果你的瓶颈在磁盘 IO 或网络带宽,零拷贝省的那点 CPU 完全无关痛痒;反过来,如果你在做一个内存数据库转发、或者高频小文件服务,那 CPU 搬运就是主要矛盾,零拷贝能带来实打实的倍数提升。先定位瓶颈,再选技术——这句话被说烂了,但它确实能省掉你无数个通宵。
第三,「零拷贝」这个词被滥用得太厉害。sendfile 省了一次拷贝,mmap 两次,splice 名副其实。我见过太多方案文档写「采用零拷贝架构」,实际也就是在 Java 里用了 FileChannel.transferTo,而那个方法在 Linux 上就是调 sendfile。行吧,也算零拷贝,没毛病。
七、一条实用的落地路线
如果你想在自己服务里吃到零拷贝的红利,顺序建议是这样的:
- 先测。用
perf stat看上下文切换次数(context-switches),如果这个数字很高,说明你确实在数据搬运上浪费 CPU; - 静态文件走 Nginx 或 Go 的
io.Copy。零成本,收益立竿见影; - 大文件分发考虑
copy_file_range或 mmap,尤其是文件到文件的备份场景; - 不要为了零拷贝去重构业务代码。这是最容易被带偏的地方——大多数系统里,你把
read+write换成io.Copy就够了,剩下 90% 的性能问题在数据库和网络设计上,跟拷贝没关系。
最后一句实在话:零拷贝是个好东西,但它属于「系统工程师的最后 10% 优化」。前面 90% 的性能来自架构合理、SQL 走对索引、缓存设计得当、以及——最关键的——你没有在半夜重试一个会雪崩的下游调用。优化顺序搞反的人,通常不是不聪明,只是太喜欢在错的地方使劲。
代码审查时如果看到同事在 for 循环里手动 read/write,别急着帮他写 sendfile。先看看他的场景到底需不需要——很多时候,那个循环本身才是该被拆掉的那个东西。