少数派报告----Edward's Webblog

Some raw thought.

14 November 2026

Go1.27中用goroutineleak定位协程泄漏

by Edward Yuan

Go 1.27 引入了 goroutineleak profile。现在可以在程序运行时采集泄漏的 goroutine,并用 pprof 定位到它们卡住的代码行。

什么情况下算 goroutine 泄漏?

一个 goroutine 阻塞了,而解除阻塞所需的条件永远不会再满足,它就泄漏了。常见的情况有三种。

普通 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 对应的泄漏记录预期会消失。

tags: