Skip to content
Published on

Go 1.26 的 goroutineleak profile — 用 GC 的标记阶段抓 goroutine 泄漏

分享
Authors

引言 — goroutine 泄漏为什么一直没有出现在 profile 里

调试过 goroutine 泄漏的人都懂这种挫败感。服务的内存持续走高。你拉一个 goroutine profile。里面有 4 万个 goroutine。到底哪几个是永远不会醒来的?

profile 回答不了这个问题。goroutine profile 只是把每个 goroutine 的栈都转储出来,其中出现的 [chan receive] 并不会区分「200 毫秒后就会醒来的正常 worker」和「抓着一个没人引用的通道永远沉睡的僵尸」。两者都同样阻塞在通道接收上。Go 1.26 提案的说法很到位 — goroutine 泄漏出了名地难调试,甚至在拥有内存、goroutine profile 这类相当详尽的诊断信息的情况下,有时也很难察觉它的存在。

所以实践中,大家一直把 uber-go/goleak 这类工具接到测试里 — 在测试前后比较 goroutine 的栈,抓出还留着的那些。这个办法效果不错,但只在测试里管用。生产环境里真正在泄漏的 goroutine,依然是够不着的。

Go 1.26 于 2026 年 2 月 10 日发布,给这个空白补上了一个工具 — runtime/pprof 里新的 profile 类型,goroutineleak

Go 1.26 添加了什么

先看发布说明的定义。泄漏的 goroutine 是指「阻塞在某个并发原语 — 通道、sync.Mutexsync.Cond 等 — 上、且永远不可能解除阻塞的 goroutine」。这里重要的是「永远不可能」— 不是它此刻正阻塞着这个事实,而是在未来任何一种执行下都不会醒来这个事实。

运行时具体怎么判定,发布说明直接给出了解释。如果 goroutine G 阻塞在并发原语 P 上,而 P 从任何可运行的 goroutine、以及这些 goroutine 能唤醒的任何 goroutine 出发都不可达,那么 P 就永远不可能解除阻塞,因此 G 也永远不会醒来。

在 Go 1.26 里这是实验性功能,需要在构建时开启。

# 开启该 profile 后构建/运行
GOEXPERIMENT=goroutineleakprofile go run ./cmd/server

# 不带实验标志构建时,pprof.Lookup("goroutineleak") 会返回 nil

开启这个实验标志后,net/http/pprof 的端点 /debug/pprof/goroutineleak 也会一并注册。

发布说明给这个功能附带的两条注记很值得注意。第一,实现本身已经生产就绪,之所以归类为实验性,纯粹是为了收集关于 API 的反馈 — 具体来说,是想确认把它做成一个新的 profile 类型这个选择是否正确。第二,设计上保证只要没有实际使用,就不会带来额外的运行时开销。也就是说,只要开着但不去拉取 profile,基本上就是免费的。

Go 1.27 草案发布说明 已经这样写道 — goroutineleak profile 现已正式可用(generally available),goroutineleakprofile 这个 GOEXPERIMENT 设置已被移除。Go 1.27 预计在 2026 年 8 月发布,所以这个功能下个月就会直接处于开启状态。这就是现在值得理解它的原因。

核心思路 — 把可达性当作存活性的近似

这个功能漂亮的地方就在这里。没有新建任何追踪基础设施,它复用的是已经存在的 GC 标记

逻辑是这样的。假设 goroutine G 阻塞在通道 ch 上。要唤醒 G,就得有人向 ch 发送数据或者关闭它。而要做到这一点,那个人必须能拿到 ch 的一个引用。而能拿到引用这件事,恰好就是 GC 所说的内存可达性

所以用论文的说法,内存可达性对并发操作的存活性做了一个可靠的过近似(soundly over-approximate)。「过近似」这个词是关键 — GC 判定为「可达」的集合,一定不遗漏地包含所有真正存活的 goroutine。不会出现明明活着却被误判为不可达的情况。

这为什么重要,是因为这个方向上的可靠性带来了没有假阳性这一保证。提案明确写道 — 该方法在理论上是可靠的,也就是说不存在假阳性。如果 profile 说「这个 goroutine 泄漏了」,那它就真的泄漏了,不是猜测。

论文也解释了这个保证为什么是必需的。假阳性可能导致仍在使用中的内存被释放,从而引发意外崩溃。由于这项技术是在 GC 内部运行的,一旦判断出错,那就不只是一份误报,而是一次内存安全事故。可靠性不是可选项,而是硬性要求。

算法 — 围绕标记阶段的六个步骤

官方提案 把对 GC 周期的改动写成了六个步骤,概括如下。

  1. 准备标记根集合。 通常的 GC 会把所有 goroutine 都当作根。泄漏检测周期只把可运行的(runnable)goroutine 当作根。这一行改动就是一切的起点。
  2. 标记。 从这组根出发,标记可达的内存。
  3. 检查。 标记结束后,查看是否还有未被标记的 goroutine,阻塞在第 2 步中被标记的并发原语上。
  4. 提升。 把这类 goroutine 视为「最终会变为可运行」(eventually runnable),加入根集合,回到第 2 步继续标记。
  5. 不动点与报告。 重复这个过程直到达到不动点,然后把最终没能进入根集合的 goroutine 报告为泄漏。接着,再以泄漏的 goroutine 为根,多标记一轮,让所有可达的内存都像普通 GC 一样被标记上。
  6. 清扫(sweeping) 照常进行。

第 3、4 步的循环是核心。它会沿着「A 唤醒 B,B 唤醒 C」这样的链条一直追到不动点。如果只标记一轮就结束,就会把「此刻阻塞着但很快会醒来的 goroutine」误判为泄漏。这个循环正是消除假阳性的机制。

第 5 步的最后一句也不能一带而过。后面还会再谈到,泄漏 goroutine 占用的内存不会被回收。只是报告出来,标记照样保留。

实现层面还有一个细节值得一提。要让这套方法站得住脚,某些指针必须只被有条件地追踪。*sudog 对象(阻塞的 goroutine 挂在等待队列上时用的结构体)通过 allgssemtable 是全局可达的,如果放任不管,GC 就会把那些尚未被判定为可达的通道也一并标记上。为此,运行时引入了可能可追踪指针(maybe-traceable pointer) 这个概念,在泄漏检测周期期间,把 *sudog 相应字段临时置为不可追踪状态。这不是一个凭空得到的功能,而是相当小心地改动了运行时内部才做出来的。

动手试一下

来构造一个典型的泄漏。

package main

import (
	"os"
	"runtime/pprof"
	"time"
)

func leak() {
	ch := make(chan int) // 无缓冲通道
	go func() {
		<-ch // 没有人发送 -> 永远阻塞
	}()
	// ch 在这里离开作用域,任何可运行的 goroutine 都不再引用它
}

func main() {
	leak()
	time.Sleep(100 * time.Millisecond) // 给 goroutine 一点时间真正阻塞住

	f, _ := os.Create("leak.pprof")
	defer f.Close()
	pprof.Lookup("goroutineleak").WriteTo(f, 1) // debug=1: 人类可读的摘要
}

GOEXPERIMENT=goroutineleakprofile go run main.go 跑起来,泄漏就会被抓到。不带实验标志跑的话,pprof.Lookup("goroutineleak") 会返回 nil,随即触发 panic,要小心。

debug 级别的含义在提案里有定义 — debug < 2 时,只过滤出处于泄漏状态的 goroutine;debug >= 2 时,会得到所有 goroutine 的完整栈转储。而 debug=0 时,输出的是和普通 profile 一样经过 gzip 压缩的 protobuf,可以用 go tool pprof 打开。

如果是服务端,HTTP 端点用起来更方便。

# import net/http/pprof 后会自动注册到默认的 mux 上
curl 'localhost:6060/debug/pprof/goroutineleak?debug=1'

# 也可以直接用 go tool pprof 连上去
go tool pprof -top 'http://localhost:6060/debug/pprof/goroutineleak'

debug=2 的输出和熟悉的 goroutine 转储几乎一样,只是状态那一行多了一个标签。

goroutine 21 [chan receive (leaked)]:
main.leak.func1()
	/path/main.go:13 +0x6c

(leaked)。这一个词就是这个功能的全部。以前的 goroutine 转储里只能看到 [chan receive],没法判断它是正常的还是僵尸,现在运行时替你做了这个判断。

这里有一个重要的行为要注意 — 拉取 profile 这个动作,并不只是读一份快照。根据提案,提取 profile 会先把一次泄漏检测 GC 周期排进队列并等它完成,然后再拉取 goroutine profile,过滤出泄漏状态的部分。也就是说,一次 curl /debug/pprof/goroutineleak 会强制触发一次特殊的 GC 周期。这不是那种接到仪表盘上每 10 秒拉一次的东西。

抓不住什么 — 两种假阴性模式

现在轮到诚实的部分了。没有假阳性这个保证的另一面,是假阴性可以要多少有多少这个事实。发布说明也没有隐瞒这一点 — 在所有情况下检测出永久阻塞的 goroutine 是不可能的,这种方法能检测出这类泄漏中的一大类。是「一大类」,不是「全部」。

提案用一句话总结了这个局限 — 堆资源在内存中相互暴露得越多,也就是彼此越可达,效果就越差。这在实际代码里到底意味着什么,论文用在真实代码中发现的两种模式做了说明。

第一,全局通道。 如果一个通道挂在全局变量上,它在内存里就始终可达。因此阻塞在这个通道上的 goroutine,会一直被归类为「将来有可能醒来」— 哪怕实际上根本没有人会再用这个通道。

var gch = make(chan int) // 全局 -> 始终可达

func neverDetected() {
	go func() {
		<-gch // 是真正的泄漏,但 profile 抓不到
	}()
}

发布说明也说了同样的话 — 由于这项技术建立在可达性之上,它可能无法识别出那些因阻塞在「通过全局变量、或某个可运行 goroutine 的局部变量可达的并发原语」上而产生的泄漏。

第二,这种更棘手 — 论文里称之为「失控的存活 goroutine」(runaway live goroutines)。下面是论文从真实代码里抽出来的例子。

type dispatcher struct {
	ch    chan struct{}
	ticks int
}

func newDispatcher() *dispatcher {
	d := &dispatcher{ch: make(chan struct{})}
	go func() { // 心跳 goroutine
		for ; ; time.Sleep(time.Second) {
			d.ticks++ // 一直抓着 d 不放
		}
	}()
	return d
}

func main() {
	d := newDispatcher()
	go func() { d.ch <- struct{}{} }() // 这个 goroutine 可能会泄漏
	if someCondition {
		return // 没有人接收 d.ch
	}
	<-d.ch
}

看看这里发生了什么。心跳 goroutine 因为在 time.Sleep 循环里转,所以始终可运行。而它引用着 d,于是 d 就是可达的,因此 d.ch 也是可达的。站在 GC 的角度,它只能得出「心跳 goroutine 说不定哪天就会向 d.ch 发送数据」这个结论 — 尽管心跳其实只碰 d.ticks,压根不看 d.ch。因为可达性是按对象算的,不是按字段算的。

这种模式之所以棘手,是因为心跳、指标上报、后台刷新这类 goroutine,正是写得规规矩矩的服务里常见的结构。只要有一个这样的 goroutine 抓着某个结构体不放,挂在同一个结构体上的通道泄漏,就会整个变成隐形的。你的代码库在这个 profile 上跑得干干净净,到底是因为真的没有泄漏,还是因为心跳把一切都遮住了,得另外想清楚。

开销 — 成本落在哪里

这里的数字要分清楚来自哪个出处。

上游说的是什么。 Go 1.26 发布说明写道,这个功能的设计目标是,只要实际没有在使用,就不会带来额外的运行时开销。没有给出具体数字。提案同样只提到了一个原型,没有附上性能数据。

论文测的是什么(作者自测,ASPLOS 25)。从这里开始,是Saioc 等人的论文用一个叫 Golf 的工具 — 一个扩展自 Go 1.22.5 GC 的实现 — 测出来的数字,而且和进入 Go 1.26 的配置并不相同(下面会解释为什么不同)。原样照搬如下:

  • 在微基准测试中,GC 标记阶段减速倍数的中位数,以正常程序为基准是 0.96 倍,也就是反而略微更快了。最好能低到 0.13 倍(876µs → 112µs)。但最坏情况下会慢到 4.8 倍(113µs → 538µs)。
  • 在有泄漏的程序上,中位数是 0.71 倍,更快,因为需要标记的内存变少了。最坏情况是 5.87 倍(136µs → 798µs)。
  • 从绝对时间看,标记阶段依然能在 10ms 内结束。正常示例下最大值是 2ms(相对于 619µs 的基线是 3.27 倍)。
  • 在受控环境下跑 Uber 的真实服务(170 万行代码)时,主要成本是 GC 暂停时间。每个 GC 周期的平均暂停时间约为基线的 2.5 倍。不过即便在 10% 泄漏的场景下,GC 总暂停时间在 30 秒运行时间里也只占 0.65 %左右。

只看减速倍数会觉得吓人,但也得同时看到绝对值是毫秒级的,而且倍数波动很大。而且这些数字全都是作者用自己的工具、在自己的基准测试上测出来的。

从实务角度要记住的事情反而很简单。拉取 profile 的那一刻,会强制触发一次特殊的 GC 周期。 不拉取的话(如果按发布说明所说的设计意图)基本就是免费的。所以成本模型不是「开着的成本」,而是「调用它的成本」。

和 goleak 的关系 — 互补,而非替代

如果你已经在用 uber-go/goleak,不需要准备换掉它。论文做了直接对比,结果相当谦虚。

在 Uber 代码库的测试套件里,goleak 报告了 29,513 个单独的泄漏(去重后 357 个)。同样的条件下,Golf 检测到了 17,872 个(60%),去重后是 180 个 — 大约是 357 个的 50 %。用论文自己的话说,goleak 在测试套件里从设计上就更有效。

再深入一点,当有缺陷的代码在测试中暴露出泄漏时,Golf 找到了 goleak 找到的东西中的 82 %(按每份报告比例的曲线下面积计算),而在去重后的 180 份报告里,有 103 份(55%)是 Golf 把 goleak 报告的每一个单独泄漏都找齐了。在微基准测试里,整体检测率达到了 94.75 %,好得多。

数字为什么会这样分化很关键。正如论文指出的,Golf 的效力取决于 GC 周期相对泄漏发生时刻和测试结束时刻,恰好什么时候运行。测试很短,GC 可能根本不会运行。而 goleak 和 GC 无关,它在测试结束的那一刻直接比较栈。

所以分工是自然而然的。

  • 测试和 CI 里用 goleak。 在短暂、确定性的环境里,栈比较抓得更细。
  • 生产环境及其周边用 goroutineleak profile。 这是 goleak 去不了的地方,而没有假阳性这个保证,恰好在 on-call 的时刻能派上用场。凌晨三点不用再重新确认「这真的是泄漏吗」,这件事比听起来更重要。

发布说明也建议在测试、CI、生产环境里都试一试,所以把这两者框成非此即彼的选择,本身就是个错误的问题。

论文和上游的差异 — 回收那部分被拿掉了

只读发布说明的话,这部分很容易被忽略,所以单独写出来。

论文的标题是 "Dynamic Partial Deadlock Detection and Recovery via Garbage Collection"。不只是检测,回收占了一半。论文里的 Golf 会在 stop-the-world 的条件下报告死锁的 goroutine,并强制终止它们,结果据报告,在 10% 泄漏的场景下,服务器内存占用降低了约 49 倍,吞吐量提升了 9%,尾延迟降低了约 1.5 倍(同样是作者自测)。

但进入 Go 1.26 的只是其中一半。再看一遍提案的第 5 步 — 报告泄漏之后,会再以泄漏的 goroutine 为根多标记一轮,让所有可达的内存都像普通 GC 一样被标记上。也就是说,上游的实现是故意让泄漏 goroutine 抓着的内存继续活着的。发布说明也只说 profile 会报告泄漏,完全没提回收内存或者杀掉 goroutine。

这与其说是妥协,不如说更像一个合理的设计决定。语言运行时擅自杀死用户的 goroutine 并回收内存,是诊断工具不该越过的一条线。不管可靠性证明有多扎实,生产运行时基于这个判断强制终止代码,和只是打印一份报告,二者的风险分量是不一样的。

实务上的含义很明确。这个 profile 不会帮你修复泄漏。 也不会把内存还给你。它只会用栈追踪告诉你泄漏在哪里,修的事还是得你自己来。不要指望 Go 1.26 能拿出论文里那个亮眼的 49 倍内存缩减 — 那是一个真正做回收的工具的数字。

什么时候该用,什么时候不该用

值回成本的场景

  • 长期运行的服务内存持续走高,但原因找不出来,尤其是 goroutine 数量也在同步增长时,这是第一个该拉的 profile。
  • on-call 时正在没有把握地猜测「这些 goroutine 是不是真的僵尸」。没有假阳性这个保证,恰恰在这个时刻能派上用场。
  • 已经把 goleak 接进了 CI,但正在追一个只在生产环境才出现的非确定性泄漏。

属于过度或者不合适的场景

  • 短小、确定性的单元测试。没有保证 GC 一定会运行,检测就会时有时无 — goleak 才是对的工具。
  • 常开的监控仪表盘。每拉一次 profile 就强制一次 GC 周期。这是事故期间拉几次的工具,不是拿来定期轮询的指标。
  • 代码库被全局通道和心跳 goroutine 织得密密麻麻。假阴性会占主导,「干净」这个结果可能什么信息都给不了你。
  • 想因为通过了这个 profile 就断定没有泄漏。这个工具不支持这样的结论。

结语

归纳一下。Go 1.26 的 goroutineleak profile 通过复用 GC 的标记阶段来检测 goroutine 泄漏。它只把可运行的 goroutine 当作根来标记,反复把阻塞在已标记原语上的 goroutine 提升为根,直到达到不动点,再把最终剩下的 goroutine 报告为泄漏。得益于「内存可达性是存活性的一个可靠过近似」这个洞察,它没有假阳性;而正因为建立在同一个可达性之上,它在全局变量和失控的存活 goroutine 面前,就会产生假阴性。

我个人喜欢这个功能,不是因为性能数字,而是因为它从一个已经存在的机制里,挖出了几乎免费的信息。GC 本来每个周期都在计算「谁能到达什么」。而认出这个计算其实也是「谁能唤醒谁」这个问题的答案 — 这项研究的全部就在于此。而这个洞察也确实从博士研究走进了 Go 运行时 — 一篇 ASPLOS 25 论文,不到一年就进了标准库。

Go 1.27 在 2026 年 8 月发布后,不用加任何标志就能直接用了。在那之前,建议现在就用 GOEXPERIMENT=goroutineleakprofile 对自己的服务拉一次试试。如果拉出了什么,那就是真的。如果什么都没有 — 那就是一个该去数一数心跳 goroutine 的理由。

参考资料