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 谁负责让它能退出。
笔记加载中…