Go协程泄漏的三种现场与排查思路
Go 协程轻量,但轻量不代表可以随意放任。线上服务跑着跑着内存涨上去、响应变慢,pprof 一抓,goroutine 数量成千上万,多半就是协程泄漏了。所谓泄漏,就是协程启动了却永远无法退出,占着栈空间和堆引用,逐渐把资源耗干。这篇文章整理三种最常见的泄漏现场,以及对应的排查路径。
现场一:channel 发送/接收双方永远等不到对方
这是最经典的泄漏场景。一个协程往 channel 里发数据,但接收者已经退出;或者一个协程等着从 channel 读数据,但发送者永远不来。双方如果没有超时或取消机制,协程就会一直阻塞在那里。
举个例子:
ch := make(chan int)
go func() {
ch <- 42 // 永远没人接收,goroutine 泄漏
}()
// 主流程继续做别的事,没有接收 ch
更隐蔽的是在业务循环中,每个请求都启动一个协程等待某个 channel,而该 channel 的写入逻辑因为提前 return 或 panic 被跳过了。
排查思路:用 pprof 抓 goroutine,在 `runtime/pprof` 的输出里找到阻塞在 `chan send` 或 `chan receive` 的栈。重点看调用栈的上下文,确认对端协程是否已经不存在。修复手段就是加缓冲、加超时、或者用 `context` 取消来兜底。推荐使用 `select` 配合 `time.After` 或 `ctx.Done()`,让协程有路可退。
现场二:`sync.WaitGroup` 计数未归零导致永久等待
不少同学喜欢用 `WaitGroup` 等一批协程结束,但如果在执行协程前 Add 次数不够,或者协程内部因阻塞没执行 Done,主流程就会一直卡在 `Wait()` 上。虽然这种不算标准意义上的“协程泄漏”,但同样会造成协程堆积——如果主流程是每来一个请求就起一组协程并 Wait,前一组没结束,后面的请求继续堆积,goroutine 数量自然会飙升。
常见错误是子协程里又起了子协程,却把 WaitGroup 的 Done 放在了外层协程的最后,而外层协程可能因为内层还没完成就提前 Done,导致内层的 Wait 永远等不到。或者是在循环里误用了 `go` 关键字,把 Add 放在 goroutine 内部执行,结果主流程 Wait 时计数还没加上。
排查思路:抓 goroutine 栈时看到阻塞在 `sync.WaitGroup.Wait` 的协程,顺着调用栈找到创建 WaitGroup 的位置。检查所有 Done 的分支是否覆盖了提前 return、panic 等情况。更稳妥的做法是在协程入口用 `defer wg.Done()`,并且确保 Add 在启动协程之前完成。
现场三:锁未释放或 `time.Ticker` 忘记停止
互斥锁(`sync.Mutex`)如果加锁后忘了解锁,或者因为某段逻辑 panic 导致 `Unlock` 没执行,另一个获取锁的协程就会永远阻塞。这虽然不是协程自己启动,但会让持有锁的协程和等待锁的协程一起形成死锁状态,在 pprof 里表现为大量 goroutine 阻塞在 `sync.Mutex.Lock`。
另外,`time.Ticker` 也是一个容易忽略的点。如果创建了 Ticker 但从不 `Stop`,它的 channel 会持续产生事件,关联的协程如果不退出就会一直执行,看起来像活跃泄漏。更常见的是用 `for range ticker.C` 的协程,外层没有退出条件。
排查思路:pprof 栈上会显示 `sync.Mutex.Lock` 阻塞,并且能看到当前持锁的 goroutine 是哪个(在 dump 里查找 `Mutex` 或 `Lock` 相关的地址)。如果是 panic 导致解锁失败,建议加 `defer` 来保证解锁。对于 Ticker,凡是 `time.NewTicker` 的地方都必须有对应的 `Stop`。可以用 `go vet` 检查部分问题,但最好的办法是代码审查时建立 checklist。
实战排查建议
以上三种现场,核心工具都是 Go 内置的 pprof。启动服务时引入 `net/http/pprof`,然后通过 `go tool pprof http://localhost:port/debug/pprof/goroutine` 获取 dump。用 `web` 或 `tree` 视图,找到数量异常大的阻塞栈。
还有一种简单却有效的方式:在测试环境定期输出 goroutine 数量,观察是否有单调递增趋势。如果协程总数只增不减,说明存在泄漏。对于已经泄漏的协程,直接 `kill` 服务没用,关键是找到泄漏点并修复。建议所有可能阻塞的 channel、锁、WaitGroup 操作都画一个生命周期图,明确每一步谁会唤醒、谁会超时。
协程泄漏没有银弹,但遵守三条原则能避开大部分坑:一是 channel 操作必须明确发送方和接收方的退出条件;二是 mutex 和 WaitGroup 尽量用 `defer` 保证释放;三是凡是涉及定时器、外部资源,都要考虑关闭或取消。做到这几点,你的 Go 服务至少能在高并发下稳定地呼吸。
转载请注明出处,版权归原作者所有。
管理员
黑卡会员