← 返回信息流
AI 资讯Hacker News·3 小时前

Go零拷贝技术:sendfile、splice与io.Copy成本分析

原标题:Zero-copy in Go: sendfile, splice, and the cost of io.Copy

速览

Go语言零拷贝借助sendfile和splice系统调用,避免数据在用户态与内核态间多次复制。io.Copy虽然接口简单,但内部可能涉及额外拷贝,导致性能损失。文章通过基准测试对比不同方法的吞吐和CPU占用,为开发者优化数据传输提供参考。

AI 深度解读

背景

一位开发者的小型文件服务在某个下午突然变得极其缓慢,原因是一个看似“无害”的中间件改动。服务器 CPU 占用翻倍,吞吐量几乎减半。差异仅在一行代码:原本直接将 *os.File 传给 io.Copy,有人将其包裹在一个小型日志读取器中以统计字节数。这一层包裹悄悄关闭了 sendfile(2) 系统调用。

本文探讨这条快速路径:Go 运行时为你免费做了什么、如何观察它真正生效,以及那些令人意外地容易丢失优化的方式。

核心内容

实验环境

  • Linux 6.6 / Ubuntu 24.04 (WSL2),AMD Ryzen 5 9600X,16 GiB RAM
  • 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 的实际工作原理

正常的“发送此文件”流程如下:

disk -> page cache -> read() 到用户缓冲区 -> write() 到套接字缓冲区 -> NIC
                       ^ 复制 1                        ^ 复制 2

sendfile(2) 将这两次复制合并为一次内核内的传输:

disk -> page cache --(sendfile)--> 套接字缓冲区 -> NIC
                       ^ 无需用户空间往返

没有 read、没有 write,也没有 32 KiB 缓冲区在地址空间中反弹。内核直接将页面缓存页面拼接(splice)到套接字的发送队列中。对于套接字到套接字的转发,等价的是 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.goos/zero_copy_linux.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 的参数。

  • 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 什么都没做,它是最小示例,代表一段“只想统计字节数”或“只想注入追踪跨度”的中间件,任何看似无辜的理由都可能将 io.Reader 放在文件前面。对于类型系统,该值现在只是一个 io.Reader,运行时对 *os.File 的类型断言失败,优化消失。

io.LimitReader 看起来同样有包装,但运行时在放弃之前会显式检查 *io.LimitedReader,解开它,然后继续。因此它保留了快速路径。

三种处理器,底层发生了三种不同的事情。

用 strace 观察

对每种处理器执行 strace -c -e trace=read,write,sendfile,splice,发起五次 512 MiB 传输,查看摘要。

rawio.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         0 read
------ ----------- ----------- --------- --------- ----------------
100.00    0.232466          78      2972       861 total

2,958 次 sendfile 调用,7 次 read,7 次 writereadwrite 是 accept/setup 的通信,不是文件数据。860 个“错误”是 EAGAIN 返回,表示套接字缓冲区已满,运行时轮询器反弹回来,这在 sendfile 下是正常的。

wrappedio.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         0 read
------ ----------- ----------- --------- --------- ----------------
100.00    5.650692          43    131093         3 total

零次 sendfile。约 13.1 万次 readwrite 系统调用,全部是 32 KiB 块,数据通过用户空间缓冲区反弹。32 KiB 是 io.copyBuffer 的默认值。系统调用中的墙钟时间大约是快速路径的 24 倍。

对 wrapped 处理器在负载下的 CPU 分析显示调用链明显:在收集的 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 未能找到 *os.File 交给 sendfile,因此它将工作抛回给 io.copyBuffer,后者现在手动进行反弹。火焰图显示了每次反弹的每个周期去向。

limitio.Copy(conn, io.LimitReader(f, n))):

% time     seconds  usecs/call     calls    errors syscall
查看原文 →segflow.github.io