长连接必须绑定context,否则goroutine无法响应取消、超时或重启信号,导致泄漏;因goroutine不监听ctx.Done()而与父请求失联,持续占用资源且阻碍优雅退出。

长连接必须绑定 context,否则无法响应上游取消、超时或服务重启信号,最终导致 goroutine 泄漏和连接堆积。
为什么长连接不接 context 就会泄漏
HTTP/2 流、gRPC stream、WebSocket 连接、TCP 心跳通道等长连接场景,本质是启动一个或多个长期运行的 goroutine 来读写数据。如果这些 goroutine 不监听 ctx.Done(),它们就对父请求的生命周期“失联”了:
- 用户关闭页面或客户端断连后,服务端仍维持连接和 goroutine,直到 TCP keepalive 超时(默认数分钟)才被动释放
- HTTP handler 因超时返回 504,但底层连接 goroutine 仍在跑,继续消耗内存、fd 和 CPU
- 服务滚动更新时,旧进程无法优雅退出,因为长连接 goroutine 拒绝终止
在 net.Conn 或 websocket.Conn 上绑定 context 的正确姿势
Go 标准库的 net.Conn 本身不感知 context,必须手动包装读写逻辑;第三方库如 gorilla/websocket 提供了 context-aware 方法,但需主动使用。
- 对
net.Conn:用conn.SetReadDeadline()+select监听ctx.Done(),不能只依赖Read()阻塞 - 对
gorilla/websocket.Conn:优先调用conn.ReadJSON(ctx, &v)和conn.WriteJSON(ctx, v),而非无 context 版本 - 自定义心跳 goroutine 必须在
for循环开头加select { case - 所有阻塞 I/O 操作(包括
time.Sleep)都应替换为time.AfterFunc或select+ctx.Done()
WithCancel 和 WithTimeout 的选择陷阱
长连接的生命周期通常由外部事件驱动(如客户端断开),而非固定时长,所以 context.WithTimeout 容易误用:
-
context.WithTimeout(parent, 30*time.Second)会强制 30 秒后取消,即使连接还在活跃收发 —— 这不是“长连接”,是“短命连接” - 正确做法是用
context.WithCancel(parent),并在检测到客户端断连(如Read返回io.EOF或websocket.CloseMessage)时显式调用cancel() - 仅对单次操作加超时,比如“等待客户端发来第一个 ping 帧不得超过 10 秒”,此时用
context.WithTimeout(ctx, 10*time.Second)是合理的
Value 传参要克制,尤其别传 conn 或 logger
context.WithValue 确实能透传 traceID、userID 等元数据,但常见错误是把连接对象、日志实例甚至数据库事务塞进去:
-
context.WithValue(ctx, "conn", conn)—— 违反 context 设计初衷,且造成循环引用风险 -
context.WithValue(ctx, loggerKey, logger.With(...))—— 日志实例不应随 context 传递,应在 handler 入口就绑定好字段 - 真正该放的只有不可变、轻量、请求级元数据,例如
requestID、authToken、tenantID - 键类型必须是自定义未导出类型(如
type userIDKey struct{}),避免字符串 key 冲突
最常被忽略的一点:长连接的 cancel 函数必须在连接关闭后调用,而不是在 handler 返回时 —— 因为 handler 可能早已返回,而连接 goroutine 还在运行。cancel 的时机必须与连接实际终止同步,否则 context 树不会真正回收。











