goroutine 泄漏的典型场景有哪些?如何定位与预防?
结论先行
goroutine 泄漏指 goroutine 永远停在某个阻塞点上无法退出:既占栈内存,又可能持有锁或连接不释放,数量只增不减最终拖垮进程。典型成因是“等一个永远不会到来的信号”——无人收发的 channel、永不关闭的 channel、忘了 cancel 的 context,以及循环里不断创建阻塞 goroutine。定位靠 NumGoroutine 趋势与 pprof goroutine profile,预防靠“谁创建谁负责退出”加统一取消信号。
要点(典型场景)
- 无缓冲或已满的 channel 发送:发送方等不到接收者,永久阻塞在 ch <- x。
- 空 channel 接收:等一个永远没人发送、也没人 close 的 channel。
- 没人 close:生产者退出前未关闭 channel,消费者的 range/<-ch 死等。
- context 派生后忘 cancel:子 goroutine 阻塞在 <-ctx.Done() 或依赖 ctx 的调用上,父 ctx 又从不取消。
- 锁未释放或嵌套等锁:表现与死锁相同,goroutine 卡在 semacquire。
- 循环内每轮启动 goroutine 且内部等待条件永不满足:数量线性上涨,形成“活锁式泄漏”。
定位三板斧
- 趋势观测:定时打印 runtime.NumGoroutine(),数量随请求量只增不减即高度可疑。
- profile 抓栈:暴露 /debug/pprof/goroutine,用 go tool pprof 拉取后查看分布与阻塞栈,等待点通常形如 chan receive、select、semacquire。
- 测试兜底:集成测试里用 uber-go/goleak 校验测试结束时无残留 goroutine。
示例
// 泄漏版:没人给 ch 发数据,goroutine 永久等待
func leak() {
ch := make(chan int)
go func() { <-ch }()
}
// 修复思路:给等待加退出信号
func fixed(ctx context.Context) {
ch := make(chan int)
go func() {
select {
case <-ch: // 正常业务路径
case <-ctx.Done(): // 取消即退出
}
}()
_ = ch
}
常见追问/记忆点
- 追问:泄漏的 goroutine 最终会怎样?答:栈内存不回收,可能持有锁与连接,GC 管不到它们,内存与句柄持续增长。
- 追问:goleak 的原理是什么?答:对比测试前后 goroutine 栈快照,发现多出来的 goroutine。
- 追问:如何从设计上预防?答:给每个 goroutine 一条“退出路径”(ctx/close/超时),并限定并发上限。
- 记忆:阻塞点 = 等待点 = 风险点;ctx 取消与 close 是两大终止信号,谁创建 goroutine 谁负责让它能退出。