切换导航Segflow 提供了如下导航链接主页、文章、简历。Go 语言中的零拷贝sendfile、splice 以及 io.Copy 的代价有一天下午一个小型文件服务在进行一次“无害”的中间件更改后速度变得极其缓慢服务器 CPU 使用率翻倍吞吐量几乎减半。差异仅仅是一行代码有人没有直接将 *os.File 传递给 io.Copy而是将其包装在一个小型的日志读取器中以统计字节数。这一次包装悄然禁用了 sendfile(2)。本文将探讨这条快速路径Go 为你免费提供了什么如何观察到它实际生效以及出人意料地容易失去它的方式。环境设置实验环境为 Linux 6.6 / Ubuntu 24.04WSL2AMD Ryzen 5 9600X16 GiB 内存Go 1.22.12512 MiB 的随机字节文件页面缓存预热。每个基准测试都是通过普通 TCP 将同一个 big.bin 文件发送到同一台机器上的 Go 客户端。服务器固定在 CPU 0 上运行客户端固定在 CPU 1 上运行用 /usr/bin/time 进行测量保证数据可比性。系统调用计数来自于普通的 strace -c -e traceread,write,sendfile,splice 命令。sendfile 的实际作用正常的“发送文件”流程是磁盘 - 页面缓存 - 通过 read() 读入用户缓冲区 - 通过 write() 写入套接字缓冲区 - 网卡存在两次复制。而 sendfile(2) 将这两次复制合并为一次内核内传输即磁盘 - 页面缓存 --(sendfile)-- 套接字缓冲区 - 网卡无需用户空间往返。对于套接字到套接字的转发等效操作是 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 中。三种处理方式同一个文件比较三种读取器形式它们都通过普通 TCP 发送同一个 512 MiB 的文件区别在于传递给 io.Copy 的参数。原始方式是直接将 *os.File 传递给 io.Copy包装方式是将 *os.File 隐藏在一个 “仅为 io.Reader” 的结构体后面限制方式是使用 *io.LimitedReader 进行包装。justReader 会使运行时对 *os.File 的类型切换失败优化失效而 io.LimitReader 运行时会解包继续处理保留快速路径。使用 strace 进行观察在 strace -c -e traceread,write,sendfile,splice 下运行每个处理程序进行五次 512 MiB 的传输后查看统计结果。原始方式 (io.Copy(conn, f))有 2958 次 sendfile 调用7 次 read 调用7 次 write 调用。read 和 write 调用用于接受连接和设置的通信860 次“错误”是 EAGAIN 返回使用 sendfile 时属正常现象。包装方式 (io.Copy(conn, justReader{f}))没有 sendfile 调用约 131000 次 read 和 write 系统调用组合将 32 KiB 数据块通过用户空间缓冲区传输。系统调用实际时间约是快速路径的 24 倍。负载下包装处理程序的 CPU 分析结果显示1670 个 CPU 样本中有 1362 个约 82%位于系统调用中嵌套的 io.copyBuffer 是关键特征火焰图展示了每次数据传输的时间消耗情况。限制方式 (io.Copy(conn, io.LimitReader(f, n)))与原始方式几乎无区别有 2896 次 sendfile 调用。使用 LimitReader 包装无额外开销运行时识别这种特定类型。代价分析回环吞吐量易产生误导更能反映实际情况的指标是服务器传输文件所消耗的 CPU 时间。包装方式每字节所消耗的 CPU 时间约是原始方式的 3.4 倍在真实网络环境或并发负载下会使 CPU 核心满负荷导致尾部延迟大幅增加。从服务器每消耗一秒 CPU 时间所传输的有效负载MiB衡量差距更明显。套接字到套接字的 splicesendfile 用于文件到套接字的传输对称的套接字到套接字传输是代理常见场景运行时会使用 splice(2)。一个 30 行的 TCP 代理示例中两个 io.Copy 调用在 *net.TCPConn 之间进行。使用 strace 跟踪一次通过代理的传输有 10677 次 splice 调用数据路径上无 read 或 write 调用。同样若在任何一端使用自定义的 io.Reader 或 io.Writer 进行包装会退回到 32 KiB 的数据传输方式。经验法则从文件服务器经历总结出以下几点尽量避免包装文件和连接之间的每一层 io.Reader 都可能使你失去快速路径如果必须包装保留可选接口实现 io.WriterTo 或 io.ReaderFrom 接口并转发的包装器可让 io.Copy 继续使用快速路径io.LimitReader 无额外开销其他包装器可能有是范围响应首选包装方式相信 strace -c而非直觉两次 sendfile 调用和零次 write 调用意味着使用快速路径数十万次 readwrite 调用组合则未使用不要假设 sendfile 总是可用标准的 io.Copy(w, f) 针对 http.ResponseWriter 调用可能因与代码无关的原因绕过 sendfile。sendfile 和 splice 是古老而实用的内核 API在 Go 中运行时大多会免费完成这些操作关键是知道哪些部分不需要改动以确保优化持续生效。当 strace 显示大量 read/write 调用时原因大多是添加了看似无害的包装器。当 sendfile 无法满足需求需将文件映射到自己的地址空间让内核无需复制数据时就需使用用户空间的方法这是另一个话题。