最稳路径是配对启动两个goroutine做双向透传;单用io.Copy会因单向阻塞导致卡死、丢包、goroutine泄漏。

直接上结论:用 net.Conn 配对启动两个 goroutine 做双向透传,是 Go 实现端口转发最稳、最可控的路径;任何试图用 io.Copy 单向“偷懒”的写法,都会在真实流量下暴露连接卡死、goroutine 泄漏、丢包等问题。
为什么 net.ListenTCP 后只写不读会丢包
常见现象是:本地能 telnet 通,但 HTTP 请求卡在首包、SSH 登录无响应、或只传了几十字节就断开。这不是网络问题,而是 TCP 缓冲区被填满后内核开始丢包。
- TCP 是全双工协议,
conn.Write()成功只表示数据进了本机发送缓冲区,并不保证对方已读 - 若不启动 goroutine 持续
conn.Read(),远端发来的 ACK 或应用层响应会堆积在接收缓冲区,溢出即丢包 - 尤其在 HTTPS、WebSocket、gRPC 等二进制协议中,首帧缺失直接导致握手失败
如何正确启动双向转发 goroutine
必须为每个客户端连接启动两个 goroutine,分别处理 local → remote 和 remote → local 流向,且需同步关闭两端连接。
- 别用
go io.Copy(dst, src)就完事——它内部虽循环读写,但一端出错时另一端仍在阻塞等待,goroutine 永不退出 - 用显式循环 + 错误判断:任一端
Read()返回io.EOF或非临时错误时,应主动调用src.Close()和dst.Close() - 用
sync.WaitGroup等待两个 goroutine 全部退出后再释放连接资源,避免remoteConn被提前 GC 或复用 - 示例关键逻辑:
go func() { io.Copy(localConn, remoteConn) localConn.CloseWrite() remoteConn.CloseRead() }() go func() { io.Copy(remoteConn, localConn) remoteConn.CloseWrite() localConn.CloseRead() }()
如何避免 listen tcp :8080: bind: address already in use
这不是代码 bug,而是系统层面的 TIME_WAIT 行为。Go 默认未启用 SO_REUSEADDR,反复启停服务时端口会被短暂占用。
立即学习“go语言免费学习笔记(深入)”;
- 仅 Linux/macOS 支持该选项,Windows 下通常不明显但不可依赖
- 不能只靠
defer ln.Close()——进程崩溃或 Ctrl+C 时根本没机会执行 - 必须用
net.ListenConfig手动设置:lc := net.ListenConfig{ Control: func(network, address string, c syscall.RawConn) error { return c.Control(func(fd uintptr) { syscall.SetsockoptInt32(int(fd), syscall.SOL_SOCKET, syscall.SO_REUSEADDR, 1) }) }, } ln, err := lc.Listen(context.Background(), "tcp", ":8080")
转发 HTTPS 流量时浏览器报 ERR_SSL_PROTOCOL_ERROR 怎么办
只要后端是合法 HTTPS 服务,且域名与证书匹配,纯透传就不会触发该错误——端口转发本身不解析 TLS,也不终止握手。
- 真正出问题的场景是:你在本地用
http://localhost:8080访问,却期望它代理https://example.com:443;浏览器实际发起的是明文 HTTP 请求,自然被后端拒绝 - 正确做法是确保客户端访问协议与目标服务一致:HTTPS 流量必须走
https://URL,由浏览器自行完成 TLS 握手,转发层只透传加密字节流 - 如果需要中间解密(如调试),那已不属于“端口转发”范畴,得上 MITM 代理,比如
mitmproxy或自研 TLS 终止逻辑
最容易被忽略的一点:所有 net.Conn 都应设置读写 deadline,否则空闲连接可能无限 hang 住,直到 TCP keepalive 触发(默认 2 小时)。用 conn.SetReadDeadline() 和 conn.SetWriteDeadline() 控制超时,比依赖连接池或外部健康检查更底层、更可靠。



















