☰
Go协程泄漏排查实战:从pprof到goroutine栈定位
2026/10/14 6:48:01 网站建设 项目流程

1. 先搞清楚一件事:什么样的程序才算协程泄漏

先说一个很多初学者容易走偏的点:goroutine 不是越少越好,而是“该死的时候必须能死掉”。一个正常的 Go 服务,协程数量在请求进来时会涨,请求结束时会降,整体曲线像呼吸一样有起伏。如果某个协程创建之后永远卡在那里不退出,既不干活也不释放资源,那它就是泄漏了。你可以想象成公司里有人办了入职手续却一直不来打卡也不办离职,工资照发、工位占着,时间一长整个办公区全是这种“幽灵员工”,真正干活的人反而挤不进去。

从技术层面看,协程泄漏的本质是:协程的执行体由于某些条件永远无法满足,阻塞在某个等待点上,导致它的栈内存、关联的堆对象、以及可能在等锁时持有的其他资源全部无法被回收。Go 的运行时并没有“协程超时强制回收”这种机制,一旦泄漏,它就真真实实地占用内存、占用调度器的时间片,直到进程退出。

我收到的这个排查需求,核心就是回答两件事:一是怎么快速发现协程泄漏,二是在几十个协程里怎么准确定位到“那一个卡死的坏家伙”。这篇文章我会从现象识别讲到工具实操,再到典型场景复现和线上排查技巧,全程用我实际踩过的坑做例子,给出一套可以直接上手的方案。

适合看这篇文章的人分三类:第一类是刚接触 Go 并发、被“孤儿协程”搞到内存暴涨的新手;第二类是项目已经上了生产、goroutine 数量持续走高但还没崩的运维开发;第三类是准备做 Go 服务性能排查、想系统学一套排查方法论的中级工程师。不管你属于哪一类,看完至少能学会动手抓一次 goroutine profile,并知道怎么解读那一大堆堆栈信息。

2. 识别协程泄漏的三个特征信号

2.1 特征一:goroutine 总量持续增长且不回落

这是最直接的信号。用 Go 自带的 pprof 或者直接打印runtime.NumGoroutine(),在压测前后各取一个点。正常服务在并发请求结束后,协程数量会回落到一个稳定的基线值,比如 50 个左右。如果压测结束后协程数不减反增,或者过了很久依然维持在高峰值,基本可以判定有泄漏。

我见过一个真实案例,某消息推送服务,压测 10 分钟,QPS 从 500 打到 2000,协程数从 80 涨到 3 万,停止压测后 30 分钟协程数依然停在 2.9 万。这种“只进不出”的曲线,就是教科书级别的泄漏特征。

实操时可以用一段简单的监控代码定时采样:

package main import ( "log" "net/http" "runtime" "time" _ "net/http/pprof" ) func main() { go func() { for { log.Printf("goroutine count: %d", runtime.NumGoroutine()) time.Sleep(5 * time.Second) } }() log.Println(http.ListenAndServe(":6060", nil)) }

这里把net/http/pprof的匿名导入带上,同时起一个定时打印协程数的后台任务,这样代码本身和观测一起做,排查效率会高很多。实际线上服务如果不想侵入代码,也可以直接用 Prometheus 采集go_goroutines指标。

2.2 特征二:进程内存持续上涨且 GC 无法回收

协程泄漏通常伴随内存泄漏,但内存泄漏不一定全是协程造成的,这是需要分清的。当协程卡住时,它栈上的局部变量、通过闭包捕获的外部变量、以及它持有的 channel 或锁对象,都会变成"根对象"的一部分,GC 每次扫描都会发现它们还存活,于是回收不掉。

判断内存问题是不是协程引起的,可以看 pprof 的inuse_space和goroutine两个 profile 的对应关系。打开http://localhost:6060/debug/pprof/页面,如果 goroutine 那一栏的数字异常高,并且heap里大量内存被分配在跟并发相关的调用栈上(比如runtime.chansend、sync.Mutex.Lock、time.Sleep),那基本就是协程泄漏在拖着内存不放。

2.3 特征三:协程栈上的等待点长期不变化

这两个特征之外,还有一个更隐蔽的信号:协程数量不高,但个别协程已经等了很久。这种属于"少数协程泄漏",最容易被忽略,因为它对 goroutine 总量的影响不大,但可能持有某个关键锁或者 channel,拖垮整个业务流程。

判断方法是用 pprof 抓两次 goroutine dump,间隔一段时间(比如 5 分钟),对比同一位置的堆栈有没有“年龄增长”。如果某条堆栈第一次出现在第一行是chan receive,第二次还是chan receive,且 goroutine ID 没变,那它大概率就是泄漏点。这个我后面会详细讲怎么做对比。

3. 核心工具链:从 pprof 到 go tool trace 的组合用法

3.1 goroutine profile 的三种抓取姿势

抓 goroutine profile 有三种主流姿势,适用场景各不相同。

第一种是本地调试时直接命令行抓取:

go tool pprof http://localhost:6060/debug/pprof/goroutine

这个命令会进入到 pprof 的交互界面,输入top可以看 top 列表,输入web可以生成调用图。适合服务还活着、能连上调试端口的场景。

第二种是程序主动 dump 当前所有协程栈:

buf := make([]byte, 1<<20) n := runtime.Stack(buf, true) log.Printf("=== goroutine dump ===\n%s", buf[:n])

runtime.Stack(buf, true)的第二个参数传true表示把所有协程的栈都打出来,传false只打印当前调用者的栈。线上排查问题时可以在内存监控打到阈值时自动触发一次 dump,然后把输出写到日志文件里分析。这种方式的好处是不依赖 pprof 端口,对生产环境侵入更小。

第三种是通过 HTTP 端点抓取,直接访问http://localhost:6060/debug/pprof/goroutine?debug=1,浏览器里会看到格式化的协程列表,每一段包含 goroutine ID、状态、等待时间、堆栈信息。?debug=2则是带更多运行时字段的原始格式,适合脚本解析处理。

3.2 读懂 goroutine dump 里的三块核心信息

很多人拿到一份 goroutine dump 后一头雾水,其实只要抓住三块信息就够用了。

第一块是goroutine 状态字段。典型的状态有running(正在跑)、runnable(等待被调度)、chan receive(阻塞在 channel 接收)、chan send(阻塞在 channel 发送)、select(阻塞在 select 语句)、IO wait(阻塞在系统 IO)、semacquire(阻塞在锁或信号量上)、sleep(定时器或 time.Sleep)。出现频率最高的泄漏状态集中在chan receive、chan send、select、semacquire,这四个基本都是"被伙伴丢下、没人来推进"的场景。

第二块是goroutine ID 和创建时间。dump 里每段开头类似:

goroutine 3847 [chan receive, 942 hours]:

这里的 3847 是协程 ID,942 hours表示这个协程已经以当前状态等待了 942 小时,换算过来约 39 天。这个数字非常关键,等待时间越久,越说明它不是临时阻塞,而是真正的泄漏。

第三块是堆栈调用链。从底部到顶部读,最顶部的几行是当前卡住的精确位置,比如:

main.worker.func1(0xc000014100) /home/user/project/main.go:45 +0x55 main.worker(0xc000014100) /home/user/project/main.go:38 +0x33

这说明main.worker里定义的匿名函数在main.go第 45 行卡在了一个 channel 接收上。如果这个 worker 的数量有成千上万个,且堆栈完全一样,那就是同一个位置创建了大量泄漏协程。

3.3 别忽略 go tool trace 的辅助作用

pprof 适合看“瞬间快照”,但如果想搞清泄漏协程在生命周期里经历了什么,可以用go tool trace。在代码里加两行启用 trace:

import "runtime/trace" f, _ := os.Create("trace.out") trace.Start(f) defer trace.Stop()

然后跑一段复现泄漏的流程,结束后用go tool trace trace.out打开 Web UI。在 Goroutine 分析视图里可以看每个协程的状态切换时间线:它什么时候被创建、什么时候进入阻塞、阻塞了多久。这个工具在排查“协程创建后没有立即阻塞,而是在某个步骤后卡死”的场景时非常好用,能还原协程的完整生命轨迹。

4. 高频泄漏场景:代码层面最容易中招的六个位置

4.1 无缓冲 channel 的接收方先行退出

这是最常见、也最容易犯的错。无缓冲 channel 的发送操作必须等到有接收方就绪才能完成。如果发送方和接收方的启动顺序出了问题,发送方先启动但接收方永远没启动,发送方协程就会永久阻塞在发送操作上。

举个例子,某开发者写了一个并发爬虫,每个链接放在 channel 里由多个 worker 处理,但 worker 启动了之后,主流程因为某些逻辑提前 return,结果 channel 里还有数据没人接收,所有发送方协程就全线卡死。解决套路是:要么用带缓冲的 channel,要么确保接收方协程的生命周期覆盖掉所有发送方协程的发送窗口。

4.2 select 分支里忘记处理“没消息也要退出”的情况

select是所有协程阻塞等待的“三明治夹层”,它设计出来就是为了解决“多个等待条件取其一”的问题。但如果你select里只写了读 channel 的分支,没写default分支,也没写超时分支,那么当这个 channel 永远等不到数据时,协程就永久卡死在select语句上。

我自己以前写定时任务时犯过这个错:

for { select { case msg := <-taskChan: handle(msg) } }

如果taskChan在空闲期没有消息进来,这个协程就会一直阻塞在select,而我的设计意图是“通道没事干的时候协程应该退出”。加上default分支之后,空转时就会直接跳出去,问题迎刃而解:

for { select { case msg := <-taskChan: handle(msg) default: return // 通道当前没有消息,协程退出 } }

不过要注意,加了default的空 select 会在无消息时进入忙轮询,CPU 飙升,所以更严谨的写法是用time.After或 context 控制退出。

4.3 time.Ticker 忘记 Stop

Ticker 是一个典型的“泄漏暗雷”,因为它不像 channel 那样一眼能看出问题。time.NewTicker创建的 Ticker 会自动持有一个运行时定时器的引用,如果你不调用Stop,这个定时器对象不会被 GC 回收。更隐蔽的是,即使你defer ticker.Stop(),如果 Ticker 是在循环里重复创建且没有在下一次循环开始前停掉旧的,泄漏依然会发生。

func watch() { for { ticker := time.NewTicker(time.Second) <-ticker.C // 忘记 ticker.Stop() } }

这段代码每秒钟创建一个新 Ticker,旧 Ticker 的定时器通道始终有东西可读,但因为没人读取后续的 tick 值,通道积累的数据越来越多,定时器也越来越多。修复办法很简单,把 Ticker 提到循环外创建一次,循环结束后defer ticker.Stop()。如果你需要每次循环重新设置间隔,建议用time.Timer配合Reset方法,注意Reset前要先Stop并drain通道,否则会出现脏数据。

4.4 sync.WaitGroup 的 Add 和 Done 计数不匹配

WaitGroup 泄漏的形态跟 channel 不一样,它不会让协程卡死在某个 syscall 上,而是卡死在Wait()调用处。如果某个分支路径里调用了WaitGroup.Add(1)但对应的Done()因为异常 return 或 panic 没有执行,那么所有等待这个 WaitGroup 的协程就永远在Wait()上挂着。

一个典型场景:批量任务处理,对每个 item 起了 goroutine,wg.Add(1)放在循环里,但 item 的处理函数里有错误分支直接continue跳过了defer wg.Done()语句,导致计数器永远不为零。

排查这个问题的关键点是看堆栈底部是不是sync.(*WaitGroup).Wait。一旦看到这个,就去检查 Add 和 Done 的数量是否严格配对,尤其注意异常路径。

4.5 context 取消信号没有向下传递

现代 Go 服务几乎都依赖 context 做超时和取消控制。如果某个子协程启动时只传了context.Background()或者context.TODO(),没有传父 context,那么当父流程取消时,子协程是感知不到的,它会继续阻塞在自己的等待条件上,变成孤立协程。

go func() { select { case <-ctx.Done(): return case <-someChan: // do work } }()

上面的写法是标准做法,只要调用方传入的 ctx 在流程结束时被 cancel,这个子协程就能通过ctx.Done()分支退出。如果你在某个环节把 ctx 覆盖成了context.Background(),那取消信号就断链了。排查技巧是:在 goroutine dump 里找context包的堆栈,如果发现context.(*timerCtx).Done或者context.(*cancelCtx).Done长期等待,而父级已经不存在,那就是取消传递的中断点。

4.6 锁的等待导致连锁阻塞

最后别忘了互斥锁和读写锁。当协程 A 持有锁后被某次 panic 中断而未解锁,协程 B、C、D 若都在同一个锁上等待,dump 里会出现一大堆sync.(*Mutex).Lock或sync.(*RWMutex).RLock堆栈。这种泄漏比较特殊,它本质是一个坏协程拖累一堆好协程。

排查时先看锁等待堆栈里有没有同一个锁对象0xc000....地址反复出现,如果有,再往上翻找到持有这个锁的协程,确认它为什么没释放。实战里最常见的原因是Lock()和Unlock()之间嵌入了复杂业务逻辑,某一步抛了 panic 导致 Unlock 没执行。正确做法是用defer mu.Unlock(),或者把锁保护的范围缩到最小。

5. 实战演示:从复现一个泄漏到用 pprof 锁定代码行

5.1 构造一个可复现的泄漏 Demo

先写一个故意泄漏的程序,便于演示完整排查流程。模拟场景:网关服务启动了一堆 worker 从任务 channel 取数据,某个特殊条件下 worker 会永久等待一个永远不会被发送到结果 channel 的消息。

package main import ( "fmt" "log" "net/http" "runtime" "sync" "time" _ "net/http/pprof" ) func main() { go func() { http.ListenAndServe(":6060", nil) }() taskChan := make(chan int) resultChan := make(chan int) var wg sync.WaitGroup for i := 0; i < 50; i++ { wg.Add(1) go worker(i, taskChan, resultChan, &wg) } go func() { for i := 0; i < 10; i++ { taskChan <- i } close(taskChan) }() go func() { for res := range resultChan { fmt.Println("result:", res) } }() for { log.Printf("goroutine count: %d", runtime.NumGoroutine()) time.Sleep(3 * time.Second) } } func worker(id int, taskChan <-chan int, resultChan chan<- int, wg *sync.WaitGroup) { defer wg.Done() for task := range taskChan { if task == 7 { // 模拟 bug:这个 worker 在 task==7 时直接返回, // 但 resultChan 的接收方还期望它发送一个结果 return } resultChan <- task * 2 } }

运行这段代码后,正常预期是 50 个 worker 在处理完 task 后会退出,但因为task == 7时有一个 worker 提前 return,虽然它自己退了,但其他 worker 里如果有正在向resultChan发送数据而接收方已经因resultChan的range退出而不再接收,就会卡在发送上。不过我这里用了buffered还是unbuffered需要说明,这个 demo 的关键是制造“接收方退出但发送方还在等待”的死锁。

现实中更接近的泄漏是:worker 里启动了一个子协程去等待某个超时事件,但超时事件永远不发生。为了演示更直接,我换一个更干净的写法:协程泄漏在“一个永远接收不到数据的 channel 等待”上。

func worker(id int, taskChan <-chan int, resultChan chan<- int, wg *sync.WaitGroup) { defer wg.Done() for task := range taskChan { if task == 7 { // bug: 这个 worker 自己创建了一个子协程等待通知 done := make(chan struct{}) go func() { <-done // 永远等不到 close(done) }() } resultChan <- task } }

这样就有子协程永久阻塞在<-done上。

5.2 用 pprof 的交互模式定位阻塞位置

程序跑起来之后,打开http://localhost:6060/debug/pprof/,看到 goroutine 的数量持续在涨。命令行执行:

go tool pprof http://localhost:6060/debug/pprof/goroutine

进入交互界面后输入top,会看到类似输出:

Showing nodes accounting for 47, 100% of 47 total flat flat% sum% cum cum% 47 100% 100% 47 100% runtime.gopark 47 100% 100% 47 100% runtime.chanrecv 47 100% 100% 47 100% main.worker.func1

看到main.worker.func1这个位置占了 47 个协程,就已经锁定了泄漏函数。接着输入list main.worker.func1查看具体行号:

ROUTINE ======================= main.worker.func1 in /home/user/project/main.go:45 0 30 (inline) main.worker.func1

这里的行号就是代码里<-done所在的位置。修复思路就很清晰了:要么确保done会被 close,要么用select加超时和 context 取消,不要让子协程无限期等下去。

5.3 用两次 dump 对比确认泄漏而非临时阻塞

这里分享一个我常用的判断方法:隔两分钟抓两次 goroutine dump,对比相同堆栈的协程数量变化。临时阻塞的协程可能在第一次 dump 存在,第二次已经退出或被唤醒;泄漏协程会一直在,而且数量只增不减。

第一次 dump:

curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=1' > dump1.txt sleep 120 curl -s 'http://localhost:6060/debug/pprof/goroutine?debug=1' > dump2.txt

然后对比两次 dump 中相同堆栈出现的次数,如果 dump2 里main.worker.func1对应的协程段比 dump1 更多,说明新协程在持续产生并都卡在同一个位置。这个对比不需要自动化工具,肉眼加grep -c就够用:

grep -c "main.worker.func1" dump1.txt grep -c "main.worker.func1" dump2.txt

数字明显上涨的堆栈,就是泄漏的重点怀疑对象。

5.4 拿到 ID 后用调试器或日志回放辅助确认

pprof 能定位到函数和行号,但有些场景还需要确认到底是哪一个业务实例触发的。这时候可以临时在可疑代码处加日志,输出runtime.Stack或者用go-spew打印 goroutine ID。虽然 goroutine ID 从语言规范层面是不建议依赖的,但排查阶段用一下无妨:

func getGID() int64 { var buf [64]byte n := runtime.Stack(buf[:], false) // 解析 "goroutine 123 [" 中的数字 id := int64(0) for _, c := range buf[10:n] { if c >= '0' && c <= '9' { id = id*10 + int64(c-'0') } else { break } } return id }

然后把getGID()打进日志,跟 dump 里的 ID 对应上,就能精确知道哪个协程在哪个时间点开始阻塞。这个方法虽然不优雅,但在生产事故排查时往往最有效。

6. 常见问题与排查技巧速查

6.1 pprof 端口连不上

pprof 端口连不上是线上排查最常遇到的障碍。常见原因有三个:端口没对外开放、服务部署在容器里没有映射端口、防火墙拦截。

临时解决办法是在服务启动参数里加-pprof-addr=:0让它随机分配端口并输出到日志,然后通过日志系统查看。更推荐的做法是上生产前就在服务里内置 pprof 端点,但绑定到内网 IP 而不是 0.0.0.0,避免外网暴露风险。如果实在不能开端口,就用SIGQUIT信号触发 dump:

kill -QUIT <pid>

Go 收到SIGQUIT会直接把所有协程栈打印到标准错误,然后退出。这种方式不用任何端口,只要你能拿到进程 PID 和 stderr 输出就能排查。注意它会杀掉进程,只能用在已确认有问题、可以重启的服务上。

6.2 goroutine 数量多少算异常

没有绝对标准,但有一个经验参考值:单机正常 QPS 下,goroutine 数量应该和并发请求数的量级接近,通常是 QPS 的 1-3 倍。如果一个服务 QPS 只有 100,goroutine 却稳定在 5000 以上,高度可疑。如果 QPS 为 0 时 goroutine 仍然持续高位不下,那就是泄漏实锤。

另一种判断方式是看增长趋势。用runtime.NumGoroutine()每 30 秒采样一次,画出曲线,如果曲线斜率不变且为正,即使绝对值不高也要警惕——它可能在几小时后把内存吃穿。

6.3 本地无法复现的线上泄漏

线上泄漏在本地复现不了的情况很常见,因为触发条件和数据分布有关。我的做法是:线上抓到 goroutine dump 后保存为文件,本地用go tool pprof <dump文件>离线分析,不需要线上环境。这是 pprof 最被低估的能力——它不仅能连 URL 抓,还能读文件。

go tool pprof /path/to/goroutine.dump

在这个离线会话里用list、top、web分析,和在线分析完全一样。配合线上导出的 heap profile 文件交叉对比,往往能在几分钟内定位到泄漏点。

6.4 排查时的三个禁忌

第一忌是大规模改代码再验证。泄漏排查应该先抓现场、做快照、分析堆栈,确认到具体行号后再动手改,改完单独验证、逐步放量,切忌表演式重构——把能跑的代码全推倒重写,往往引入新问题。

第二忌是只看 goroutine 数不看堆栈。有些团队接到告警就加协程池大小参数,把最大协程数调高,掩盖问题。实际上泄漏的协程只是占着位置不干活,调大池子只会让正悄悄吃内存的“幽灵协程”更多,一旦资源耗尽照样崩。

第三忌是忽略 Go 版本差异。不同 Go 版本对协程调度、netpoll、定时器实现有差异,dump 信息格式也有细微区别,排查时先确认线上版本和本地版本一致,避免被格式变化误导。

7. 一些我常用的辅助性排查手法

7.1 用 block profile 找锁等待热点

goroutine dump 可以看出“哪些协程卡住了”,但卡住的位置如果是锁,blockprofile 能告诉你“锁被谁占用、等了多久”。使用方式:

import _ "net/http/pprof" // 启动前设置采样率 runtime.SetBlockProfileRate(1)

然后访问http://localhost:6060/debug/pprof/block,pprof 会列出最耗时的锁等待和 channel 等待。这个数据比 goroutine dump 更偏向时间维度的统计,适合定位“不是最多次数而是最长时间阻塞”的场景。

7.2 mutex profile 找锁竞争源头

与 block profile 互补的是mutexprofile,它记录的是锁的竞争事件,能显示哪些锁被多个 goroutine 争抢。排查高并发服务时,如果 goroutine dump 里出现大量sync.(*Mutex).Lock堆栈,用这个 profile 能直接看到锁对象在哪一行被创建、哪一行竞争最激烈。

开启方式是在代码里加:

runtime.SetMutexProfileFraction(1)

注意这两个 profile 都有性能开销,生产环境建议只在排查期临时开启,定位完成后关闭。

7.3 给可疑协程加“心跳日志”

有些泄漏是因为业务逻辑长时间不推进,不是死锁。比如协程在等某个外部接口响应,接口挂了且没有超时设置,协程就会一直挂在net/http的读取上。这种场景看起来像协程泄漏,实际上是外部依赖导致的活锁。

我的临时排查手法是给可疑协程加心跳日志:

go func() { ticker := time.NewTicker(10 * time.Second) defer ticker.Stop() for { select { case <-ctx.Done(): return case <-ticker.C: log.Printf("worker [%d] still alive, waiting for response", getGID()) } } }()

如果心跳日志持续输出而业务结果始终没有返回,那问题定位到“外部依赖调用未设置超时”,修复方向就是给 http.Client 设置 Timeout、给数据库查询加 context 超时。

这类问题虽然堆栈会显示在IO wait或netpoll上,但本质和内存泄漏无关,是资源等待超时缺失。排查时要特别注意区分这两类问题,否则方向就偏了。

8. 最后的实操心得

做了这么多年 Go 服务治理,我的体会有三点。

第一,协程泄漏不是靠“小心写代码”就能避免的,因为并发场景的组合数量远超你的心智模型承受范围。正确态度是接受它会不定期出现,所以提前把观测工具做好:pprof 端口常驻、goroutine 指标接监控、SIGQUIT dump 机制保留在生产服务里。

第二,排查泄漏时,第一份 goroutine dump 比黄金还值钱。抓到之后先不要动任何代码,先把堆栈保存下来,再逐步分析。我见过太多人在没有完整现场证据的情况下开始改代码,最后问题没定位到还给系统引入了“修复副作用”。

第三,修复泄漏只能算完成了一半工作。另一半是加一个针对性的回归测试或者监控告警,比如在 CI 里跑一次并发压测后断言runtime.NumGoroutine()回到基线,或者在生产监控里对 goroutine 数量设置增长率告警。只有闭环的防御才能让一个坑只踩一次,而不是每个月都要重新排察一遍。

最后还是分享一个让我印象深刻的经验:有一次排查某个数据管道服务的泄漏,goroutine dump 里显示几千个协程都卡在同一个 channel 发送上,我花了大半天检查 channel 的读写逻辑都没找到问题,最后发现是上游一个微服务超时时间设得太短,导致大量请求被快速取消,而当前服务处理这些请求的协程还在等下游结果。问题根因不在当前服务内部,而在服务间交互的超时配置上。所以排查协程泄漏时,视野一定要放宽,不只看当前进程的代码,还要考虑上下游依赖的调用链语义。这个案例之后,我再排查并发问题时,都会习惯性先看一眼外部依赖的超时配置,很多时候问题不在你想的那个地方。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询