☰
goroutine 泄漏的四个真实场景,以及如何用 pprof 一次定位
2026/10/9 17:09:17 网站建设 项目流程
个人主页: > 我不会起名字322 < (欢迎各位大佬莅临😊)
其他栏目: > 技术栈学习笔记 <
其他栏目: > 力扣Hot100题目解析 <
其他栏目: > Go项目学习笔记 <
其他栏目: > redis <
其他栏目: > mysql <



文章目录

  • 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.txt

debug=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 receivechannel 从未被 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 receivechannel 从未 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,都必须有一条确定的退出路径。

写的时候养成四个习惯,基本不会踩:

  1. 启动 goroutine 时先想清楚它怎么退出——靠 close、靠 ctx、还是靠 done channel;
  2. channel 的close责任在发送方,for range ch一定要有对应的 close;
  3. 用了context就必须在 goroutine 内部select ctx.Done(),并且defer cancel();
  4. Ticker一律defer Stop()。

真出了事也别慌——NumGoroutine()看趋势,pprof 抓栈,找那个卡了几十分钟、状态是chan receive或chan send的,十有八九就是它。


上一篇:[Go map 为什么不能并发读写:fatal error 背后的扩容与渐进搬迁]

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

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

立即咨询