本节要回答:
goleak凭什么能发现泄漏、它怎么区分「真泄漏」和「还没退出的 goroutine」、在测试生命周期里该挂在哪一层。与卷二 9.3 的分工:卷二讲「项目里怎么用 goleak 防回归」,本节讲它的检测原理、过滤器机制与假阳性边界。
适用版本:Go 1.27(实测go1.27.0),go.uber.org/goleak v1.3.0。
10.3 goleak 与泄漏检测
上一节把结构化并发讲成了一条「靠人守」的纪律,但纪律不可靠。goleak 的价值就在于:它把「有没有泄漏」变成一条可自动判定、可进 CI 的断言。用起来很简单(defer goleak.VerifyNone(t) 一行),但要用得准,必须知道它内部在做什么——否则你会被它的假阳性搞到崩溃,或者被它漏掉的场景骗过去。
10.3.1 检测原理:抓全量栈再筛
goleak 没有任何「魔法」,它的检测全靠 runtime.Stack:
// go.uber.org/goleak v1.3.0 内部实现(internal/stack/stacks.go)
func All() []Stack {
return getStacks(true) // all=true,抓全部 goroutine 的栈
}
func Current() Stack {
return getStacks(false)[0] // all=false,只抓当前 goroutine
}
func getStackBuffer(all bool) []byte {
for i := _defaultBufferSize; ; i *= 2 {
buf := make([]byte, i)
if n := runtime.Stack(buf, all); n < i {
return buf[:n] // 缓冲区不够就翻倍重试
}
}
}
Find 的流程是三步:
- 记录当前 goroutine 的 ID(
stack.Current().ID())。 - 用
stack.All()抓取全部 goroutine 的栈文本。 - 逐个过滤:跳过当前 goroutine、跳过默认过滤器命中的、跳过用户
Ignore*命中的;剩下的就是「意外的 goroutine」。
// leaks.go
func Find(options ...Option) error {
cur := stack.Current().ID()
opts := buildOpts(options...)
var stacks []stack.Stack
retry := true
for i := 0; retry; i++ {
stacks = filterStacks(stack.All(), cur, opts)
if len(stacks) == 0 {
return nil // 干净,直接过
}
retry = opts.retry(i) // 还有剩余就按退避策略再试
}
return fmt.Errorf("found unexpected goroutines:\n%s", stacks)
}
关键点:goleak 是靠「解析栈文本」判断的,不是靠运行时 API 直接枚举 goroutine。这意味着它拿不到的信息(栈里没体现的)就判断不了,也意味着它看到的就是「此刻每个 goroutine 卡在哪一行」——这恰好是排障最需要的信息。
10.3.2 实测:泄漏输出怎么读
一个故意泄漏的测试,goleak 报出来的东西非常具体:
func TestLeak(t *testing.T) {
ch := make(chan struct{})
go func() { <-ch }() // 永久阻塞,泄漏
time.Sleep(20 * time.Millisecond)
}
$ GOTOOLCHAIN=go1.27.0 go test ./ch10/goleakdemo/ -run TestLeak
PASS
goleak: Errors on successful test run: found unexpected goroutines:
[Goroutine 8 in state chan receive, with probe/ch10/goleakdemo.TestLeak.func1 on top of the stack:
probe/ch10/goleakdemo.TestLeak.func1()
/tmp/gbadv4/ch10/goleakdemo/leak_test.go:22 +0x24
created by probe/ch10/goleakdemo.TestLeak in goroutine 7
/tmp/gbadv4/ch10/goleakdemo/leak_test.go:22 +0x74
]
FAIL probe/ch10/goleakdemo 1.068s
FAIL
输出里有四个信息量极大的字段,逐个读:
| 字段 | 值 | 含义 |
|---|---|---|
Goroutine 8 | ID | goroutine 编号,用于多泄漏时区分 |
state chan receive | 状态 | 阻塞在 channel 接收上(泄漏的典型状态之一) |
on top of the stack | 栈顶函数 | 当前执行到哪个函数 |
created by ... in goroutine 7 | 创建者 | 谁起了它,这是定位泄漏源的关键 |
注意第一行是 PASS 然后才 FAIL:goleak 是在测试正常通过之后才做检查的,所以是「测试通过但检测到泄漏」,最终整体 FAIL。这正是它作为「兜底断言」的定位——不干扰正常断言,只在收尾时补一刀。
常见泄漏状态对照:
| 状态 | 通常对应 | 排查方向 |
|---|---|---|
chan receive | 等一个永不到来的值 | 检查 channel 是否有人 close/发送 |
chan send | 发一个没人接收的值 | 检查缓冲容量与消费者 |
select | 等多个 channel,全都没就绪 | 检查是否有 ctx.Done() 兜底 |
sleep | time.Sleep 无 ctx 监听 | 换成 select + ctx.Done() |
sync.Mutex.Lock | 死锁或锁未释放 | 检查 defer Unlock |
10.3.3 retry 机制:为什么不会误杀短命 goroutine
goleak 最容易被误解的地方是「它怎么知道某个 goroutine 只是慢、不是泄漏」。答案是重试:
// options.go
const _defaultRetries = 20
// retry 的退避:1µs << i,上限 100ms
func (o *opts) retry(i int) bool {
if i >= o.maxRetries {
return false
}
d := time.Duration(int(time.Microsecond) << uint(i))
if d > o.maxSleep {
d = o.maxSleep
}
time.Sleep(d)
return true
}
Find 发现「还有可疑 goroutine」时不会立刻报错,而是按 1µs、2µs、4µs…… 的指数退避再抓一次,最多重试 20 次。实测:一个 50ms 后才退出的 goroutine 不会被误报:
func TestTransient(t *testing.T) {
defer goleak.VerifyNone(t)
done := make(chan struct{})
go func() {
time.Sleep(50 * time.Millisecond)
close(done)
}()
<-done
}
$ GOTOOLCHAIN=go1.27.0 go test ./ch10/goleakopts/ -run TestTransient -v
=== RUN TestTransient
--- PASS: TestTransient (0.05s)
PASS
退避总时长:1µs << i 累加到 i≈17 时达到 100ms 上限,之后 3 次都是 100ms,合计约 430ms。这就是为什么有泄漏的用例普遍耗时 0.4~1.0 秒——那段时间全花在重试上。理解这一点很重要:如果测试里有个异步收尾需要超过 430ms,goleak 会误报。此时要么让收尾更快,要么调大重试(goleak 只对内部暴露了 maxRetries,公开 API 里没有直接的「延长」选项,只能靠 IgnoreAnyFunction 或把检查点后移)。
10.3.4 默认过滤器:系统 goroutine 不会误报
buildOpts 装入了四个默认过滤器,把 Go 运行时/testing 框架自己的 goroutine 排除掉:
opts.filters = append(opts.filters,
isTestStack, // testing.RunTests、(*T).Run、(*T).Parallel 等
isSyscallStack, // 卡在 syscall 上的运行时线程
isStdLibStack, // 标准库自己的后台 goroutine
isTraceStack, // runtime/trace 相关
)
isTestStack 的判据是栈顶函数名:testing.RunTests、testing.(*T).Run、testing.(*T).Parallel、testing.runFuzzing、testing.runFuzzTests。这就是为什么并行测试(t.Parallel)产生的 goroutine 不会被误判。默认过滤器覆盖不到的是「你自己的长生命周期 goroutine」(连接池、日志 flush、监控上报),这些必须靠显式 Ignore* 或托管其生命周期来解决。
10.3.5 三个 Ignore 选项的精确语义
三个过滤选项名字相近,语义差别很大:
| 选项 | 判据 | 适用 |
|---|---|---|
IgnoreTopFunction(f) | 栈顶函数名 == f | 已知固定阻塞点(如 time.Sleep) |
IgnoreAnyFunction(f) | f 出现在栈的任意位置 | 自己的函数被间接调用时 |
IgnoreCurrent() | 创建选项时已存在的 goroutine | 测试前就有的后台 goroutine |
实测一个坑:想忽略「自己的 backgroundLoop 起的 goroutine」,用 IgnoreTopFunction("...backgroundLoop.func1") 不生效,因为该 goroutine 阻塞在 time.Sleep 上,栈顶是 time.Sleep 而不是 backgroundLoop.func1:
func backgroundLoop() {
go func() { for { time.Sleep(time.Hour) } }()
}
func TestIgnored(t *testing.T) {
backgroundLoop()
defer goleak.VerifyNone(t,
goleak.IgnoreAnyFunction("probe/ch10/goleakopts.backgroundLoop.func1")) // 用 Any,不是 Top
}
$ GOTOOLCHAIN=go1.27.0 go test ./ch10/goleakopts/ -run TestIgnored -v
=== RUN TestIgnored
--- PASS: TestIgnored (0.01s)
PASS
换成 IgnoreAnyFunction 后通过。经验法则:除非你确定 goroutine 就停在某个已知函数上,否则优先用 IgnoreAnyFunction;它按「函数名是否出现在栈中」匹配,更稳。IgnoreCurrent() 则适合「测试开始前就已经存在的全局后台 goroutine」,它记录创建时刻的所有 goroutine ID 并全部排除,避免被全局状态干扰。
10.3.6 挂在测试生命周期的哪一层
goleak 有两个入口,取舍很清楚:
| 入口 | 挂载位置 | 检查时机 | 并行测试 |
|---|---|---|---|
VerifyNone(t) | 单个测试内 defer | 每个测试结束 | 不兼容 t.Parallel |
VerifyTestMain(m) | TestMain | 全部测试结束后 | 兼容 |
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m) // 所有测试跑完统一检查一次
}
VerifyNone 的文档明确写了它不兼容 t.Parallel:因为它无法把「某个泄漏的 goroutine」归因到「某个具体的测试」,并行测试里别的测试的正常 goroutine 会被当成泄漏。所以:
- 测试少、想精确定位:用
VerifyNone(t),泄漏会归因到具体测试函数。 - 测试多、开了并行:用
VerifyTestMain(m),全局检查一次,代价是「只能知道有泄漏,不能直接知道哪个测试漏的」。
两者可以叠加:VerifyTestMain 做全局兜底,关键测试再各自 VerifyNone 做精确定位。但要小心叠加带来的归因困难——全局检查失败时,得靠二分法或逐个禁用测试来定位。
10.3.7 局限与假阳性
goleak 不是万能的,边界必须清楚:
- 只能发现「还在运行」的 goroutine:一个泄漏的 goroutine 若恰好处于「可被 GC 回收」的假死状态,未必报得出来;它检查的是「活着的栈」,不是「引用计数」。
- 无法归因到测试(并行场景):
VerifyTestMain只说「有泄漏」,不给「哪个测试」。 - 误报来自超时:异步收尾超过约 430ms 的重试窗口,会被当成泄漏。
- 全局状态干扰:包级
init()起的后台 goroutine、连接池的 keepalive,都会被当成泄漏,需要IgnoreCurrent/IgnoreAnyFunction排除。 - 不能替代
-race:goleak管「泄漏」,-race管「数据竞争」,两回事,都要开。
10.3.8 CI 集成
goleak 最大的价值在 CI:把「泄漏」从「上线后 OOM 才发现」提前到「PR 阶段就失败」。落地方式:
# CI 里跑,泄漏即非零退出码
GOTOOLCHAIN=go1.27.0 go test -race -count=1 ./...
要点:
-count=1必须加:否则命中缓存直接返回上次结果,goleak 根本不执行。-race与goleak一起开:一个查竞争、一个查泄漏,成本可接受。- 每个包都放
TestMain:goleak 是包级的,新包容易漏挂。 - 失败信息进构建日志:goleak 的输出含
created by,PR 里能直接看到泄漏源,review 效率高。
小结
goleak的原理是runtime.Stack(buf, true)抓全量栈文本,过滤掉当前 goroutine 与系统栈,剩下的即泄漏。- 输出里的
state、栈顶函数、created by三样信息,直接给出「卡在哪、谁起的」。 - 靠 20 次指数退避(约 430ms 窗口)容忍短命 goroutine;收尾超过这个窗口会误报。
- 默认过滤器排除 testing/运行时 goroutine;自己的后台任务要用
Ignore*,且优先用IgnoreAnyFunction。 VerifyNone精确但怕并行,VerifyTestMain兼容并行但归因粗;CI 里务必带-count=1。
至此第 10 章结束。goleak 把「并发纪律」变成了可自动判定的断言,但工程体系的另一半——代码怎么组织、依赖怎么装配、发布怎么保证——同样需要一套模型。下一章从 monorepo 与 go.work 开始。
阅读导航:上一节:10.2 结构化并发模型 · 下一节:11.1 monorepo 与 go.work 多模块 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。