
Go 1.5 升级后,io.Copy 在未显式关闭对端连接的双向 TCP 代理场景中会永久阻塞;根本原因在于 io.Copy 依赖 EOF 信号退出,而未关闭的连接永不发送 EOF,需在复制完成时主动关闭对端连接。
go 1.5 升级后,`io.copy` 在未显式关闭对端连接的双向 tcp 代理场景中会永久阻塞;根本原因在于 `io.copy` 依赖 eof 信号退出,而未关闭的连接永不发送 eof,需在复制完成时主动关闭对端连接。
在 Go 1.5 中,io.Copy 的行为相较 1.4.x 更加严格和符合规范:它不再依赖底层 syscall 的偶然错误(如 EPIPE 或 ECONNRESET)作为退出条件,而是坚定等待源 Reader 返回 io.EOF 或明确错误。这意味着——只要连接未被关闭,io.Copy(src, dst) 就会持续阻塞,即使对端已无数据可读。
典型误用场景如下(简化示意):
// ❌ 错误:两个 io.Copy 相互等待 EOF,形成死锁 go io.Copy(conn1, conn2) // 等待 conn2 关闭 → 但 conn2 不关 go io.Copy(conn2, conn1) // 等待 conn1 关闭 → 但 conn1 不关
由于 conn1 和 conn2 均为长连接且未被任何一方主动关闭,二者 io.Copy 永远无法收到 io.EOF,从而无限期挂起。Go 1.4.x 中可能因系统调用时机巧合触发写错误(如对端关闭导致 write: broken pipe),意外中断复制并“看似成功”,但这属于未定义行为,不可靠且掩盖了资源泄漏与逻辑缺陷。
✅ 正确做法是:每个 io.Copy 完成后,立即关闭其目标连接(即“对端”),以向另一侧的 io.Copy 发送 EOF 信号,形成协同退出闭环。配合 sync.WaitGroup 确保 goroutine 安全退出:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
_, err := io.Copy(conn1, conn2) // 将 conn2 数据拷贝到 conn1
if err != nil && err != io.EOF {
log.Printf("copy conn1<p>⚠️ 关键注意事项:</p>
- 不要忽略 io.Copy 返回的错误:io.EOF 是正常终止信号,其他错误(如网络中断、超时)需记录并处理;
- 关闭的是「目标连接」(dst),而非源连接(src)——因为 dst 的关闭会令对端 Read() 返回 io.EOF;
- 若需优雅终止(如支持半关闭),可使用 conn.(*net.TCPConn).CloseWrite() 替代 Close(),但需确保协议层兼容;
- 在真实代理服务中,建议封装为 func proxyConn(src, dst net.Conn) 并统一处理错误与关闭逻辑,提升复用性与健壮性。
总结:Go 1.5 的变更并非 bug,而是对 I/O 语义的精准回归。理解 io.Copy 的 EOF 驱动机制,并遵循“复制完成即关闭对端”的模式,是编写可靠 TCP 代理、隧道或协议转发器的基础前提。











