Goroutine 泄漏分析
摘要
Go 1.27 引入了 goroutine 泄漏分析器,以准确检测和防止运行中的程序(包括生产系统)中的 goroutine 泄漏,且误报率极低。
<p><a href="https://lobste.rs/s/dpfpr3/goroutine_leak_profiles">评论</a></p>
查看缓存全文
缓存时间: 2026/09/02 19:55
# Go 语言协程泄漏分析器 - Go 编程语言
来源:https://go.dev/blog/goroutine-leak-profiles
Go 的并发特性(https://go.dev/tour/concurrency/1)强大且易于使用,但这种便利性有时甚至会导致经验丰富的开发者犯错。幸运的是,Go 生态系统配备了实用的调试工具,例如竞态检测器(https://go.dev/doc/articles/race_detector),但即使是现有工具也可能遗漏一些并发 bug,例如本文讨论的**协程泄漏**。
协程通过共享并发原语(如通道、锁和等待组)进行同步或信息交换。在通信过程中,协程通常会**阻塞**在这些原语上,即等待满足某些条件;常见的例子包括等待获取被持有的互斥锁,或通过通道接收消息。协程也可能阻塞在操作系统操作上,例如从网络套接字或文件读取。
如果一个协程被阻塞,且永远无法满足解除阻塞的条件,我们就可以认为它发生了泄漏。随着时间的推移,泄漏协程的累积会通过过度的内存使用(由泄漏协程本身或其引用的内存导致)以及垃圾回收器的 CPU 使用来降低性能,特别是在使用 `GOMEMLIMIT` 的情况下。
协程泄漏可能非常难以检测。在单元测试中,最重要的突破包括开源库 `goleak`(https://github.com/uber-go/goleak),它可以对单个测试进行插桩,以便在测试结束后将任何未终止的协程标记为可疑。同样,Go 1.25 在标准库中引入了 `synctest` 包(https://go.dev/blog/testing-time);通过让 Go 开发者更好地控制并发事件的顺序,以可靠地测试难以复现的场景,它显著提高了并发代码中单元测试的质量。
不幸的是,这两种方法都无法检查生产系统中的协程泄漏,尤其是在大规模环境下,其行为可能超出测试的预期。
协程分析器是一种基础方法,用于检查阻塞了太多协程的操作或分析增长趋势。然而,协程分析器无法区分泄漏的协程和那些因设计而临时阻塞的大量协程,例如微服务中因流量增加导致的情况。同样,数量较少的泄漏可能多年未被发现。
Go 1.27 引入了 **协程泄漏分析器**,这是一种灵活且轻量级的机制,用于在运行中的 Go 程序(包括生产系统)中查找协程泄漏。与需要人工分析的以往方法不同,这种机制是精确的,并且几乎不会产生误报。其权衡在于它仅限于一部分协程泄漏:永久阻塞在通道或 `sync` 包(https://go.dev/pkg/sync)原语上的协程。幸运的是,正如我们将在示例中看到的,这已经覆盖了很大一部分协程泄漏。
在接下来的章节中,我们将展示如何使用该功能,然后提供一些可检测泄漏的其他示例,以及对底层实现和权衡的描述。
## 示例:并发工作者
考虑一个并发处理工作项的函数:
```go
type result struct {
res workResult
err error
}
func processWorkItems(ws []workItem) ([]workResult, error) {
// 并行处理工作项,并在 ch 中聚合结果。
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err}
}()
}
// 从 ch 收集结果,如果发现错误则返回错误。
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
// 这个提前返回可能导致协程泄漏。
return nil, r.err
}
results = append(results, r.res)
}
return results, nil
}
```
由于 `ch` 是一个无缓冲通道,每个工作者协程在发送结果时会阻塞,直到主协程从通道接收。如果 `processWorkItems` 因错误提前返回,接收循环就会终止,所有剩余的发送者协程将永远阻塞。这个例子是在真实 Go 程序(包括 Uber 生产服务)中发现的常见错误的典型代表。
让我们看看如何使用新的协程泄漏分析器来发现这些泄漏。
### 使用协程泄漏分析器进行调试
该分析器通过 `runtime/pprof` 包(https://go.dev/pkg/runtime/pprof)提供,作为 `goroutineleak` 分析类型,或者通过安装 `net/http/pprof` 包(https://go.dev/pkg/net/http/pprof)定义的分析处理器。
如果你在服务中已经设置了 `net/http/pprof`,那么你不需要做任何其他事情!分析器将自动在处理器安装的主机和端口的 `/debug/pprof/goroutineleak` 端点提供收集。
让我们将并发 bug 置于上下文中,并设置 `net/http/pprof` 包。这样,你就可以自己尝试了!
```go
package main
import (
"errors"
"log"
"net/http"
_ "net/http/pprof"
"time"
)
type workItem int
type workResult int
func processWorkItem(w workItem) (workResult, error) {
time.Sleep(10 * time.Millisecond)
if w == 5 {
return 0, errors.New("simulated error")
}
return workResult(w * 2), nil
}
type result struct {
res workResult
err error
}
func processWorkItems(ws []workItem) ([]workResult, error) {
ch := make(chan result)
for _, w := range ws {
go func() {
res, err := processWorkItem(w)
ch <- result{res, err}
}()
}
var results []workResult
for range len(ws) {
r := <-ch
if r.err != nil {
return nil, r.err
}
results = append(results, r.res)
}
return results, nil
}
func main() {
// 启动 pprof 服务器
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// 反复触发泄漏
for {
items := []workItem{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
_, err := processWorkItems(items)
if err != nil {
log.Printf("Error processing items: %v", err)
}
time.Sleep(time.Second)
}
}
```
构建上述程序,然后运行它:
```sh
$ go build -o leaky
$ ./leaky
```
### 收集分析器
程序很快就会开始累积泄漏,然后你可以通过 http://localhost:6060/debug/pprof 的 Web UI 查看它们。或者,你可以使用 `curl` 收集协程泄漏分析器,然后使用 `go tool pprof` 检查它:
```sh
$ curl http://localhost:6060/debug/pprof/goroutineleak > leak.prof
$ go tool pprof leak.prof
Type: goroutineleak
Time: 2026-03-01 13:19:49 UTC
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list processWorkItems
Total: 116
ROUTINE ======================== main.processWorkItems.func1 in .../main.go
0 116 (flat, cum) 100% of Total
. . 31: go func() {
. . 32: res, err := processWorkItem(w)
116 116 33: ch <- result{res, err}
. . 34: }()
```
分析器显示泄漏的协程在 `ch <- result{res, err}`(第 33 行),精确指出了罪魁祸首的操作。值得注意的是,程序运行时间越长,泄漏的协程数量就越多。
### 解决泄漏
这个泄漏可以通过给 `ch` 一个**缓冲区**来简单修复:
```go
ch := make(chan result, len(ws))
```
这允许所有工作项协程在 `processWorkItems` 提前返回的情况下发送消息而不阻塞。我们在本节(https://go.dev/blog/goroutine-leak-profiles#examples)列出了更多真实世界的示例。
## 实现
本节适用于那些对协程泄漏分析器底层如何检测泄漏感兴趣的人。有关性能开销和限制的详细信息,请直接跳转到本节(https://go.dev/blog/goroutine-leak-profiles#limitations)。
### 核心概念
让我们从一个初始观察开始:如果一个协程阻塞在某个没有其他协程可访问的并发原语上(在这种情况下,通过内存中的引用),那么它显然是泄漏的。这已经是一个很强的线索,我们可以进一步概括,定义一个协程**未泄漏**的情况,我们称之为**活性(liveness)**属性。我们正式定义活性(一个归纳性质)如下:
> 如果满足以下条件,则协程是**活的(live)**:
> 1. 它没有被并发原语阻塞,或者
> 2. 至少一个阻塞它的并发原语被另一个活的协程引用。
在平凡的情况下,未阻塞的协程显然没有泄漏。在归纳情况下,基本假设是任何未泄漏的协程最终都可以使用它引用的并发原语来解除任何被这些原语阻塞的协程。
为了找到所有活的协程,我们从显然活着的未阻塞协程开始,跟踪它们持有的任何引用(即通过它们的局部变量),以找到它们可以访问的并发原语。然后,我们逐步将任何阻塞在这些原语上的协程视为活的,并重复此过程,直到没有发现额外的活协程。
幸运的是,Go 运行时已经通过垃圾回收器(https://go.dev/doc/gc-guide)(GC)计算了内存可达性,因此下一步是调整 GC 以适应我们的目的。你可以通过以下图表快速比较两者:
不需要对 GC 进行彻底的检修。Go 运行时使用并发的三色标记-清除垃圾回收器(现在带有 Green Tea 变体!https://go.dev/blog/greenteagc),因此其工作机制已经与我们的目标完美契合。只需要一些关键的更改:
1. 在初始阶段,常规 GC 将**所有**协程(和全局变量)标记为可达,因此它们永远不会被视为垃圾,即它们是*标记根*。我们将其改为**仅**包含未阻塞的协程,因为这些协程保证是活的。
2. 接着是标记阶段,GC 追踪由标记根(传递)引用的对象,并将它们*标记*为可用内存。尽管我们没有直接修改这个阶段,但步骤 1 中的更改隐式地确保 GC 只标记由活协程引用的内存。
3. 标记阶段通过检查所有未被步骤 1 包含为标记根的阻塞协程来完成。如果一个协程至少被步骤 2 中标记的一个并发原语阻塞,它就被添加为标记根,GC 从步骤 2 重新开始标记阶段。这符合活性定义中的归纳步骤。
4. 一旦发现所有活的协程,任何未被添加为标记根的协程的状态都被设置为泄漏。
5. 然后标记阶段最后一次恢复,将所有泄漏的协程添加为标记根,允许 GC 标记所有在常规运行中会标记的内存。
当 GC 周期完成时,协程泄漏分析器就像常规协程分析器一样接管,并过滤出严格泄漏的协程。
### 限制
上面的示例展示了协程泄漏分析器的有用性。然而,垃圾回收器有一些限制,可能导致它遗漏泄漏:
1. **内存过度延伸**:如果一个并发原语始终通过**全局变量**或**可运行的协程**可达,那么阻塞在其上的协程永远不会被报告为泄漏,即使该并发原语将来永远不会使用。这可以通过更好地规范并发原语引用的访问,并更清晰地界定其生命周期来缓解。
2. **非标准阻塞**:为了正确性,协程泄漏检测严格限于 Go 的一等并发原语,包括:通道发送和接收操作(包括对 `nil` 通道)、阻塞的 `select` 语句(即没有 `default` 分支),直至并包括没有分支的 `select` 语句,以及 `sync`(https://go.dev/pkg/sync)包的成员,特别是 `Mutex`、`RWMutex`、`WaitGroup` 和 `Cond`。因任何其他原因(如文件和网络 IO,或直接系统调用)阻塞的协程永远不会被视为泄漏。这也适用于自定义的、用户定义的并发原语(如自旋锁),除非它们依赖上述原语作为底层实现。
3. **非确定性**:泄漏只能在它们发生后才能被检测到,但无法以其他方式预测,因此在不稳定的程序中复现和诊断泄漏仍然是一个挑战。
为了获得最佳效果,我们建议混合使用方法,在不同层面(直至并包括生产环境)使用协程泄漏分析器,以及使用 `goleak` 和 `synctest` 插桩的综合测试套件。
### 性能影响
协程泄漏检测经过精心设计,以最小化性能影响,但仍然存在一些成本。虽然内存开销可以忽略不计,仅限于簿记所需的小额增加,但协程泄漏检测可能比常规 GC 慢。这最好通过我们称之为“菊花链”的病理情况来说明:
在此无泄漏示例中,可运行的协程 G0 持有阻塞 G1 的原语 P1 的引用,依此类推。这意味着证明 Pi+1 的活性需要证明 Pi 的活性,这引入了两个成本:
1. GC 标记阶段相对于协程可被扫描的顺序实际上是串行化的,因为必须标记从某个 Pi 可达的所有内存,然后才能将 Pi+1 添加为根。
2. 检查目前在每轮标记结束时检查所有阻塞的协程,在最坏情况下,一个 GC 周期需要 O(n²) 步,其中 n 是协程总数。
虽然第二点最终可以优化,但第一点是泄漏检测固有的限制,无法绕过。无论如何,我们提醒读者,除非通过运行时标志进行配置,否则 GC 仍与用户代码并发运行。此外,如果协程泄漏在某个时间点可以被观察到,那么它在同一次执行过程中的任何未来时间点也可以被观察到。因此,定期分析基础设施可以调整分析频率(例如每 4 小时),在几乎不损失泄漏检测能力的情况下最小化开销。
## 致谢
协程泄漏检测是奥胡斯大学、圣路易斯华盛顿大学和 Uber 之间研究合作的成果,如“Dynamic Partial Deadlock Detection and Recovery via Garbage Collection”(https://dl.acm.org/doi/pdf/10.1145/3676641.3715990)(Saioc 等人,ASPLOS 2025)所述。从学术原型到实际的 Go 特性的转变,得益于 Google Go 团队的 Michael Knyszek 和 Michael Pratt 以及 PJ Malloy(@thepudds https://github.com/thepudds)的指导。
## 其他示例
以下是在工业级代码库和开源项目中观察到的导致泄漏的编码模式,按复杂性升序排列。你可以在 Go playground(https://go.dev/play/p/S4Uw66sMbpj-)上快速试用协程泄漏检测器,并尝试你自己的泄漏。
### 示例:双重发送
一些最简单的泄漏发生在通过通道发送的消息多于预期时。下面,一个协程预期通过一个无缓冲通道向主协程发送一条消息。然而,在错误情况下,发送操作后缺少 `return` 语句。因此,对于每个错误,发送者将尝试发送两条消息,这会导致泄漏。
```go
func DoubleSend() {
ch := make(chan any)
go func(err error) {
if err != nil {
// 出错时,发送 nil。
ch <- nil
// 缺少 return 语句。
}
// 否则,继续正常行为。
// 此发送仍被执行,这在错误情况下会导致泄漏。
ch <- struct{}{}
}(fmt.Errorf("error"))
}
```
相似文章
深入理解Go运行时:性能分析
深入探讨Go的性能分析机制,解释运行时如何收集CPU、堆、阻塞、互斥锁和协程的性能分析数据,以及这些数据在pprof格式中的表示方式。
Go 中的性能剖析引导优化
Daniel Lemire 对 Go 中的性能剖析引导优化(PGO)进行了基准测试,显示在解析 JSON 时速度提升约为 2-5%,当训练 profile 与工作负载匹配时效果最佳。
修复 Kubernetes 1.36 中 kubelet 的内存泄漏
详细记录追踪并修复 Kubernetes 1.36 中 kubelet 组件的一个内存泄漏问题,该问题由 startPodSync 中的上下文泄漏引起。作者使用 Go pprof 分析工具识别了该问题并实施了修复。
Goroutines 101:基础教程
这篇文章提供了Go语言中goroutines的基础教程,解释了它们如何简化并发以及如何有效地使用它们。
调试挂起的Go程序的技巧
一份实用指南,涵盖了调试挂起的Go程序的三种方法:使用SIGQUIT打印堆栈跟踪、附加delve调试器以及保存核心转储供后续分析。