长连接必须用 context.WithCancel 链式派生,不能用 context.WithTimeout 直接套 handler 入口——否则连接会在固定时间后被强制关闭;真正该超时的是单次操作(如等待 ping 帧),而非整个连接生命周期。

长连接必须用 context.WithCancel 链式派生,不能用 context.WithTimeout 直接套 handler 入口——否则连接会在固定时间后被强制关闭,和“长”字完全矛盾。
为什么长连接不能用 context.WithTimeout 套整个 handler
HTTP/2 流、gRPC stream、WebSocket 连接这类长连接的生命周期由客户端行为(如断开、发 close 帧)驱动,不是由服务端预设时长决定的。context.WithTimeout(ctx, 30*time.Second) 会在 30 秒后强制关闭 ctx.Done(),不管连接是否还在收发数据。
- 现象:用户保持页面打开,但服务端在 30 秒后主动 cancel,触发
websocket.CloseAbnormal或io.EOF,客户端感知为意外断连 - 后果:重连风暴、消息丢失、trace 断链
- 真正该超时的,是单次操作,比如“等待第一个 ping 帧不得超过 10 秒”,此时用
context.WithTimeout(ctx, 10*time.Second)是合理的
net.Conn 和 gorilla/websocket.Conn 怎么接入 context
原生 net.Conn 不感知 context,必须手动包装;gorilla/websocket.Conn 提供了 context-aware 方法,但需主动使用。
- 对
net.Conn:不能只靠conn.Read()阻塞,要配合conn.SetReadDeadline()+select监听ctx.Done(),防止卡在内核态收不到信号 - 对
gorilla/websocket.Conn:优先调用conn.ReadJSON(ctx, &v)和conn.WriteJSON(ctx, v),而非无 context 版本 - 自定义心跳 goroutine 的
time.Sleep()必须替换为select { case
子 goroutine 的 cancel() 调用时机和风险
cancel() 函数本身是幂等的,但调用时机错位会导致资源未释放或重复关闭。
- 别在 handler 结束时
defer cancel():长连接 handler 通常永不返回,defer不会执行 - 推荐模式:连接建立后立刻
ctx, cancel := context.WithCancel(r.Context()),然后用defer cancel()包裹整个连接处理逻辑(包括所有子 goroutine 启动),确保无论从哪条路径退出都触发清理 - 避免多个 goroutine 同时调
cancel():比如读协程和心跳协程都检测到断连并各自cancel,虽不 panic,但可能掩盖谁该负责关闭连接的逻辑 - 最难的不是调
cancel(),而是确认「此刻是否已不可恢复」——比如Read()返回io.EOF可以cancel,但返回net.OpError且Timeout()为 true,可能只是暂时抖动,得结合重试策略判断
真正难的不是写几行 select 监听 ctx.Done(),而是把 ctx 从入口一路透传到每个阻塞点:handler → heartbeat goroutine → ping 发送逻辑 → 底层 conn.Write()。只要其中一层没传 ctx,那一层就彻底脱离控制。











