如何在Golang中开发一个简单的端口转发器 Go语言双向IO拷贝

6次阅读

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

如何在Golang中开发一个简单的端口转发器 Go语言双向IO拷贝

为什么 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.WaitGrouperrgroup.Group 协调退出

errgroup.Group 启动两个 io.Copy 并正确退出

errgroup.Group 能统一收集两个方向的错误,并在任一出错时取消另一个,避免 goroutine 泄漏。比裸写 sync.WaitGroup 更安全。

使用场景:需要稳定转发任意 TCP 流量(如调试代理、内网穿透中继)。

立即学习go语言免费学习笔记(深入)”;

  • 导入:golang.org/x/sync/errgroup
  • 转发前先调用 conn.SetDeadlineSetReadDeadline/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.ConnCloseWrite() 不是所有系统都支持

linux/macosnet.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 还在等永远不会来的数据。

text=ZqhQzanResources