《Go 语言高级编程》10.3 goleak 与泄漏检测

卷二 9.3 讲过 goleak 的工程用法,本节讲检测原理与测试生命周期:它用 runtime.Stack(all=true) 抓全量栈、靠 20 次指数退避重试容忍短命 goroutine、用四类默认过滤器排除系统栈,以及 VerifyNone 与 VerifyTestMain 的取舍和 CI 集成方式。

本节要回答: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 的流程是三步:

  1. 记录当前 goroutine 的 ID(stack.Current().ID())。
  2. 用 stack.All() 抓取全部 goroutine 的栈文本。
  3. 逐个过滤:跳过当前 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 8IDgoroutine 编号,用于多泄漏时区分
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() 兜底
sleeptime.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 多模块 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练