Go 1.27 的 goroutineleak profile:只证明「永远卡住」的那类泄漏
goroutine 数量上涨只能说明进程在长,不能说明这些被阻塞的工作是否本来就该阻塞。一个繁忙的 HTTP server 可能有大量 goroutine 在等 socket、等 timer、等工作队列,但它依然健康。另一种情况不一样:某个 goroutine 卡在 channel 上,而剩下的代码里已经不可能再有人往这个 channel 发数据——它会一直持有自己的栈,以及栈上引用的所有对象,直到进程退出。
Go 1.27 把这类故障中一个可判定的子集单独拎了出来:runtime 新增了 goroutineleak profile。当某个 goroutine 阻塞在 channel 或同步原语上,并且该原语已经无法从任何 runnable goroutine、或任何「有可能变为 runnable」的 goroutine 到达时,它就会出现在这个 profile 里。这比「goroutine profile 数字异常大」是更强的信号,但它不是所有卡住请求、所有死锁的通用答案。
验证环境是 Go 1.27.1 / Linux AMD64。五个 sender 阻塞在不可达的 channel 上,在 goroutineleak profile 里表现为同一函数下的五条记录;把交接逻辑修好之后,该函数在 profile 里不再出现。
这个 profile 能证明什么
leak profiler 基于可达性(reachability)。一个 goroutine 阻塞在并发原语上,而这个原语从 runnable 的工作、或从「可能被解除阻塞」的工作出发都不可达时,runtime 可以判定这次等待不会结束。channel 发送、channel 接收、mutex、条件变量,以及类似的同步边界的都在范围内。
这个定义刻意排除了很多运维嘴里也叫「泄漏」的问题。阻塞在一个可达的全局 channel 上的 goroutine,可能因为「未来某个发送方在逻辑上根本不存在」而永远等下去,但 runtime 单凭可达性证明不了这一点。阻塞在网络 I/O 里的 goroutine、只是慢的请求、被活锁挡住的 worker,都需要别的证据。原来的 goroutine profile、metrics、请求超时、应用层取消检查都要继续留着;goroutineleak 是新增的一个精确诊断信号,不是替代品。
该 profile 可以通过 runtime/pprof 获取;如果应用已经装了标准的 net/http/pprof handler,它就在 /debug/pprof/goroutineleak。不要为了采集这个 profile 就把该 HTTP endpoint 暴露在不可信网络上。pprof 数据会泄露包路径、分配行为和实现细节。把已有的诊断监听绑定到 loopback,或者放到需要认证的管理边界后面。
参考:
- Go 1.27 release notes: goroutine leak profile —
- Go Blog: Goroutine Leak Profiles —
- runtime/pprof 包文档 —
- net/http/pprof 包文档 —
构造一个不可达的阻塞 sender
下面这个函数创建一个无缓冲 channel,启动一个 sender,然后直接返回,不保留任何 receiver 或 channel 引用。sender 持有这个 channel 的最后一份引用,并阻塞在发送处。没有任何 runnable goroutine 能拿到这个 channel 并从它接收。
func leak() {
ch := make(chan struct{})
go func() { ch <- struct{}{} }()
}
这个模式是刻意做小的。在真实服务里,等价的错误往往是:聚合循环提前 return、被丢弃的 worker 结果 channel,或者某条 shutdown 路径已经不再持有它等着去投递的那个信号。值得看的问题不是「channel 是不是无缓冲的」,而是:当某条错误路径改变了控制流之后,每个生产者是否仍然有消费者、取消路径,或者有界缓冲区。
造五个实例,先强制 GC 再采集 profile
这段独立测试很短,GC 调用是为了让它有确定结论;它不代表你要在服务里反复调用 runtime.GC。
package main
import (
"bytes"
"fmt"
"runtime"
"runtime/pprof"
"strings"
)
func leak() {
ch := make(chan struct{})
go func() { ch <- struct{}{} }()
}
func main() {
for range 5 {
leak()
}
for range 3 {
runtime.GC()
}
var report bytes.Buffer
if err := pprof.Lookup("goroutineleak").WriteTo(&report, 1); err != nil {
panic(err)
}
text := report.String()
fmt.Print(text)
if !strings.Contains(text, "main.leak.func1") {
panic("goroutineleak profile did not identify the blocked sender")
}
}
用 Go 1.27 或更高版本运行:go version;go run leakcheck.go。输出:
go version go1.27.1 linux/amd64
goroutineleak profile: total 5
5 @ ...
# ... main.leak.func1+0x1d ...:13
具体的程序计数器和源码位置会不同。有决定意义的是三部分:profile 类型、计数 5、以及被阻塞的 sender 函数。退出码为 0 只说明 profile 里包含该函数,不代表生产服务没有泄漏。
修正所有权边界
对有已知上界的 fan-out 操作,缓冲区有时是正确的修法,但它不是万能药。当发送次数可能超过缓冲容量时,带缓冲的 channel 只是把泄漏推迟;它也不会把取消信号传达给仍在做昂贵计算的 worker。就这个单次交接而言,给 sender 一个 receiver 并等它完成:
func deliver() {
ch := make(chan struct{})
go func() { ch <- struct{}{} }()
<-ch
}
对 worker group,更倾向让完成与取消显式化:sync.WaitGroup、传给 worker 的 context、以及一个一直消费到 worker 全部退出的结果 channel,能让所有权可见。如果聚合方可能在第一个错误上就返回,那就让剩余 worker 能观察到取消,或者继续把结果 channel 排空。改 channel 容量之前先把期望行为写下来;否则在轻量测试里,扩容只是把控制流错误藏起来。
把这个 profile 当作定点诊断
交接逻辑修好之后,在测试的同一个位置再跑一次 profile,断言它不再报出那个旧的阻塞函数。不要断言整个 profile 在大进程里必须永远为空:框架和测试自身的 goroutine 可以是合法的。做生产诊断时,在可比负载下采集两份 profile,看同一个阻塞位置是否在增长。新出现的阻塞位置,当作 code review 和事故信号来处理。
限制仍然在那儿:没有条目,不能证明所有 goroutine 都在推进;它只证明 runtime 没有找到不可达的同步等待。把这个 profile 和请求超时、有界队列、取消测试、以及常规的 goroutine 增长监控放在一起用。