本节把 TaskHub 推进到「不漏资源」:goroutine 泄漏不会立刻报错,只会让内存和调度开销慢慢增长,直到某天 OOM。本节讲清泄漏的根因,并用
goleak在测试阶段就把它抓住。
适用版本:Go 1.27(实测go1.27.0),go.uber.org/goleak。
9.3 goroutine 泄漏与 goleak
第 9.1、9.2 节让并发可控了,但还有一个隐形的敌人:泄漏的 goroutine。它不像 panic 那样立刻可见,而是静静地占着内存和栈,越积越多,最终压垮进程。TaskHub 是长驻服务,一个每次请求泄漏 1 个 goroutine 的 bug,跑几天就能累积出百万 goroutine。
9.3.1 泄漏的本质:goroutine 没有退出路径
goroutine 本身很轻(初始栈几 KB),但它不会自动被回收——只要它还活着(没跑到函数末尾),就一直在。泄漏就是「起了 goroutine,却没有任何一条路径能让它退出」。
最常见的根因是在 channel 上永久阻塞:
func leak() {
ch := make(chan int)
go func() { ch <- 1 }() // 无人接收,永久阻塞在发送上
}
这个 goroutine 卡在 ch <- 1 上,永远醒不过来,因为没人读这个 channel。函数 leak 返回了,但这个 goroutine 还活着。实测连续调用 100 次:
$ go run ./ch9/leak
start goroutines = 1
after 100 leaks = 101
goroutine 数从 1 涨到 101,每次调用泄漏一个,线性增长。真实服务里如果这个 leak 在请求路径上,QPS 一上来,goroutine 数就随请求数一起涨。
9.3.2 三类常见泄漏
| 类型 | 根因 | 典型场景 |
|---|---|---|
| channel 阻塞 | 发送/接收方不存在或提前退出 | 无缓冲 channel 发送无人接收;带缓冲 channel 写满后无人消费 |
| context 未取消 | goroutine 监听 ctx.Done() 但 ctx 从不取消 | 请求结束没 cancel(),WithCancel 的 ctx 泄漏 |
| 资源未释放 | Ticker/Timer 没 Stop,rows 没 Close | 循环里 NewTicker 不 Stop,迭代器不关 |
第二类特别隐蔽:context.WithCancel 返回的 cancel 函数即使没用也必须调用,否则 context 的内部结构(挂在父 ctx 上)不会释放——这是context 泄漏,连带监听它的 goroutine 一起泄漏。标准写法:
ctx, cancel := context.WithCancel(parent)
defer cancel() // 无论如何都调用
第三类在循环里最容易犯:time.NewTicker 在循环里创建却不 Stop,每个 ticker 都挂着一个后台 goroutine(Ticker 内部有个 goroutine 负责投递),循环跑一万次就泄漏一万个。Ticker/Timer 用完必须 Stop,sql.Rows、http.Response.Body 用完必须 Close。
9.3.3 粗检:runtime.NumGoroutine
最简单的检测手段是在关键路径前后打印 goroutine 数,看它是否随请求增长:
before := runtime.NumGoroutine()
handleRequest()
time.Sleep(50 * time.Millisecond) // 等 goroutine 退出
after := runtime.NumGoroutine()
if after > before {
log.Printf("可能的泄漏: %d -> %d", before, after)
}
runtime.NumGoroutine() 返回当前 goroutine 总数,一行就能加。缺点是只给一个总数,不告诉你泄漏的是哪个 goroutine、卡在哪。生产上可以把它暴露成指标(第 10 章),看趋势——持续上升就是泄漏,稳定则健康。但精确定位还得靠 pprof 的 goroutine profile(第 12 章)或下面的 goleak。
9.3.4 goleak:在测试里抓泄漏
go.uber.org/goleak 是专门抓 goroutine 泄漏的测试工具:它在测试前后各拍一次 goroutine 快照,测试结束后如果多出不该有的 goroutine 就失败。用法是在测试开头 defer 一行:
import "go.uber.org/goleak"
func TestLeaks(t *testing.T) {
defer goleak.VerifyNone(t)
ch := make(chan int)
go func() { ch <- 1 }() // 泄漏
_ = ch
}
跑起来,泄漏被当场抓住,并且打印出泄漏 goroutine 的栈:
$ go test -run TestLeaks -v ./ch9/leaktest/
leak_test.go:17: found unexpected goroutines:
[Goroutine 8 in state chan send, with taskhub/ch9/leaktest.TestLeaks.func1 on top of the stack:
taskhub/ch9/leaktest.TestLeaks.func1()
/tmp/gbwork/ch9/leaktest/leak_test.go:15 +0x28
created by taskhub/ch9/leaktest.TestLeaks in goroutine 7
]
--- FAIL: TestLeaks
报告里最关键的两行:in state chan send 说明它卡在 channel 发送上,created by ... TestLeaks 说明是谁起的。这就是定位泄漏的直接线索。把它修对——用 context 给 goroutine 一条退出路径:
func worker(ctx context.Context, out chan<- int) {
for {
select {
case <-ctx.Done():
return // 收到取消信号就退出
case out <- 1:
}
}
}
func TestNoLeak(t *testing.T) {
defer goleak.VerifyNone(t)
ctx, cancel := context.WithCancel(context.Background())
out := make(chan int)
go worker(ctx, out)
<-out
cancel() // 让 worker 退出
time.Sleep(20 * time.Millisecond)
}
这个测试单独跑是通过的:
$ go test -run TestNoLeak -v ./ch9/leaktest/
Go test: 1 passed in 1 packages
9.3.5 一个真实的坑:测试之间的相互污染
把两个测试一起跑时,TestNoLeak 也会失败——因为 TestLeaks 泄漏的 goroutine 还活在同一个进程里,被 TestNoLeak 的 VerifyNone 也看到了:
$ go test -v ./ch9/leaktest/
--- FAIL: TestLeaks ...
--- FAIL: TestNoLeak ...
leak_test.go:39: found unexpected goroutines:
[Goroutine 6 in state chan send, with taskhub/ch9/leaktest.TestLeaks.func1 ...
这个现象本身就说明了两点:
goleak.VerifyNone是进程级的检查,看到的是整个进程的 goroutine,不区分是哪个测试泄漏的。- 所以它不能和
t.Parallel共用(并行测试的 goroutine 会互相误报);需要并行时用goleak.VerifyTestMain,它在所有测试跑完后统一检查一次。
VerifyTestMain 的用法是包一个 TestMain:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) // 所有测试结束后统一校验
}
TaskHub 的约定是:所有含并发或后台 goroutine 的包都加 TestMain + VerifyTestMain,把泄漏变成 CI 的红灯,而不是上线后慢慢爆的内存。
9.3.6 生产环境定位:pprof 的 goroutine profile
goleak 只能在测试里用。生产上 goroutine 已经涨起来了,怎么定位?用 runtime/pprof 的 goroutine profile——它按栈把当前所有 goroutine 分组,一眼就能看出「哪个函数上堆了几千个 goroutine」:
var b strings.Builder
_ = pprof.Lookup("goroutine").WriteTo(&b, 1) // debug=1 打印完整栈
实测泄漏 5 个 goroutine 后的 profile:
$ go run ./ch9/gorprof
goroutine profile: total 6
5 @ 0x1003f8748 0x100392a54 0x100392668 0x10044c628 0x1003fe854
# 0x10044c627 main.leak.func1+0x27 /tmp/gbwork/ch9/gorprof/main.go:13
关键信息有两处:total 6 是当前 goroutine 总数,5 @ ... 表示有 5 个 goroutine 卡在同一个栈上,栈顶是 main.leak.func1、位置在 main.go:13。真实服务里看到「几千个 goroutine 卡在同一个函数」,基本就锁定泄漏点了。
生产上更常用 HTTP 方式:导入 net/http/pprof 后,curl http://localhost:6060/debug/pprof/goroutine?debug=1 就能拿到同样的 profile(第 12 章会详细讲 pprof 的用法与安全暴露)。debug=2 输出带 goroutine 编号的全量栈,debug=0 输出二进制格式给 go tool pprof 分析。
NumGoroutine 看趋势(有没有涨)、goleak 抓回归(测试里有没有漏)、pprof 做定位(漏在哪个栈),三者配合才完整。
9.3.7 怎么修:给每个 goroutine 一条退出路径
修泄漏的原则是每个 goroutine 都必须有明确的退出条件。检查清单:
| 检查项 | 正确的做法 |
|---|---|
| channel 发送 | 用 select { case ch <- v: case <-ctx.Done(): } |
| channel 接收 | 用 select { case v := <-ch: case <-ctx.Done(): } |
| context | WithCancel/WithTimeout 的 cancel 必须 defer 调用 |
| Ticker/Timer | defer t.Stop() |
| 迭代器/连接 | defer rows.Close()、defer resp.Body.Close() |
| 后台 worker | 由父 ctx 控制,父取消则 worker 退出 |
| 子任务 | 用 9.1 节的 errgroup,父 Wait 保证子任务结束 |
一句话:任何 go func() 之前,先问「它什么时候退出」。答不上来,就会泄漏。
有时进程里确实有合法的长期后台 goroutine(如 metrics 上报、配置监听),它们不该被判为泄漏。goleak 提供了忽略选项:
defer goleak.VerifyNone(t,
goleak.IgnoreTopFunction("taskhub/metrics.(*Reporter).run"),
goleak.IgnoreCurrent(), // 忽略测试启动前就存在的 goroutine
)
IgnoreTopFunction 按栈顶函数名忽略,IgnoreCurrent 忽略「测试开始前就存在」的 goroutine。忽略要精确到函数名,别用宽泛的匹配把真泄漏也放过——每一条忽略规则都该在注释里写清「为什么它是合法的」。
9.3.8 常见坑
- 无缓冲 channel 发送无人接收:最常见的一号泄漏源,用
select+ctx.Done()兜底。 WithCancel的cancel不调用:context 泄漏连带 goroutine 泄漏,永远defer cancel()。- 循环里
NewTicker不Stop:每个 ticker 一个后台 goroutine,用defer或复用单个 ticker。 goleak.VerifyNone配t.Parallel:并行测试互相误报,改用VerifyTestMain。- 只在测试里抓、不在生产监控:生产要把
NumGoroutine暴露成指标,看长期趋势。 - 以为 goroutine 会随函数返回自动回收:不会,它必须自己跑到结尾或被取消。
- 把泄漏当成「迟早会被 GC」:被 channel 阻塞的 goroutine 引用着它的栈和变量,GC 回收不了。
time.After在循环里用:每次迭代创建一个 Timer,高频循环下大量 Timer 堆积,用Ticker或可复用的Timer。
小结
- goroutine 泄漏的本质是「没有退出路径」,实测 100 次泄漏让 goroutine 数从 1 涨到 101。
- 三类根因:channel 阻塞、context 未取消、资源(Ticker/Timer/Rows)未释放。
runtime.NumGoroutine()能粗检总数与趋势,但定位要靠goleak或pprof。goleak.VerifyNone在测试里抓泄漏并打印栈,是定位泄漏最快的工具;它不能配t.Parallel,并行场景用VerifyTestMain。- 修泄漏的原则:每个
go func()之前先回答「它什么时候退出」。 - 三者配合:
NumGoroutine看趋势、goleak抓回归、pprof做定位;合法后台 goroutine 用goleak的忽略选项精确排除。
到这里第 9 章讲完,TaskHub 的并发有了结构、有了保护、也有了不漏资源的保障。下一章转向可观测:结构化日志与关联 ID、指标与 SLO、链路追踪——让这套系统在出问题时能「被看见」,而不是靠猜。
阅读导航:上一节:9.2 限流、熔断与隔离 · 下一节:10.1 结构化日志与关联 ID 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。