长连接服务必须将context.context作为第一参数传入handler,并在阻塞i/o操作中通过select监听ctx.done()以及时清理资源,否则易产生僵尸协程。

长连接服务(如 WebSocket、gRPC stream、TCP keep-alive 连接)一旦启动,若不主动监听 ctx.Done() 并做清理,很容易变成“僵尸协程”——连接没关、资源没释放、goroutine 一直挂着。关键不是“能不能取消”,而是“什么时候响应、响应后做什么”。
长连接 handler 必须把 ctx 当作第一参数传入
Go 的 HTTP、gRPC、net.Listener 等接口都支持传入 context.Context,但很多人只在入口处接收,后续就丢进局部变量或结构体里,导致取消信号无法穿透到真正阻塞的 I/O 操作中。
- 错误做法:
func handleConn(conn net.Conn) { ... }—— 完全脱离 context 控制 - 正确做法:
func handleConn(ctx context.Context, conn net.Conn) { ... },且所有子 goroutine 都接收该ctx - HTTP handler 中直接用
r.Context(),别自己 new 一个context.Background() - gRPC stream server 中,每个
stream自带stream.Context(),它已集成客户端断连信号
阻塞读写必须配合 select + ctx.Done()
长连接的核心是循环读/写,而 conn.Read() 或 stream.Recv() 是阻塞调用。不加 context 监听,cancel 信号永远进不来。
- 不能写:
for { n, _ := conn.Read(buf) }—— 无法响应取消 - 必须写:
select { case - 更稳妥的是用
conn.SetReadDeadline()配合ctx.Deadline(),避免 Read 卡死在内核态(尤其在 proxy 后) - 对
http.ResponseWriter写流也一样:每次Flush()前检查ctx.Err() != nil
cancel() 调用时机决定资源是否泄漏
很多人调了 context.WithCancel(),却忘了在连接关闭时显式调用 cancel()。这会导致子 context 的 timer、goroutine 不释放,ctx.Done() 永远不 closed。
- HTTP handler 结束时,如果用了
context.WithTimeout(),defer cancel()是安全的;但长连接 handler 通常不会自然结束,得靠外部触发 - 推荐模式:在连接建立时派生子 context,并在
defer中注册清理函数,例如:go func() { - 注意:不要在多个 goroutine 里重复调
cancel(),它本身是幂等的,但多次调用可能掩盖逻辑错误 - 数据库连接、TLS session、自定义 buffer pool 等资源,必须在
ctx.Done()分支里显式释放,不能只依赖 GC
代理层和客户端断连检测有延迟,不能全信 ctx.Err()
ctx.Err() 返回 context.Canceled 或 context.DeadlineExceeded 很方便,但真实环境里,Nginx、Envoy、iOS 网络栈可能让 TCP FIN 包延迟数秒甚至丢失,导致 ctx.Done() 不触发。
- HTTP/2 和 gRPC 默认会传播 cancel,但 HTTP/1.1 下需手动检查
conn.RemoteAddr()是否还活跃,或加心跳包 - 建议搭配
net.Conn.SetKeepAlive()和SetKeepAlivePeriod()主动探测 - 测试时别只靠
curl --no-buffer,要用nc -c或真实断网模拟 - 上线后监控
runtime.NumGoroutine()+ 连接数突增,是发现泄漏最直接的方式
真正难的不是写几行 select,而是想清楚:哪个 goroutine 负责关连接、哪个负责停 ticker、哪个负责归还 buffer,以及它们之间谁先退出、谁等谁。这些顺序一旦错,资源就卡死了。











