先分清:是并发高,还是泄漏了
排查线上 Go 服务的时候,内存持续增长往往是最让人头疼的问题。很多团队的第一反应是抓 heap profile,结果发现内存都被某个缓存或临时对象占着,但释放不掉。其实真正的源头,可能是一批永远退不出去的 goroutine。

goroutine 泄漏并不是指 goroutine 从内存里消失,而是它被某种原因卡住,永远无法退出。它占用的栈内存不会被回收,它持有的引用让关联对象也无法释放,更严重的是,如果它卡在 channel 或锁上,还会拖住整条链路。很多从内存入手排查的问题,绕到最后都会回到 goroutine 这里。
以 pprof 和 goroutine dump 为主线,这篇文章会聊一聊 Go 服务里 goroutine 泄漏的常见形态、定位方法,以及怎么在设计层面尽量避免它。内容偏实战,适合已经能写 Go 业务代码、但还没怎么系统查过这类问题的同学。
不过要先把一个概念掰清楚:看到 goroutine 数量高,并不等于泄漏。一个常规 HTTP 服务,每个连接都会分配一个 goroutine,高峰期有几千个 goroutine 很正常。只有当请求量平稳、goroutine 数量仍然持续上涨,或者大量 goroutine 卡在同一类等待状态时,才需要警惕。
判断趋势最简单的办法,是用 runtime.NumGoroutine() 采样:
package main
import (
"fmt"
"runtime"
)
func main() {
fmt.Printf("goroutines: %d\n", runtime.NumGoroutine())
}
这段代码在线上一般不直接跑,而是放到监控 SDK 里定期采样。把采样值画成折线,就能很快看出趋势。如果请求量平稳,goroutine 数量还在以固定速率增长,那大概率有问题;如果总是冲高回落,那只是并发模型的特征。
goroutine 泄漏的高发模式
从实际排查的情况看,goroutine 泄漏通常集中在几类代码模式里。
- channel 收发没有对端。无缓冲 channel 里,发送方没有接收方会一直阻塞,接收方没有发送方也一样。即使是有缓冲 channel,缓冲区被填满后,发送方照样会卡住。
- select 永远等不到就绪分支。比如某个 channel 在极端条件下永远不会收到消息,又没有 default 分支兜底,goroutine 就会一直挂在 select 上。
- 锁没有最终释放。比如持锁路径里有 panic 或提前 return,导致其他等待锁的 goroutine 全部堆积。
- 常驻 goroutine 没有退出机制。比如定时轮询、心跳上报,循环里只处理业务,不检查 context 是否取消。
- 第三方库内部的 goroutine 被漏关。一些库在初始化时会启动后台 goroutine,调用方如果忘了调用 Stop 或 Close,泄漏就会积累。
这类问题的共同点是:goroutine 的创建很容易,一行 go func(){} 就够了,但它的生命周期却经常没人负责。很多团队在单测阶段不会发现这些问题,因为单测跑完进程就结束了,根本等不到数量膨胀。只有服务长期运行,泄漏才会一点点显现。
举个典型的例子。一个后台任务每 5 秒启动一个 goroutine 去轮询某个资源,轮询函数内部阻塞在 channel 上,而且没有超时。调用方每次循环都创建新的 goroutine,却不等待旧的退出。这样一来,几个小时后 goroutine 数量就会积累到几千甚至上万。
用 pprof 定位泄漏的两种方式
定位 goroutine 泄漏,最常用的工具是 pprof。Go 标准库把 goroutine profile 暴露得已经很完整,关键是你得知道怎么拿、怎么看。
如果服务是 HTTP 服务,最简单的方式是引入 net/http/pprof,然后在 main 函数里单独起一个 HTTP server:
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
启动之后,有两个地址对你排查泄漏最有用:
/debug/pprof/goroutine:返回二进制 profile,交给 go tool pprof 分析。/debug/pprof/goroutine?debug=1:直接返回文本格式的完整 goroutine 栈。
第二种方式在实际排查中很常用,因为不需要额外工具,curl 一下就能拿到完整快照:
curl http://localhost:6060/debug/pprof/goroutine?debug=1 > goroutine.txt
打开这个文件,你会看到类似下面的内容:
goroutine 42 [chan receive, 5 minutes]:
main.worker(0x0, 0xc0000a0000)
/app/worker.go:35 +0x45
created by main.main
/app/main.go:28 +0x30
方括号里的 chan receive 是 goroutine 当前状态。如果大量 goroutine 都卡在 chan receive 或 chan send,而且栈顶都指向同一个业务函数,问题基本就定位到了。
对于刚接触 dump 的同事,我建议先记住几个常见状态的含义:
chan receive:等待从 channel 接收数据。chan send:等待向 channel 发送数据。select:卡在多路选择,没有分支就绪。semacquire:等待锁或信号量。IO wait:等待网络或文件 IO。
前四个是 goroutine 泄漏的高发状态,IO wait 则要结合业务判断。比如一个 HTTP server 的 goroutine 阻塞在 IO wait 等待新请求,那是正常状态;但如果你确认某个后台任务不应该再接收 IO,它还长期挂在这里,就需要查了。
如果嫌文本栈不够直观,还可以用 go tool pprof 直接打开二进制 profile,生成调用图:
go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine
这个命令会启动一个本地 Web 页面,以图或火焰图方式展示当前 goroutine 分布。它最大的价值是快速找到高占比调用链。但要记住,goroutine profile 采样的是“当前阻塞在哪里”,不是“CPU 花在哪里”,不要和 CPU profile 混为一谈。
单看一份 dump 容易误判
很多人抓了一份 dump,看到几百个 goroutine 阻塞在某处,就急着改代码,结果毫无效果。原因很可能是这份 dump 里的阻塞本来就是正常设计。
比如服务端专门负责处理请求的 goroutine,长期阻塞在 IO wait 等待下一个连接,这是它的本职工作。再比如全局配置中心启动的监听 goroutine,阻塞在读 channel 上,也完全正常。真正泄漏的 goroutine,往往有几个共同特征:数量是持续增长的,每次抓 dump 都出现在同一个调用栈,而且它们的编号一直在变大。
这里分享一个我觉得很有效的做法:抓两份 dump,中间隔几分钟。对比两份文件,重点看哪些 goroutine 栈是新增的。泄漏的 goroutine 会稳定地出现在每次 dump 里,而且数量不断上升;正常阻塞的 goroutine 则不会无限增长。如果两次 dump 之间整体数量只差几十个,那可能只是瞬时波动;如果差了几千个,就要顺着新增栈往下查。
另外,在选择检测工具时,也可以根据自己的场景来。下面的表格是一个简单的参考:
| 检测手段 | 粒度 | 上手成本 | 适用场景 | 局限 |
|---|---|---|---|---|
| runtime.NumGoroutine | 全局计数 | 低 | 线上监控趋势 | 无法定位阻塞位置 |
| net/http/pprof debug=1 | 详细文本栈 | 中 | 在线 HTTP 服务 | 文件可能很大,需要对比多份 |
| go tool pprof goroutine | 调用图/火焰图 | 较高 | 快速定位高占比栈 | 无法直接看到等待时长和编号变化 |
| 多份 dump 对比 | 变化趋势 | 中高 | 确认是否持续增长 | 需要间隔采集,分析耗时 |
一条更高效的排查路径
如果你需要快速定位到根因,我建议按下面这个顺序推进,而不是随机看代码。
- 先用监控确认 goroutine 总数持续增长,排除偶发高峰。
- 抓第一份 dump,记录当前 goroutine 数量和主要阻塞栈。
- 隔 1 到 2 分钟再抓一份,和第一份做对比。
- 找出新增量最大的栈顶函数,回到代码里确认它的创建路径。
- 如果该路径被循环调用,检查 goroutine 是否具备退出条件。
- 修复后通过压测或线上观察,验证 goroutine 数量回落。
有些服务没有对外开放端口,比如纯后台任务,这时候可以采用另一种方式:在程序内部调用 runtime/pprof 主动写文件。
f, _ := os.Create("goroutine.dump")
defer f.Close()
_ = pprof.Lookup("goroutine").WriteTo(f, 1)
这里的 pprof 来自标准库 runtime/pprof。第二个参数填 1,表示输出 debug=1 相同的文本格式;填 2 会带上更详细的上下文,适合离线分析。这种方式不依赖 HTTP 端口,对很多内部服务非常实用。
常见误区:预防不是靠小心
比检测更重要的是预防,但预防不能靠“下次注意”。要先避开几个典型的误区。
误区一:给 channel 加了缓冲区就安全。缓冲区只能推迟阻塞,不能解决阻塞。一旦写入速度超过读取速度,缓冲区照样会被填满,发送方依旧会卡住。
误区二:在 goroutine 里加 defer recover() 就能防止泄漏。recover 只能拦截 panic,拦截不了 channel 阻塞、锁等待或者死循环。它能避免程序崩溃,但救不了已经泄漏的 goroutine。
误区三:用 sync.WaitGroup 就不会泄漏。WaitGroup 只负责等待已经创建的 goroutine 退出,如果其中一个永远阻塞,等待方只会一直等下去。
真正有效的预防,是把 goroutine 的生命周期纳入调用方控制。最简单也最基础的模式,是用 context 传递取消信号:
ctx, cancel := context.WithCancel(context.Background())
go func() {
for {
select {
case <-ctx.Done():
return
default:
// 处理业务
}
}
}()
然后确保服务关停或不再需要这个 goroutine 时调用 cancel。如果 goroutine 是在某个业务方法里创建的,那么方法返回前,就应该把它的退出信号一起整理好。
另一个更工程化的做法,是用 worker pool 或 errgroup 限制并发数量,而不是随手 go func()。比如通过带缓冲 channel 做信号量,控制同时运行的 goroutine 数量:
sem := make(chan struct{}, 32)
for _, task := range tasks {
sem <- struct{}{}
go func(t Task) {
defer func() { <-sem }()
process(t)
}(task)
}
但要注意,限制并发只是拖延时间,不是根治。如果任务本身无法退出,goroutine 数量最终还是会涨上去。根治的方向只有一个:每个 goroutine 都要有明确的退出路径,要么由 context 取消,要么由 channel 关闭信号触发,要么有清晰的结束条件。
把 goroutine dump 纳入日常巡检
坦白说,大多数 goroutine 泄漏不是某一天突然出现的,而是代码不断演化后慢慢累积出来的。就算预防做得再好,也需要一个常规巡检机制。
我的建议很简单:开发环境集成一个检查脚本,周期性请求 /debug/pprof/goroutine?debug=1,统计 goroutine 总量和基线,超过阈值就告警。一条 curl 加一个监控项就能覆盖大部分风险,成本很低。
另一个值得做的动作,是把 goroutine dump 作为发布流程的一环。每次灰度发布后,隔几分钟抓两次 dump,看一下 goroutine 数量是否平稳。如果新版本上线后数量异常增长,尽早回滚,比事后层层分析省力得多。
goroutine 是 Go 并发模型的灵魂,但它的轻量也容易让人忽略管理成本。和内存泄漏一样,goroutine 泄漏最终会以服务不稳定和资源上涨的形式暴露出来,只不过它埋得更深,排查起来更考验耐心。希望这篇文里的思路和命令,能帮你在下次遇到这类问题时,先看一眼 goroutine dump,而不是在内存 profile 里绕圈子。
原创文章,作者:fudengji,如若转载,请注明出处:https://fudengji.cn/article/566/