文章目录
- 2026-09-02_Goroutine 泄漏分析
- 例子:并发工作者
- 使用goroutine泄漏分析器进行调试
- 收集profile
- 修复内存泄漏问题
- 实现
- 核心概念
- 限制
- 性能影响
- 致谢
- 致谢
2026-09-02_Goroutine 泄漏分析
Go 的并发特性强大且易于使用,但这种便利有时甚至会导致经验丰富的开发者犯错。幸运的是,Go 生态系统配备了有用的调试工具,例如竞态检测器( race detector),但即使是现有工具也可能遗漏一些并发错误,例如本文讨论的主题——goroutine 泄漏。
Goroutine 通过共享的并发原语(例如通道channel、锁lock和等待组wait group)进行同步或交换信息。在通信过程中,Goroutine 通常会因这些原语而阻塞,即等待某个条件满足;常见的例子包括等待获取已被占用的互斥锁,或通过通道接收消息。Goroutine 还可能因操作系统操作而阻塞,例如从网络套接字或文件中读取数据。
如果一个 goroutine 被阻塞,但解除阻塞所需的条件永远无法满足,我们可以认为它是泄漏的。随着时间的推移,泄漏的 goroutine 的累积会通过过度的内存使用(由泄漏的 goroutine 本身或它们所引用的内存)以及垃圾收集器的 CPU 使用来降低性能,特别是当使用 GOMEMLIMIT 时。
Goroutine 泄漏通常很难检测。在单元测试中,最重要的突破之一是开源库goleak,它可以对单个测试进行检测,在测试结束后将任何未终止的 goroutine 标记为可疑。类似地,Go 1.25 在标准库中引入了 synctest包;它可以通过让 Go 开发者对并发事件的顺序有更多控制,从而显著提高并发代码中单元测试的质量,以可靠地测试难以重现的场景。
不幸的是,两种方法都无法在生产系统中检查 goroutine 泄漏,尤其是在大规模系统中,这些系统可能表现出测试未考虑到的行为。Goroutine 分析是一种初步的检查方式,用于发现阻塞过多 goroutine 的操作或分析增长趋势。然而,goroutine 分析无法区分泄漏的 goroutine 与那些按设计暂时大量阻塞的 goroutine,例如微服务中因流量增加而导致的阻塞。同样,数量较少的泄漏可能多年未被发现。
Go 1.27 引入了goroutine 泄漏分析器,这是一种灵活且轻量的机制,用于在运行中的 Go 程序中(包括生产系统)发现 goroutine 泄漏。与以前需要人工分析的方法不同,这种机制精确且几乎不会产生误报。其权衡之处在于,它仅限于部分 goroutine 泄漏:即永久阻塞在通道或 sync 包中的原语上的 goroutine。幸运的是,这已经涵盖了很大一部分 goroutine 泄漏,正如我们在示例中将看到的那样。
例子:并发工作者
考虑一个并发处理工作项的函数:
typeresultstruct{res workResult errerror}funcprocessWorkItems(ws[]workItem)([]workResult,error){// Process work items in parallel, aggregating results in ch.ch:=make(chanresult)for_,w:=rangews{gofunc(){res,err:=processWorkItem(w)ch<-result{res,err}}()}// Collect the results from ch, or return an error if one is found.varresults[]workResultforrangelen(ws){r:=<-chifr.err!=nil{// This early return may cause goroutine leaks.returnnil,r.err}results=append(results,r.res)}returnresults,nil}由于 ch 是一个无缓冲通道,每个工作 goroutine 在发送其结果时都会阻塞,直到主 goroutine 从通道接收。如果 processWorkItems 因错误提前返回,接收循环将终止,所有剩余的发送者 goroutine 将永远阻塞。
这个例子象征性地展示了在实际的 Go 程序中,包括 Uber 生产服务中发现的一个常见错误。让我们看看如何使用新的 goroutine 泄漏分析器来发现这些泄漏。
使用goroutine泄漏分析器进行调试
该配置文件可以通过 runtime/pprof 包以 goroutineleak 配置文件类型获取,或者通过安装 net/http/pprof 包定义的配置文件处理器获取。如果您已经在服务中设置了 net/http/pprof,那么无需再做其他操作!该配置文件将在安装处理器的主机和端口上的 /debug/pprof/goroutineleak 端点自动可用于收集。
packagemainimport("errors""log""net/http"_"net/http/pprof""time")typeworkIteminttypeworkResultintfuncprocessWorkItem(w workItem)(workResult,error){time.Sleep(10*time.Millisecond)ifw==5{return0,errors.New("simulated error")}returnworkResult(w*2),nil}typeresultstruct{res workResult errerror}funcprocessWorkItems(ws[]workItem)([]workResult,error){ch:=make(chanresult)for_,w:=rangews{gofunc(){res,err:=processWorkItem(w)ch<-result{res,err}}()}varresults[]workResultforrangelen(ws){r:=<-chifr.err!=nil{returnnil,r.err}results=append(results,r.res)}returnresults,nil}funcmain(){// Start pprof servergofunc(){log.Println(http.ListenAndServe("localhost:6060",nil))}()// Repeatedly trigger the leakfor{items:=[]workItem{1,2,3,4,5,6,7,8,9,10}_,err:=processWorkItems(items)iferr!=nil{log.Printf("Error processing items: %v",err)}time.Sleep(time.Second)}}构建上面的程序,然后运行它:
$gobuild-o leaky $./leaky收集profile
程序不会花很长时间就会开始积累内存泄漏,然后您可以通过在浏览器中使用 http://localhost:6060/debug/pprof 的 Web 界面来查看这些泄漏。
或者,您可以使用 curl 收集 goroutine 泄漏分析文件,然后使用 go tool pprof 检查它:
$curlhttp://localhost:6060/debug/pprof/goroutineleak>leak.prof $ go tool pprof leak.prof Type: goroutineleak Time:2026-03-0113:19:49 UTC Entering interactive mode(type"help"forcommands,"o"foroptions)(pprof)list processWorkItems Total:116ROUTINE========================main.processWorkItems.func1in.../main.go0116(flat, cum)100% of Total..31: gofunc(){..32: res, err :=processWorkItem(w).11633: ch<- result{res, err}..34:}()分析显示在 ch <- result{res, err}(第33行)处有 goroutine 泄漏,指明了肇事的操作。值得注意的是,程序运行时间越长,泄漏的 goroutine 数量就越多。
修复内存泄漏问题
这个泄漏可以通过给 ch 提供一个缓冲区来简单修复:
ch:=make(chanresult,len(ws))这允许所有工作项的 goroutine 在 processWorkItems 提前返回的情况下发送消息而不会阻塞。
我们在本节中列出了更多的现实世界示例。
实现
本节面向那些对 goroutine 泄漏分析器底层泄漏检测工作原理感兴趣的读者。有关仅涉及性能开销和限制的详细信息,请直接跳到本节。
核心概念
让我们从一个初步观察开始:如果一个 goroutine 因某些其他 goroutine 无法访问的并发原语而被阻塞(在本例中,通过内存中的引用),那么它显然是泄漏的。这已经是一个强有力的线索,我们可以进一步将其概括为 goroutine 未泄漏时的定义,这一属性我们称为活性。我们正式定义活性,这是一种归纳性质,如下所示:
如果满足以下条件,则 goroutine 处于活动状态:
- 它没有被并发原语阻塞,或者
- 至少有一个阻塞它的并发原语被另一个活动的 goroutine 引用。
在简单情况下,未被阻塞的 goroutine 显然不会泄漏。在归纳情况下,基本假设是任何未泄漏的 goroutine 最终可能使用它引用的并发原语来解除由这些原语阻塞的其他 goroutine 的阻塞。
要找出所有存活的 goroutine,我们从明显存活且未阻塞的 goroutine 开始,并追踪它们持有的任何引用,即通过它们的局部变量,找出它们可以访问的并发原语。然后,我们逐步将任何在这些原语上被阻塞的 goroutine 也视为存活,并重复此过程,直到不再发现额外的存活 goroutine。
幸运的是,对于我们来说,Go 运行时已经通过垃圾回收器(GC)计算了内存可达性,因此下一步是将 GC 调整以适应我们的用途。你可以通过以下图表快速比较这两个 GC:
不需要对 GC 进行全面的彻底改造。Go 运行时使用并发的三色标记清除垃圾回收器,(现在还有 Green Tea 变体!),所以它的工作模式已经与我们的目标很好地对齐。只需要做一些关键的改动:
- 在初始阶段,常规垃圾回收(GC)将所有 goroutine(和全局变量)标记为可达,这样它们就永远不会被视为垃圾,即它们是标记根。我们将其修改为只包括未被阻塞的 goroutine,因为这些 goroutine 被保证是活跃的。
- 接下来是标记阶段,GC 会追踪标记根引用的对象(包括间接引用的对象),并将它们标记为可用内存。即使我们没有直接修改这一阶段,步骤 1 中的更改也隐含地确保了 GC 只标记活跃 goroutine 引用的内存。
- 标记阶段的最后一步是检查步骤 1 中未作为标记根被包含的所有阻塞的 goroutine。如果一个 goroutine 被至少一个在步骤 2 中被标记的并发原语阻塞,它将被添加为标记根,GC 将从步骤 2 继续标记阶段。这与活性定义中的归纳步骤一致。
- 一旦发现了所有活跃的 goroutine,任何未被添加为标记根的 goroutine 其状态将被设置为泄漏。
- 然后标记阶段最后一次恢复,将所有泄漏的 goroutine 添加为标记根,从而允许 GC 标记在常规运行时本会标记的所有内存。
一旦 GC 循环完成,goroutine 泄漏分析器就会像在常规 goroutine 分析中那样开始工作,并筛选出严格意义上的泄漏 goroutine。
限制
上面的例子展示了 goroutine 泄漏分析的有用性。然而,垃圾回收器存在一些限制,可能导致它遗漏泄漏:
- 内存过度使用:如果一个并发原语可以通过全局变量或可运行的 goroutine 持续访问,那么即使该并发原语在未来从未使用,阻塞在其上的 goroutine 也不会被报告为泄漏。这可以通过更好地规范对并发原语引用的访问,以及更清楚地划定它们的生命周期来缓解。
- 非标准阻塞:为了保证正确性,goroutine 泄漏检测严格限制于 Go 的一级并发原语,包括:通道发送和接收操作(包括对 nil 通道的操作)、阻塞的 select 语句,即没有 default 分支的情况,包括没有任何分支的 select 语句,以及 sync 包中的成员,具体包括 Mutex、RWMutex、WaitGroup 和 Cond。阻塞于其他原因的 goroutine,例如文件和网络 IO,或直接系统调用,从不被视为泄漏。同样,这也适用于自定义的用户定义并发,例如自旋锁,除非它们的底层实现依赖上述原语。
- 非确定性:泄漏只能在发生后检测,而无法提前预测,因此在不稳定的程序中重现和诊断泄漏仍然是一个挑战。为了获得最佳效果,我们鼓励混合使用方法,在各个层级上使用 goroutine 泄漏分析,包括生产环境,以及使用 goleak 和 synctest 仪器化的完整测试套件。
性能影响
Goroutine 泄漏检测的设计非常谨慎,以尽量减少性能影响,但仍然存在一些成本。
虽然内存开销可以忽略不计,仅限于用于记账的小量附加,但 goroutine 泄漏检测可能比常规 GC 更慢。这一点在一个我们称之为“雏菊链”的特殊案例中表现得最为明显:
在这个无泄漏的示例中,可运行的 goroutine G₀ 拥有对原始对象 P₁ 的引用,该对象会阻塞 G₁,依此类推。
这意味着,为了证明某些 Pᵢ₊₁ 的活性,需要先证明 Pᵢ 的活性,这会带来两个成本:
- GC 标记阶段相对于可以扫描的 goroutine 的顺序实际上是串行化的,因为必须在将 Pᵢ₊₁ 添加为根之前标记从某个 Pᵢ 可达的所有内存。
- 当前检查在每轮标记结束时检查所有被阻塞的 goroutine,在最坏情况下,对于一个 GC 周期,需要 O(n²) 步,其中 n 是 goroutine 的总数。
虽然第二点最终可以进行优化,但第一点是内存泄漏检测的固有限制,无法规避。
无论如何,我们要提醒读者,除非通过运行时标志另行配置,否则 GC 仍然与用户代码并发运行。此外,如果在某个时间点可以观察到 goroutine 泄漏,那么在同一次执行的任何未来时间点也可以观察到。因此,周期性的分析架构可以调整分析频率,例如每 4 小时一次,以在几乎不影响泄漏检测能力的情况下最小化开销。
致谢
Goroutine 泄漏检测是奥尔胡斯大学、圣路易斯华盛顿大学和 Uber 之间研究合作的成果,如《通过垃圾回收的动态部分死锁检测与恢复》中所述。
漏,那么在同一次执行的任何未来时间点也可以观察到。因此,周期性的分析架构可以调整分析频率,例如每 4 小时一次,以在几乎不影响泄漏检测能力的情况下最小化开销。
致谢
Goroutine 泄漏检测是奥尔胡斯大学、圣路易斯华盛顿大学和 Uber 之间研究合作的成果,如《通过垃圾回收的动态部分死锁检测与恢复》中所述。
从学术原型向实际 Go 特性转变的实现,得到了 Google Go 团队的 Michael Knyszek 和 Michael Pratt 以及 PJ Malloy (@thepudds) 的指导。