io.copy双向转发卡死是因为其阻塞特性导致两端互等eof;需用errgroup.group协调、显式closewrite()通知对端写结束,并注意windows兼容性及缓冲区调优。

为什么 io.Copy 直接用在双向转发里会卡死
因为 io.Copy 是阻塞的,一端不关闭,另一端就永远等不到 EOF;而 TCP 连接两端通常不会主动关写入流(比如 HTTP 客户端发完请求就等着响应,不关连接),导致两个 io.Copy 互相等待,程序挂住。
常见现象:net.Conn 建立后,数据只单向流动,或者完全没反应,go run 卡在那不动。
- 别写
go io.Copy(dst, src)+go io.Copy(src, dst)就完事 —— 没关连接、没处理错误、没设超时,99% 会出问题 - 必须显式控制读写生命周期,至少一方写完要关写入端(
conn.CloseWrite()),否则对端io.Copy永远不返回 - 如果底层协议是全双工(如 SSH、Raw TCP),得靠 goroutine +
io.Copy配合sync.WaitGroup或errgroup.Group协调退出
用 errgroup.Group 启动两个 io.Copy 并正确退出
errgroup.Group 能统一收集两个方向的错误,并在任一出错时取消另一个,避免 goroutine 泄漏。比裸写 sync.WaitGroup 更安全。
使用场景:需要稳定转发任意 TCP 流量(如调试代理、内网穿透中继)。
立即学习“go语言免费学习笔记(深入)”;
- 导入:
golang.org/x/sync/errgroup - 转发前先调用
conn.SetDeadline或SetReadDeadline/SetWriteDeadline,防止某端卡死拖垮整个连接 - 务必在
io.Copy后调用dst.CloseWrite()(如果 dst 是net.Conn),告诉对端“我写完了”,否则对方可能一直等后续数据 - 示例关键片段:
g, _ := errgroup.WithContext(ctx) g.Go(func() error { _, err := io.Copy(remoteConn, localConn) if err != nil && !errors.Is(err, io.EOF) && !strings.Contains(err.Error(), "use of closed network connection") { return fmt.Errorf("copy to remote: %w", err) } // 写完了,关本地连接的写入端,让 remoteConn 知道该结束了 if wc, ok := localConn.(interface{ CloseWrite() error }); ok { wc.CloseWrite() } return nil }) g.Go(func() error { _, err := io.Copy(localConn, remoteConn) if err != nil && !errors.Is(err, io.EOF) && !strings.Contains(err.Error(), "use of closed network connection") { return fmt.Errorf("copy from remote: %w", err) } if wc, ok := remoteConn.(interface{ CloseWrite() error }); ok { wc.CloseWrite() } return nil }) _ = g.Wait()
net.Conn 的 CloseWrite() 不是所有系统都支持
Linux/macOS 下 net.Conn 通常实现了 CloseWrite()(底层调用 shutdown(SHUT_WR)),但 Windows 的默认 TCP 实现不支持,会 panic 或静默失败。
兼容性影响:在 Windows 上直接调用 CloseWrite() 可能导致转发中断或连接重置。
- 检查是否支持:
_, canCloseWrite := conn.(interface{ CloseWrite() error }) - 不支持时,可改用
conn.Close(),但要注意这会彻底断开连接,不适合长连接协议(如 WebSocket) - 更稳妥的做法:只在明确知道对端是“读完就结束”的场景(如 HTTP/1.0 无 keep-alive)才关写入;否则留着连接,靠上层协议或超时机制自然断连
- 若需跨平台可靠行为,建议封装一层适配逻辑,Windows 下跳过
CloseWrite(),改用带 timeout 的io.CopyN或手动分块读写
转发时要不要缓冲?io.Copy 默认用了多大 buffer
io.Copy 内部用的是 io.CopyBuffer,默认 buffer 是 32KB(io.DefaultCopyBuffer = 32 * 1024)。对大多数局域网转发够用,但高延迟或高吞吐场景下可能成瓶颈。
性能影响:buffer 太小 → 系统调用频繁;太大 → 内存占用高、首字节延迟略升。
- 不要盲目调大 buffer,先压测。实测在千兆内网中,64KB 和 32KB 吞吐差异通常
- 如需自定义 buffer,用
io.CopyBuffer(dst, src, make([]byte, 64*1024)),注意这个 slice 必须在 goroutine 内复用,别跨 goroutine 共享 - 真正影响延迟的往往不是 buffer 大小,而是是否启用
TCP_NODELAY(禁 Nagle):tcpConn.SetNoDelay(true)
,这对交互式流量(如 SSH、telnet)很关键 - 别在
io.Copy外再套一层bufio.Reader/Writer—— 它们和io.Copy的 buffer 叠加反而降低性能,还可能破坏边界(比如把一个完整 TCP 包拆成两段)
实际跑起来最常被忽略的,是连接空闲超时和 TCP keepalive 没开。没有它们,中间 NAT 或防火墙会在几分钟后悄无声息地掐断连接,而你的 io.Copy 还在等永远不会来的数据。










