Go1.27中用goroutineleak定位协程泄漏
by Edward Yuan
Go 1.27 引入了 goroutineleak profile。现在可以在程序运行时采集泄漏的 goroutine,并用 pprof 定位到它们卡住的代码行。
什么情况下算 goroutine 泄漏?
一个 goroutine 阻塞了,而解除阻塞所需的条件永远不会再满足,它就泄漏了。常见的情况有三种。
- 等消息。goroutine 在 channel 上接收数据,发送方已经全部退出,也没有人会关闭 channel。
- 发结果。调用方超时返回,后台 goroutine 还在向无缓冲 channel 发送结果,此后再也没有接收方。
- 等同步。goroutine 调用了
WaitGroup.Wait(),负责调用Done()的代码走了提前返回的分支,计数从此无法归零。
普通 goroutine profile 会列出当前的 goroutine 和调用栈。一个正常等待消息的会话和一个已经泄漏的会话,都可能显示为 chan receive。goroutineleak 会借助 GC 的可达性分析,判断仍有机会推进的 goroutine 能否触达相关的同步原语。它能检测部分 channel、select 和 sync 阻塞。同步对象如果还挂在全局变量里,即使业务上再也没人操作它,也可能漏报。网络 I/O 阻塞也超出了这个 profile 的检测范围。
游戏网关里的泄漏
假设游戏网关为每个玩家会话启动一个 goroutine,从 inbox 读取消息。玩家断线时,网关调用 cancel(),然后结束会话。
package main
import (
"context"
"log"
"net/http"
_ "net/http/pprof"
)
func startSession(ctx context.Context, inbox <-chan string) {
go func() {
for msg := range inbox {
log.Print(msg)
}
}()
}
func simulateDisconnect() {
ctx, cancel := context.WithCancel(context.Background())
inbox := make(chan string)
startSession(ctx, inbox)
inbox <- "player joined"
cancel()
}
func main() {
for range 5 {
simulateDisconnect()
}
log.Fatal(http.ListenAndServe("127.0.0.1:6060", nil))
}
每次模拟先发一条消息,让会话 goroutine 确实接收过数据。随后 cancel() 关闭 ctx.Done(),但会话 goroutine 只在 for msg := range inbox 上等待。simulateDisconnect() 返回以后,inbox 只剩这个接收方持有,再也没有人能发送消息或关闭它。于是每次断线都留下一个 goroutine。
代码里的 _ "net/http/pprof" 会把 pprof 的 HTTP handler 注册到默认 mux。http.ListenAndServe("127.0.0.1:6060", nil) 使用这个 mux,因此 Go 1.27 会直接提供 /debug/pprof/goroutineleak。已经接入 net/http/pprof 的服务可以沿用现有的诊断端口。 go.dev
在浏览器和 go tool pprof 里定位
把代码保存为 main.go,用 Go 1.27 执行 go run main.go。在浏览器打开 pprof 索引页,找到 goroutineleak。也可以直接打开 goroutineleak 文本报告,在堆栈里找 main.startSession.func1,看它后面的文件名和行号。
这时候我们就能找到问题代码所在行:
goroutineleak profile: total 5
5 @ 0x104298708 0x10422a760 0x10422a2d4 0x1044448e8 0x10429f744
# 0x1044448e7 main.startSession.func1+0x87 /Users/edward/code/demos/game_leak_demo/main.go:12
命令行可以先看泄漏集中在哪些函数,再查看这些函数的源码。
curl -fsS http://127.0.0.1:6060/debug/pprof/goroutineleak -o leak.prof
go tool pprof -top leak.prof
go tool pprof -list=startSession leak.prof
-top 按函数汇总,-list=startSession 则把泄漏数量标到源码行上。这个例子预期有 5 个 goroutine 卡在 for msg := range inbox,对应 5 次模拟断线。这里给的是预期结果,示例未在 Go 1.27 环境中实测。
定位到这一行后,修复就是让会话在等待消息时也能接收取消信号。
func startSession(ctx context.Context, inbox <-chan string) {
go func() {
for {
select {
case <-ctx.Done():
return
case msg, ok := <-inbox:
if !ok {
return
}
log.Print(msg)
}
}
}()
}
重启程序,用同样的命令重新采集。断线触发 cancel() 后,空闲会话可以直接从 select 退出,startSession 对应的泄漏记录预期会消失。