文章目录
- goroutine 泄漏的四个真实场景,以及如何用 pprof 一次定位
- 先说结论
- 我遇到的事
- 为什么会泄漏:goroutine 只有退出才会释放
- 场景一:往无缓冲 channel 发送,但没人接收
- 场景二:从 channel 接收,但 channel 永远不会关闭
- 场景三:context 创建了,但没有真正传播进去
- 场景四:Ticker 用完没 Stop
- 怎么定位:从打点到抓栈
- 第一步:确认 goroutine 数量在涨
- 第二步:开 pprof
- 第三步:抓 goroutine 栈快照
- 第四步:用 go tool pprof 交互式分析
- 常见泄漏特征对照
- 两个防复发的手段
- 用 errgroup 管理 goroutine 组
- 测试里加 goleak
- 排查速查表
- 小结
goroutine 泄漏的四个真实场景,以及如何用 pprof 一次定位
摘要:goroutine 泄漏不会让程序立刻崩,它只是让内存缓慢上涨、接口越来越慢,直到某天 OOM。这篇记录我排查这类问题的完整过程:四个最常见的泄漏场景(channel 发送无人接收、channel 接收永不关闭、context 没有传播到 goroutine 内部、Ticker 未 Stop),每种都配可复现代码。然后讲怎么用 runtime.NumGoroutine 打点、用 pprof 抓 goroutine 栈快照、看栈底停在哪个调用上定位泄漏点,最后给出 errgroup 和 goleak 两个防复发手段。
标签:go · golang · goroutine · pprof · 并发
专栏:Go 学习笔记
先说结论
- goroutine 泄漏的本质:启动的 goroutine 永远阻塞在某个操作上,既退不出去,也无法被 GC 回收——它占着栈(初始 2KB,会增长)和它闭包引用的所有对象。
- 泄漏点几乎总能从栈底停在哪看出来:
chan receive、chan send、semacquire、select。 - 排查三板斧:
runtime.NumGoroutine()打点看趋势 → pprof 抓 goroutine 栈 → 对比两个时间点的快照找持续增长的那条路径。 - 防复发:
errgroup管理 goroutine 组,测试里加goleak。
我遇到的事
那次是线上一个常驻服务,跑着跑着内存就往上涨,重启之后又从低位开始爬,曲线像个锯齿。接口响应时间也在慢慢变差。日志没有任何报错。
我一开始以为是内存泄漏,去抓 heap profile,看到大对象分布很均匀,没发现明显的"某个结构体在堆积"。后来才反应过来:heap 上不去,是因为被"钉住"的对象分散在每个 goroutine 的栈上——真正的元凶是 goroutine 数量在涨。
runtime.NumGoroutine()一打点,从启动时的 12 个,两天涨到了 1 万多个。
这就是 goroutine 泄漏的典型画像:不报错、不崩溃、慢慢耗死你。
为什么会泄漏:goroutine 只有退出才会释放
Go 的 goroutine 没有"超时自动回收"机制。一个 goroutine 只有在函数返回或未恢复的 panic时才会结束。只要它卡在下面这些操作上,就会一直存在:
chan receive ← 从 channel 接收,但没有数据也没有关闭 chan send ← 往 channel 发送,但没有接收方(或缓冲区满) semacquire ← 等在锁上 select ← 所有 case 都不可达,且没有 default而且它不只是占 2KB 栈。闭包捕获的所有变量都无法被 GC 回收——如果那个闭包引用了一个大切片或者一个连接对象,泄漏的就是那块内存。
场景一:往无缓冲 channel 发送,但没人接收
最经典的一种。
funcleak(){ch:=make(chanint)// 无缓冲gofunc(){ch<-42// 永久阻塞:没有接收者}()// ch 随函数返回而不可达,但那个 goroutine 永远卡在发送上}无缓冲 channel 的发送必须等到有接收方 ready。函数返回后ch再也没有人引用,接收永远不可能发生,这个 goroutine 就永久卡死了。
变体:缓冲区满了也一样。
ch:=make(chanint,1)ch<-1ch<-2// 缓冲满,阻塞修复:确保发送有配套的接收方,并在不再发送时由发送方关闭 channel;或者给发送加select+ctx.Done()兜底。
funcfixed(ctx context.Context){ch:=make(chanint)gofunc(){deferclose(ch)select{casech<-42:case<-ctx.Done():return}}()}场景二:从 channel 接收,但 channel 永远不会关闭
这个比场景一更常见,因为它长得很像正常代码。
funcconsume(chchanint){gofunc(){forv:=rangech{// 只有 ch 被 close,这个循环才会结束process(v)}}()}for range ch在 channel关闭时才会退出。如果生产者在所有数据发完之后忘了close(ch),这个消费者 goroutine 就会永远挂在chan receive上。
单看这段代码完全没问题——问题在生产端。这是个跨函数的契约:谁负责关 channel。
修复:明确 channel 的关闭责任在发送方。
funcproduce()<-chanint{ch:=make(chanint)gofunc(){deferclose(ch)// 发送方负责关闭fori:=0;i<10;i++{ch<-i}}()returnch}如果确实需要"发完就结束但不确定什么时候发完",用context控制消费者退出,而不是依赖close。
场景三:context 创建了,但没有真正传播进去
这是最隐蔽的一种,因为它看起来"已经用了 context"。
funchandler(ctx context.Context){ctx,cancel:=context.WithTimeout(ctx,3*time.Second)defercancel()gofunc(){doSlowWork()// 完全没用到 ctx!}()}WithTimeout创建了带超时的 context,defer cancel()也写了。但 goroutine 内部根本没监听ctx.Done()——超时到了,context 取消了,goroutine 该干嘛还干嘛。
context 只是一个信号载体,不会主动中断任何代码。必须由被控制的代码自己去select它。
修复:慢操作必须在 select 里和ctx.Done()竞争。
funchandler(ctx context.Context)error{ctx,cancel:=context.WithTimeout(ctx,3*time.Second)defercancel()done:=make(chanerror,1)gofunc(){done<-doSlowWork()}()select{caseerr:=<-done:returnerrcase<-ctx.Done():returnctx.Err()// 超时返回,goroutine 写完 done 后自然退出}}注意done用了容量为 1 的缓冲。这很关键——如果done是无缓冲的,超时分支执行后没人再接收,写done的那个 goroutine 就泄漏了(正好是场景一)。
另外注意:context.WithCancel/WithTimeout返回的cancel必须调用,通常用defer cancel()。不调用的话,父 context 会一直持有这个子 context 的引用,相关的 timer 也不释放。
场景四:Ticker 用完没 Stop
funcmonitor(){ticker:=time.NewTicker(time.Second)// 忘了 ticker.Stop()for{select{case<-ticker.C:check()case<-done:return// 退出了,但 ticker 还在跑}}}time.Ticker底层有一个运行时定时器在持续往 channel 里发信号。函数返回后如果没Stop(),这个 timer 不会被 GC(因为它被运行时的定时器堆引用着),而且它持有的 channel 和闭包也一起留着。
修复:defer ticker.Stop()是标配。
funcmonitor(){ticker:=time.NewTicker(time.Second)deferticker.Stop()// ...}顺带说time.After:在循环里用time.After会每次创建一个新 Timer。
for{select{casev:=<-ch:handle(v)case<-time.After(time.Second):// 每轮一个新 Timertimeout()}}- Go 1.23 之前:未触发的 Timer 不会被 GC 回收,短超时的循环里会堆积大量 Timer,既是内存泄漏也是性能问题。正确做法是循环外创建
time.NewTimer,每轮Reset。 - Go 1.23 及之后:运行时改为不可达的未触发 Timer 可被 GC 回收,这个模式的危害大幅降低。但写多版本兼容的代码时仍然建议用
NewTimer+Reset。
怎么定位:从打点到抓栈
第一步:确认 goroutine 数量在涨
在程序里暴露一个打点,或者用runtime.NumGoroutine()起个定时打印:
gofunc(){ticker:=time.NewTicker(10*time.Second)deferticker.Stop()forrangeticker.C{log.Printf("goroutines: %d",runtime.NumGoroutine())}}()看趋势就够了:稳定在一个区间是正常的,单调上涨就是泄漏。
第二步:开 pprof
在程序里引入 pprof 的 HTTP 接口:
import("net/http"_"net/http/pprof"// 副作用导入,自动注册 /debug/pprof/ 路由)funcinit(){gofunc(){// 务必只监听本地,不要暴露到公网log.Println(http.ListenAndServe("localhost:6060",nil))}()}安全提醒:pprof 端口绝对不要暴露到公网,它会泄露源码路径、变量值等敏感信息。
第三步:抓 goroutine 栈快照
# 文本格式,直接看每个 goroutine 的栈curl-s"http://localhost:6060/debug/pprof/goroutine?debug=1">g1.txt# 等 5 分钟sleep300# 再抓一次curl-s"http://localhost:6060/debug/pprof/goroutine?debug=2">g2.txtdebug=1会把相同栈的 goroutine 聚合,并显示数量,最适合快速判断:
10023 goroutines: ← 注意这个数字 goroutine 7821 [chan receive, 15 minutes]: main.consume.func1(...) /app/consumer.go:42 +0x5f created by main.consume /app/consumer.go:40 +0x8c看到10023 goroutines加上[chan receive, 15 minutes],泄漏点基本就锁定了——consumer.go:42那个for range ch。
关键技巧:看方括号里的状态和时长。[chan receive, 15 minutes]表示这个 goroutine 已经卡了 15 分钟。正常工作的 goroutine 状态是瞬时的,卡了几分钟以上的都有问题。
第四步:用 go tool pprof 交互式分析
go tool pprof http://localhost:6060/debug/pprof/goroutine(pprof)top10# 按 goroutine 数排前 10(pprof)traces# 打印完整调用栈(pprof)web# 生成调用图(需要 graphviz)也可以用-http起个 Web UI 看火焰图:
go tool pprof-http=:8080 http://localhost:6060/debug/pprof/goroutine常见泄漏特征对照
| 栈底状态 | 典型原因 | 对应场景 |
|---|---|---|
chan send | 发送无接收方,或缓冲已满 | 场景一 |
chan receive | channel 从未被 close | 场景二 |
select | 所有 case 阻塞,且无ctx.Done() | 场景三 |
semacquire | 锁竞争,或锁未被释放 | — |
time.Sleep长时间 | goroutine 内没响应取消 | 场景三 |
runtime.gopark+select {} | 空的select {}永久阻塞 | — |
两个防复发的手段
用 errgroup 管理 goroutine 组
errgroup自带Wait(),能保证所有 goroutine 都退出才返回,还支持统一的取消:
import"golang.org/x/sync/errgroup"funcrun(ctx context.Context)error{g,ctx:=errgroup.WithContext(ctx)fori:=0;i<10;i++{i:=i g.Go(func()error{returnworker(ctx,i)// worker 内部必须响应 ctx})}returng.Wait()// 等全部结束,任一出错则取消其余}它不会自动治好泄漏(worker 内部还是得响应ctx),但让"有没有退干净"变成一个可检查的边界。
测试里加 goleak
goleak在测试结束时检查有没有残留的 goroutine,有就直接让测试失败:
import"go.uber.org/goleak"funcTestMain(m*testing.M){goleak.VerifyTestMain(m)}也可以在单个测试里用:
funcTestWorker(t*testing.T){defergoleak.VerifyNone(t)// ...}这能把泄漏拦在 CI 阶段,比上线后靠内存曲线发现要早得多。
排查速查表
| 现象 | 检查项 |
|---|---|
| 内存缓慢上涨,heap 看不出问题 | 打runtime.NumGoroutine()看趋势 |
| 接口越来越慢但 CPU 不高 | 可能是锁竞争,抓block/mutexprofile |
栈停在chan send | 无接收方,或缓冲满 |
栈停在chan receive | channel 从未 close,责任方在发送端 |
用了WithTimeout却没生效 | goroutine 内部没select ctx.Done() |
cancel没 defer | 子 context 与 timer 不释放 |
| Ticker 相关泄漏 | 缺defer ticker.Stop() |
补充:block和mutexprofile 默认关闭,需要主动开启:
runtime.SetBlockProfileRate(1)// 记录所有阻塞事件runtime.SetMutexProfileFraction(1)// 记录所有锁竞争小结
goroutine 泄漏的核心就一句话:每个go出去的 goroutine,都必须有一条确定的退出路径。
写的时候养成四个习惯,基本不会踩:
- 启动 goroutine 时先想清楚它怎么退出——靠 close、靠 ctx、还是靠 done channel;
- channel 的
close责任在发送方,for range ch一定要有对应的 close; - 用了
context就必须在 goroutine 内部select ctx.Done(),并且defer cancel(); Ticker一律defer Stop()。
真出了事也别慌——NumGoroutine()看趋势,pprof 抓栈,找那个卡了几十分钟、状态是chan receive或chan send的,十有八九就是它。
上一篇:[Go map 为什么不能并发读写:fatal error 背后的扩容与渐进搬迁]