长连接超时必须绑定读写循环并设 deadline,仅传 ctx 不监听 done() 会导致 goroutine 永久泄漏;net.conn 不响应取消信号,需动态调用 setreaddeadline 配合剩余超时时间,且各层嵌套 goroutine 必须逐级派生并 cancel context。

超时控制必须绑定到每个长连接的读写循环里,只在建立连接时传一次 ctx 不起作用;不监听 ctx.Done() 的 goroutine 会永久泄漏。
长连接里为什么 context.WithTimeout 容易失效
常见现象是:设了 5 秒超时,但连接卡在 conn.Read() 上几十秒都不退出。根本原因不是 context 失灵,而是标准 net.Conn 不响应取消信号——它压根没实现 ReadContext 方法。
- HTTP/2、gRPC、WebSocket 等上层协议虽支持 context,但底层仍依赖
net.Conn;一旦阻塞在系统调用(如read(2)),ctx.Done()就只能等,无法中断 -
context.WithTimeout只负责发信号,不负责“杀掉”连接;你得自己把信号转化成可响应的动作 - 误以为 “传了 ctx 就万事大吉”,结果在
for { conn.Read(...) }循环里完全没检查ctx.Err(),协程永远 hang 住
怎么让 net.Conn 真正响应超时
必须把 context 超时转化为连接层可感知的 deadline,而不是靠 ctx.Done() 干等。
- 每次
conn.Read()前调用conn.SetReadDeadline(time.Now().Add(timeout)),且 timeout 应基于剩余超时时间动态计算(别硬写死) - 更稳妥的做法:用
net.Dialer的DialContext建连,它会在 DNS、TCP 握手阶段响应 cancel;但建连后仍需手动管理读写 deadline - 不要用
bufio.Reader.ReadString这类封装——它内部可能多次调用Read却不重设 deadline;改用io.ReadFull+ 显式 deadline 控制 - 如果用的是 TLS 连接,还要额外设置
tls.Config.HandshakeTimeout和conn.SetWriteDeadline,否则写响应也可能卡死
goroutine 泄漏的典型断层点
长连接常嵌套心跳、重连、消息分发多个 goroutine,任意一层漏传 ctx,整条链就脱离管控。
- WebSocket handler 启动心跳协程时,必须用
ctx = heartBeatCtx派生新 context,不能复用原始r.Context() - 心跳协程再启动重连逻辑,要再次
context.WithTimeout(heartBeatCtx, ...),否则重连失败后原心跳还在跑 - 所有子 goroutine 启动前必须
defer cancel(),且 cancel 函数要来自该层派生的 context,不是上游传来的那个 - 用
sync.Once包裹conn.Close(),避免多路退出时重复 close 导致 panic
超时起点和资源清理的隐蔽坑
超时计时从 context.WithTimeout 调用那一刻开始,不是从 conn.Read() 开始——中间任何调度延迟、锁竞争、GC STW 都会吃掉你的超时预算。
- 高频长连接场景下,
cancel()必须显式调用,不能只靠defer;panic 或提前 return 会让 defer 失效,导致 timer 和 channel 泄漏 - 数据库查询、下游 HTTP 调用等子操作,必须用
db.QueryContext(ctx, ...)、client.Do(req.WithContext(ctx)),不能只靠外层超时兜底 - 日志中记录
ctx.Err()时,要同时打上连接 ID 和当前 goroutine ID,否则海量连接出问题时根本定位不到是哪条链卡住了
最麻烦的不是写错超时逻辑,而是某次心跳 goroutine 因为忘了派生 context,导致整个连接池里几百个连接都持续 hold 着 goroutine,直到服务重启才释放——这种泄漏不会立刻报错,但压测时 CPU 和 goroutine 数会缓慢爬升,极难复现。











