返回

文章详情

Go中的零拷贝:sendfile、splice及io.Copy的成本

Hacker News2026年7月5日 20:24

我的一个小文件服务在一个下午由于一次“无害”的中间件更改而变得缓慢。服务器的CPU翻了一倍,吞吐量大约减半。差异只是一行代码:有人把一个 *os.File 封装在一个小的日志读取器中以计算字节。这个包实际上静默地关闭了sendfile(2)。这篇文章讲述了快速路径:Go为你免费做了什么,如何看到它真正工作,以及意外容易失去它的方法。 设置 Linux 6.6 / Ubuntu 24.04 (WSL2),AMD Ryzen 5 9600X,16 GiB RAM Go 1.22.12 512 MiB 随机字节文件,页面缓存预热。下面的每个基准都通过普通TCP向同一台机器上的Go客户端提供同一个 big.bin 文件。服务器固定在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 ^ 无用户空间往返。 没有读,没有写,没有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。大致内容是: // (简化版,位于 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 的内容。 // 原始:将 *os.File 直接传递给 io.Copy。 _ , _ = io.Copy(conn, f) // 包装:将 *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}) // 限制:用 *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的传输,并查看摘要。 原始 (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 2,958次sendfile调用,7次读取,7次写入。读取和写入是接受/设置的闲聊,而不是文件数据。860个“错误”是EAGAIN返回的情况,套接字缓冲区已满,运行时轮询器回弹,这是sendfile下的正常现象。 包装 (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。 ~131千次组合读取和写入系统调用,全部都是32 KiB的块通过用户空间缓冲区跳跃。这个32 KiB的数字是io.copyBuffer中的默认值。

赞助内容

NordVPN Next-gen Antivirus

本站免费、广告极少。如果觉得有帮助,可以请我们喝杯咖啡 —— 任何金额都对持续运营有实际帮助。

请我喝杯咖啡