Go零拷贝:sendfile、splice与io.Copy的隐藏代价

一个"无害"的中间件改动让 Go 文件服务的 CPU 翻倍、吞吐量减半。本文深入解析 sendfile/splice 零拷贝机制、Go 运行时的自动优化路径,以及一个 io.Reader 包装器如何悄无声息地关闭它。
一个下午,某文件服务在"无害"的中间件改动后突然变慢:CPU 翻倍,吞吐量减半。罪魁祸首是一行代码——用一个小小的日志 Reader 包装了 *os.File,统计传输字节数。这一包装悄无声息地关闭了 Go 运行时的 sendfile(2) 快速路径。
什么是 sendfile
正常的"发送文件"流程涉及两次数据拷贝:先 read() 到用户空间缓冲,再 write() 到 socket 缓冲。sendfile(2) 系统调用将这两次拷贝合并为一次内核内传输——数据从页缓存直接送到 socket 发送队列,完全绕过用户空间。
对于 socket 到 socket 的转发(代理场景),对称的系统调用是 splice(2),它通过内核管道在两个 socket 间移动数据,同样不需要数据进入用户内存。

Go 运行时的自动优化
在 Go 中,你不需要手动调用 sendfile 或 splice。当你写下 io.Copy(conn, f) 时,运行时自动执行以下检测链:
// io.Copy 检查目标是否实现 io.ReaderFrom
// *net.TCPConn 实现了 ReadFrom
// ReadFrom 内部进一步检查源:
lr, ok := r.(*io.LimitedReader)
if ok { remain, r = lr.N, lr.R }
f, ok := r.(*os.File)
if !ok { return 0, nil, false } // 退回普通路径
// ... sendfile 循环 ...
两个类型断言加一个系统调用循环——这就是全部。运行时识别三种类型:*os.File、*io.LimitedReader(包装 *os.File)和 *net.TCPConn。任何其他类型都不透明。
三种写法,三种结果
以下三种写法服务同一个 512MB 文件,仅 io.Copy 的源不同:
// raw: 直接传 *os.File
io.Copy(conn, f)
// wrapped: 用自定义 Reader 隐藏 *os.File
type justReader struct{ r io.Reader }
func (j justReader) Read(p []byte) (int, error) { return j.r.Read(p) }
io.Copy(conn, justReader{r: f})
// limit: 用 *io.LimitedReader 包装
io.Copy(conn, io.LimitReader(f, fileSize))
用 strace -c 观察系统调用:
- raw:2,958 次 sendfile,7 次 read,7 次 write。内核直接传输,零用户空间拷贝。
- wrapped:零次 sendfile。约 131,000 次 read+write,全部是 32KB 块在用户空间缓冲间弹跳。系统调用总数是 raw 的 44 倍。
- limit:2,896 次 sendfile。与 raw 不可区分——
io.LimitReader是运行时唯一识别的"免费"包装器。

代价有多大
在回环测试中,三种写法的吞吐量看起来差不多(1.5-1.7 GiB/s),因为 TCP 缓冲吸收了大量差异。更诚实的指标是服务端 CPU 时间:
| 模式 | 10x512MiB CPU 时间 | 每 GiB CPU |
|---|---|---|
| raw | 0.27s | ~54ms |
| wrapped | 0.92s | ~184ms |
| limit | 0.30s | ~60ms |
wrapped 路径的 CPU 开销是 raw 的 3.4 倍。在本地测试中被吸收,但在真实网络或并发负载下会打满一个核心并恶化尾延迟。2,972 次系统调用 vs 131,093 次系统调用来传输同样的 2.5GiB——这就是"我只是想统计字节数"的代价。
splice 与代理
对于 socket 间转发,Go 运行时使用 splice(2)。一个 30 行的 TCP 代理:
ln, _ := net.Listen("tcp", ":9100")
for {
c, _ := ln.Accept()
go func(client net.Conn) {
defer client.Close()
server, _ := net.Dial("tcp", upstream)
defer server.Close()
go io.Copy(server, client)
io.Copy(client, server)
}(c)
}
strace 显示 10,677 次 splice 调用,零次 read/write 在数据路径上。代理从不触碰数据字节。同样的脆弱规则适用:在任何一端套自定义 Reader/Writer 就会退回到 32KB 弹跳。
五条经验法则
- 能不包装就不包装。每多一层 io.Reader 都多一次类型断言失败的风险。
- 必须包装时保留可选接口。实现
io.WriterTo(源端)或io.ReaderFrom(目标端)并转发,可以让 io.Copy 继续走快速路径。 io.LimitReader是免费的,其他包装器不是。- 相信 strace,不要靠直觉。sendfile 计数和零 write 意味着你在快速路径上;数十万 read+write 意味着你不在。
- 不要假设 sendfile 总是可达的。即使教科书式的
io.Copy(w, f),框架层面也可能让 sendfile 失效。
sendfile 和 splice 是老旧、无聊的内核 API,你几乎不需要手动调用它们。Go 运行时免费为你做了。诀窍是知道该放任什么不管,让这个"免费"持续有效。
评论 (0)
加载评论中…