
io.Copy 是不是真零拷贝
不是。io.Copy 本身不提供零拷贝,它只是高效地在两个 io.Reader 和 io.Writer 之间循环读写,默认使用 32KB 缓冲区。所谓“零拷贝”效果,依赖底层实现是否支持直接内存传递(如 splice 系统调用),而 Go 标准库的 io.Copy 在 Linux 上对支持 splice 的文件描述符(如管道、socket)会自动降级使用系统调用——但这需要双方都满足条件,且仅限于特定平台和类型。
- 只有当源是
*os.File且目标是*net.TCPConn(或反之),且运行在 Linux 内核 ≥2.6.17 时,io.Copy才可能触发splice -
net.Conn接口本身不暴露 fd,所以普通http.ResponseWriter或自定义io.Writer不会触发该优化 - 用
strace观察系统调用可验证:若看到大量read+write,说明没走splice;若出现splice,才算真正靠近零拷贝
什么时候该用 io.Copy 而不是自己 read/write 循环
绝大多数流量转发场景下,直接用 io.Copy 就够了——它已做了缓冲复用、错误分类、EOF 判断和边界处理。自己手写循环反而容易漏掉 corner case。
- 转发 HTTP 请求体(
req.Body→backendConn):用io.Copy(backendConn, req.Body),别自己开make([]byte, 4096) - 代理 WebSocket 数据帧(二进制流):只要两端都实现了
io.ReadWriter,io.Copy就适用 - 避免手动管理缓冲区大小:默认 32KB 平衡了内存占用与 syscall 频次;改用
io.CopyBuffer只在明确知道更小/更大缓冲更优时才值得 - 注意:如果需要修改数据(如加 header、解密),就不能直接
io.Copy,得用io.TeeReader或中间bytes.Buffer,但这就脱离零拷贝前提了
io.Copy 返回 EOF 但连接还没关怎么办
io.Copy 返回 io.EOF 表示读端自然结束(比如客户端关闭了写端),但它不负责关闭写端。这是常见误解点——很多人以为 copy 完就万事大吉,结果连接 hang 在半开状态。
- 典型现象:
io.Copy(dst, src)返回nil或io.EOF,但 dst 连接没关,对方收不到 FIN,TCP 连接持续 ESTABLISHED - 必须显式关闭写端:
dst.CloseWrite()(如果支持,如*net.TCPConn)或dst.Close()(如果只是单向流) - 更安全的做法是用
sync.WaitGroup启动两个io.Copy(正向 + 反向),并在两者都结束后统一关闭连接 - HTTP/1.1 中,如果后端返回
Connection: close,你也要主动关掉客户端连接,不能只等io.Copy返回
想真正零拷贝?绕不开 net.Conn 的 RawConn
Go 标准库不暴露 raw fd 给用户,但通过 net.Conn 的 SetDeadline 等方法可间接判断是否为底层 socket。真要压榨性能,得用 syscall.Syscall 或第三方封装(如 golang.org/x/sys/unix)调用 splice 或 sendfile。
立即学习“go语言免费学习笔记(深入)”;
-
net.Conn没有公开的 fd 获取接口,但tcpConn.(*net.TCPConn).SyscallConn()可拿到syscall.RawConn,再用Control方法执行底层操作 - 注意:
Control回调中不能阻塞,也不能调用任何 Go 运行时函数(包括println),否则会死锁 - 实践中,除非你在做高性能网关(QPS > 10w)、且 profiling 明确卡在 memcpy,否则不值得折腾——
io.Copy的 32KB 缓冲已足够快,且可移植 - 一个容易被忽略的细节:Linux 的
splice要求至少一端是 pipe,所以纯 socket-to-socket 仍需一次 kernel copy;真正零拷贝往往需要引入内存映射或 AF_XDP 等更底层方案










