缓存时间:
2026/07/05 22:34
# Go 中的零拷贝:sendfile、splice 以及 io.Copy 的代价
来源:https://segflow.github.io/post/zero-copy-sendfile-splice/
我一个小小的文件服务,在一次“无害”的中间件改动后,一天下午突然变得慢如蜗牛。服务器的 CPU 翻了一番,吞吐量几乎减半。差异只有一行代码:原来直接传递给 `io.Copy` 的 `*os.File`,被人用一个小型的日志读取器包了起来,用来统计字节数。
就是这么一个封装,悄无声息地关闭了 `sendfile(2)`。
这篇文章要讲的就是这条高速路径:Go 免费为你做了什么,如何观察到它实际生效,以及那些让你意外丢失它的、看似简单的方式。
## 环境搭建
- Linux 6.6 / Ubuntu 24.04 (WSL2),AMD Ryzen 5 9600X,16 GiB 内存
- Go 1.22.12
- 512 MiB 随机字节文件,页面缓存已预热
下面每个基准测试都在同一台机器上,通过纯 TCP 将同一个 `big.bin` 文件提供给 Go 客户端。服务器固定在 CPU 0 上,客户端固定在 CPU 1 上,这样我们可以在服务器端读取 `/usr/bin/time`,并进行同口径比较。系统调用计数来自一个普通的 `strace -c -e trace=read,write,sendfile,splice`。
## `sendfile` 的实际工作原理
一个常规的“发送此文件”流程如下所示:
```
磁盘 -> 页缓存 -> read() 到用户缓冲区 -> write() 到套接字缓冲区 -> NIC
^ 复制 1 ^ 复制 2
```
`sendfile(2)` 将这两个复制合并成一次在内核中传输:
```
磁盘 -> 页缓存 --(sendfile)--> 套接字缓冲区 -> NIC
^ 无需用户空间往返
```
没有 `read`,没有 `write`,没有 32 KiB 的缓冲区在你的地址空间中来回跳跃。内核只是将页缓存中的页面直接拼接到套接字的发送队列中。对于套接字到套接字的转发,对应的操作是 `splice(2)`,它通过内核管道移动字节,而无需在用户内存中实例化它们。
在 Go 中,你不需要直接调用这两个函数中的任何一个。标准库会在可能的时候替你完成。
## 快速路径
Go 运行时为 `*net.TCPConn` 提供了一个 `ReadFrom` 方法。当你写下
```
io.Copy(conn, f)
```
时,`io.Copy` 会检查目标是否实现了 `io.ReaderFrom`。`*net.TCPConn` 实现了,因此调用会被分派到它的 `ReadFrom` 方法。该方法的第一项工作是查看源:它是不是一个 `*os.File`?它是不是一个包装了 `*os.File` 的 `*io.LimitedReader`?如果是,它会调用 `internal/poll.SendFile`,该函数会循环执行 `sendfile(2)` 直到文件被耗尽。
整个检测链条位于两个文件中:`net/sendfile_linux.go` 和 `os/zero_copy_linux.go`。大致如下:
```go
// (简化版,位于 net/sendfile_linux.go 中)
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 循环 ...
```
两次类型断言和一个系统调用循环。就是这么简单。
## 三种处理器,一个文件
下面是我要比较的三种读取器形状。它们都通过纯 TCP 提供同一个 512 MiB 的文件。唯一的区别是传递给 `io.Copy` 的内容。
```go
// raw: 直接将 *os.File 交给 io.Copy。
_, _ = io.Copy(conn, f)
// wrapped: 将 *os.File 隐藏在一个 "只是 io.Reader" 的结构体后面。
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))
```
`justReader` 不做任何事情。它是一个最简例子,代表那种“只是想统计字节数”或“只是想注入一个追踪 span”的中间件,或者其他任何出于无辜理由在文件前面加一个 `io.Reader` 的情况。从类型系统的角度来看,这个值现在就是一个 `io.Reader`,没有别的。运行时的类型 switch 在 `*os.File` 上失败,优化就此消失。
`io.LimitReader` 看起来也同样像是包装器,但运行时在放弃之前会显式检查 `*io.LimitedReader`,解包它,然后继续。因此它保留了快速路径。
三种处理器,底层发生着三种不同的事情。
## 用 strace 观察
在每个处理器下运行 `strace -c -e trace=read,write,sendfile,splice`,发起五次 512 MiB 传输,然后查看摘要。
**`raw` (`io.Copy(conn, f)`):**
```
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
99.79 0.231981 78 2958 860 sendfile
0.15 0.000359 51 7 1 write
0.05 0.000126 18 7 read
------ ----------- ----------- --------- --------- ----------------
100.00 0.232466 78 2972 861 total
```
2958 次 `sendfile` 调用,7 次 `read`,7 次 `write`。`read` 和 `write` 是 accept/setup 的通信,不是文件数据。860 个“errors”是套接字缓冲区满时的 `EAGAIN` 返回,运行时的轮询器会重试,这在 sendfile 下是正常的。
**`wrapped` (`io.Copy(conn, justReader{f})`):**
```
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
56.67 3.202339 48 65546 3 write
43.33 2.448353 37 65547 read
------ ----------- ----------- --------- --------- ----------------
100.00 5.650692 43 131093 3 total
```
零次 `sendfile`。大约 13 万次 `read` 和 `write` 系统调用,全部是 32 KiB 的块,通过用户空间缓冲区来回反弹数据。32 KiB 这个数字是 `io.copyBuffer` 中的默认值。系统调用中花费的墙上时间大约是快速路径的 **24 倍**。
负载下 wrapped 处理器的 CPU 分析可以清晰地展示调用链:
CPU 火焰图:wrapped 读取器在用户空间复制上花费时间
在运行期间收集的 1,670 个 CPU 样本中,**1,362 个 (~82%) 位于系统调用内部**:761 个在 `syscall.Write` 写入套接字,522 个在 `syscall.Read` 从文件中拉取下一个 32 KiB。每个热点栈顶部的帧序列是 `io.Copy -> io.copyBuffer -> (*TCPConn).ReadFrom -> readFrom -> io.Copy -> io.copyBuffer -> ...`。这个嵌套的 `io.copyBuffer` 是标志:`*TCPConn.readFrom` 找不到可以交给 `sendfile` 的 `*os.File`,所以它将工作抛回给 `io.copyBuffer`,后者现在手动进行反弹。火焰图显示了每次反弹的每一个周期去了哪里。
**`limit` (`io.Copy(conn, io.LimitReader(f, n))`):**
```
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
99.84 0.239191 82 2896 893 sendfile
0.14 0.000330 47 7 1 write
0.02 0.000047 6 7 read
```
与 `raw` 无法区分。2896 次 `sendfile`。LimitReader 包裹是免费的,因为运行时认识那个特定的类型。
## 这要付出什么代价
回环吞吐量是一个误导性的数字:接收器和 TCP 缓冲区吸收了大部分,因此三种模式下的墙上时钟看起来相似(每个请求 1.5 - 1.7 GiB/s)。一个更诚实的衡量标准是**服务器自身的 CPU 时间**来发送文件:
| 模式 | 服务器 user+sys CPU (10 x 512 MiB) | 每 GiB CPU |
|------|-----------------------------------|------------|
| raw | 0.27 s | ~54 ms |
| wrapped | 0.92 s | ~184 ms |
| limit | 0.30 s | ~60 ms |
wrapped 路径每字节消耗的 CPU 大约是其 **3.4 倍**。在本地主机上可能不明显;但在真实网络或并发负载下,它会占满一个核心并拖垮尾部延迟。传输同样的 2.5 GiB 数据,2972 次系统调用对比 131093 次系统调用,就是说出“我只想要一个 `io.Reader`”这句话的代价。
换一个角度,以每消耗一秒钟服务器 CPU 所传输的 MiB 有效载荷来看,差距更加明显:
吞吐量:原始 sendfile vs wrapped vs 手动复制(越高越好)
## 用于套接字到套接字的 `splice`
`sendfile` 是文件到套接字。对称的情况是套接字到套接字,这是代理的主要工作。这里运行时转而使用 `splice(2)`。一个 30 行的 TCP 代理:
```go
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)
}
```
两个 `io.Copy` 调用,都在 `*net.TCPConn` 之间。通过 strace 观察一次代理的传输:
```
% time seconds usecs/call calls errors syscall
------ ----------- ----------- --------- --------- ----------------
99.98 0.754999 70 10677 481 splice
0.01 0.000109 13 8 read
0.01 0.000065 65 1 write
```
10677 次 `splice` 调用。数据路径上零次 `read` 或 `write`。代理从未接触这些字节。`(*TCPConn).readFrom` 查看源,发现另一个 `*TCPConn`,然后分派到 `spliceFrom`。同样的脆弱性规则也适用:将任何一端包裹在你自己的 `io.Reader` 或 `io.Writer` 中,你就会回退到 32 KiB 的反弹。
## 经验法则
从引发这一切的文件服务器中,我获得了几点体会:
**如果可以避免,就不要包装。**文件和连接之间的每一层 `io.Reader` 都是一个让类型断言失效,从而退出快速路径的机会。运行时只识别 `*os.File`、`*io.LimitedReader` 以及少数 `*net.TCPConn` 形状的类型。其他任何东西都是不透明的。
**如果必须包装,请保留可选接口。**一个实现了 `io.WriterTo`(当包装源时)或 `io.ReaderFrom`(当包装目标时)并向下转发的包装器,可以保持 `io.Copy` 一路分派到底层。正确实现有点繁琐,但标准库经常这样做。
**`io.LimitReader` 是免费的,其他任何东西都不是。**对于范围响应,这应该是你优先使用的包装器。这正是 `http.ServeContent` 如何在 `Range` 请求中保持 `sendfile` 活跃的方式。
**相信 `strace -c`,而不是你的直觉。**两个 sendfile 计数和零次 `write` 意味着你走的是快速路径。几十万个 `read+write` 对意味着你不是。没有中间地带。
**不要假设 sendfile 总是可达的。**另一个我会留到另一篇文章再讲的意外:即使是教科书般的 `io.Copy(w, f)` 配合 `http.ResponseWriter`,也可能会因为与你的代码无关的原因绕开 `sendfile`。原理相同,但触发点隐藏在框架中,而不是处理器中。
`sendfile` 和 `splice` 是古老、无聊的内核 API,你在 Go 中几乎不需要主动去使用它们。运行时大多数情况下会免费为你完成。诀窍在于知道要留下什么不变,才能让它继续保持这样。当 `strace` 显示在你期望零拷贝的地方出现了 read/write 泛滥时,原因几乎总是某个出于最好理由而添加的善意包装器。
而当 sendfile 都不够用的时候,当你想要将文件映射到你的地址空间,这样内核根本不需要再复制它时,你就得进入用户空间了。但那又是另一个故事了。