bigywater.tech
Go · 92

io.Reader 与 io.Writer:组合、io.Copy 与零拷贝

io.Copy 看起来只是一个读写循环,实际上先看两端有没有实现 WriterTo / ReaderFrom。这篇用计数包装、分配统计和真实文件/TCP 拷贝验证它怎么选实现、缓冲区多大,以及隐藏接口会付出多少代价。

32768 / 8192
io.Copy 默认缓冲区大小,目标是 io.Discard 时变成 8192
57 vs 109 ms
256 MiB 从文件拷到本机 TCP 连接,用接口与隐藏接口
83 vs 79 ms
文件到文件,这台 macOS 上两种写法没有差别

缓冲区

实测 · 本机执行输出
  两边都只暴露 Read / Write 时,io.Copy 把 1048576 字节(1 MiB)拷过去:调用 Read 33 次,前 3 次传入的缓冲区长度 = [32768 32768 32768]
  目标换成 io.Discard(它自带 ReadFrom):Read 129 次,缓冲区长度 = [8192 8192 8192]
  io.CopyBuffer 传 4096 字节的缓冲区:Read 257 次,缓冲区长度 = [4096 4096 4096]
  源是 io.LimitReader:限 100 字节时每次 io.Copy 分配 200 B;限 1 MiB 时每次分配 32856 B(源码里会按 LimitedReader.N 缩小缓冲区)

两边都只有 Read/Write 时,io.Copy 用 32 KiB 缓冲区,1 MiB 要 33 次 Read;如果目标是 io.Discard,它自带 ReadFrom,变成 8 KiB 缓冲区,129 次。源是 io.LimitReader 时缓冲区会缩小到限制值,限 100 字节时每次 io.Copy 只分配 200 B。

挑实现的顺序

实测 · 本机执行输出
  源实现了 WriterTo:io.Copy 调用了 WriteTo 1 次,Read 0 次(WriteTo 里自己再 Copy 时会走 Read)
  目标实现了 ReaderFrom、源只有 Read:ReadFrom 1 次,Write 0 次
  两边都只暴露 Read / Write:ReadFrom 0 次,Write 32 次
  拷到 io.Discard:源是 *bytes.Reader(有 WriteTo)每次 1 次分配;把它包成只有 Read 的 struct 后每次 2 次分配

源有 WriterTo 就用 WriteTo;否则目标有 ReaderFrom 就用 ReadFrom;都没有才自己循环。给 *bytes.Reader 套一层 struct{io.Reader} 就把 WriteTo 藏起来了,分配也从 1 次变成 2 次。

这条路径值多少

实测 · 本机执行输出
  文件到文件 256 MiB:io.Copy(*os.File, *os.File) 中位 83 ms(3103 MiB/s);包一层隐藏 ReaderFrom/WriterTo 后 79 ms(3261 MiB/s)
  读 256 MiB 文件算 SHA-256:io.Copy(hash, file) 中位 127 ms;io.Copy(Discard, io.TeeReader(file, hash)) 中位 144 ms
  文件到本机 TCP 连接 256 MiB:io.Copy(*net.TCPConn, *os.File) 中位 57 ms(4506 MiB/s);隐藏接口后 109 ms(2357 MiB/s)

文件到本机 TCP,走 *net.TCPConn 的 ReadFrom 只要 57 ms(4506 MiB/s),包一层隐藏后 109 ms。文件到文件在这台 macOS 上两种写法都是约 80 ms,没有差别。第一次测的时候我没有交替运行,得到过 254 对 177 ms 的“差距”,那是顺序和缓存的影响;交替运行后消失了。TeeReader 多包一层来算 SHA-256,耗时多了约 13%。

读文档

实测 · 本机执行输出
  Copy copies from src to dst until either EOF is reached on src or an error occurs. It returns the number of bytes copied and the first error encountered while copying, if any. A successful Copy returns err == nil, not err == EOF. Because Copy is defined to read from src until EOF, it does not treat an EOF from Read as an error to be reported. If src implements WriterTo, the copy is implemented by calling src.WriteTo(dst). Otherwise, if dst implements ReaderFrom, the copy is implemented by calling dst.ReadFrom(src).

文档写明:src 有 WriterTo 就调用它,否则 dst 有 ReaderFrom 就调用它;成功时返回 err == nil 而不是 EOF。写自己的类型时,包装 reader/writer 要想想是否因此失去了这些快速路径。

分步演示

io.Reader 与 io.Writer
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Go 1.27.1(brew);用 go run / go build / go test 直接编译运行,需要第三方库时经代理从 proxy.golang.org 下载;计时用 time.Now / testing.B,数字每次运行会有小幅抖动。读调用次数与缓冲区长度由包装 Read 的计数器记录;分配用 testing.AllocsPerRun / Benchmark 的 B/op;文件与 TCP 实验用 256 MiB 临时文件(实验后已删除),本机回环 TCP;每种写法交替各 3 轮,每轮 5 次取中位数,再取 3 轮的中位数;读过的源码与文档:src/io/io.go 的 copyBuffer,go doc io.Copy
    • 没有验证:Linux 上的 splice/copy_file_range/sendfile 行为(macOS 上的结果不能直接套用)、磁盘缓存冷启动时的文件拷贝、io.Pipe、MultiWriter 的开销、ReadFrom 在不同 Go 版本里的实现差别
    京ICP备2026067502号-1京公网安备11010502063487号