Go 1.27 的goroutine泄漏检测器(pprof)
goroutine 泄漏是 Go 程序最常见也最难发现的 bug 之一:goroutine 卡死在一个永远等不到的 channel 收发上,它和它引用的内存从此无法回收,随时间累积,服务跑久了内存悄悄上涨、连接数飙高,重启就恢复。
1.27 之前唯一的排查手段是抓 /debug/pprof/goroutine,拿到全部 goroutine 的调用栈,人工分辨哪些是泄漏、哪些是设计上的阻塞。在 Go 1.27 新增 了/debug/pprof/goroutineleak,该配置能在运行时自动判定泄漏,直接给出清单,list 能定位到具体泄露的相关代码行。
这篇文章分三部分:检测器更新了什么、怎么做到的,把它用起来需要的 pprof 基本功;最后实测两个来自 Kubernetes 和 etcd 的真实泄漏。所有命令和代码在 Go 1.27.1 + Windows 11 上实际跑过。
原文:Goroutine Leak Profiles
新增的 goroutineleak 泄露检测器
pprof 多了一个 profile 类型 goroutineleak,只报告被判定为泄漏的 goroutine。服务里已有 net/http/pprof 的话,什么都不用做,1.27 起新端点自动可用。
/debug/pprof/goroutine(老) | /debug/pprof/goroutineleak(1.27 新) | |
|---|---|---|
| 报告内容 | 所有 goroutine 的栈 | 只报判定为泄漏的 |
| 误报 | 高(设计性阻塞也算) | 几乎为零 |
| 判定方式 | 人工看栈、比趋势 | 运行时自动判定 |
老端点的两个痛点,正是新端点要解决的:
- 分不清泄漏和设计上的阻塞——微服务流量高峰时,几千个
goroutine阻塞在channel等待被接收。 - 少量泄漏混在几千条栈里,靠人工 diff 增长趋势才能发现,服务协程多年漏检是常态。
借 GC 做活性归纳
其判断原理来自一个归纳定义的活性(liveness):
一个 goroutine 是"活"的,当且仅当它没有被任何并发原语阻塞,或者它阻塞所依赖的原语,被另一个"活"的 goroutine 引用着。
Go 的 GC 本来就会计算内存可达性,检测器的改动很聪明:把 GC 的标记根从"所有 goroutine"改成"只有未阻塞的 goroutine",然后归纳传播——阻塞在"被活 goroutine 引用的原语"上的,也算活。跑完一轮,没被标活的阻塞 goroutine 就是泄漏。
边界
虽然 goroutineleak 的新增带来了很大的 goroutine 泄漏检测的便利性,但它的限制还是有些多:
- 只认 Go 并发原语:
channel收发(含nil channel)、无 default 的select(包括空select)、sync.Mutex/RWMutex/WaitGroup/Cond。网络/文件 IO、系统调用这类阻塞不算,永远不会被报告。 - 原语一直被全局变量或可运行
goroutine引用时,阻塞其上的goroutine不会被报告,哪怕它将来真的不会再被使用(官方称为memory overreach)。 - 只能事后检测,不能预测。生产上还是建议周期性采集,测试期用
goleak和testing/synctest兜底。 - 内存开销可忽略,但检测可能比普通 GC 慢(最坏的 "菊花链" 场景是 O(n²)),低频采集是性价比最优解。
对于 "菊花链" 场景,建议还是看一下官方的 blog 文章,有配图,写的很清楚。
pprof 基本使用
检测器长在 pprof 上,要用它得先把 pprof 接进程序。顺手把 pprof 的通用用法过一遍——泄漏检测只是它的一个维度。
接入:三行代码,八个端点
pprof 接入有两种姿势。runtime/pprof 适合一次性程序和 benchmark,在代码里主动 dump 到文件;长期运行的服务用 net/http/pprof,注册常驻 HTTP 端点,生产环境最常用。后者只要三行:
import (
"net/http"
_ "net/http/pprof" // ← 匿名导入,副作用是注册所有 /debug/pprof 处理器
)
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()装好之后,这些端点全都有了:
| 端点 | Type | 内容 |
|---|---|---|
/debug/pprof/goroutine | goroutine | 所有 goroutine 的调用栈 |
/debug/pprof/goroutineleak | goroutineleak | 只报判定为泄漏的 goroutine(1.27 新增) |
/debug/pprof/heap | heap | 存活对象的内存分配 |
/debug/pprof/allocs | allocs | 历史累计分配 |
/debug/pprof/profile?seconds=30 | cpu | CPU 采样(默认 30 秒) |
/debug/pprof/block | block | 阻塞原语上的等待时间 |
/debug/pprof/mutex | mutex | 锁竞争的持有者 |
/debug/pprof/threadcreate | threadcreate | OS 线程创建栈 |
服务部署在远端的话,把 localhost:6060 换成域名走网关同样能抓。两个注意:别把 pprof 端口暴露到公网,要么只绑 localhost 要么前面加鉴权——profile 里的函数名和调用关系本身就是信息泄露面;另外刚切 1.27 时编辑器可能出红线但 go build 通过,那多半是 IDE 分析引擎还没跟上新语法,以命令行编译结果为准。
Web UI 抓取与查看
pprof 能够自起一个本地 Web UI 界面:
go tool pprof -http=:3001 http://localhost:6060/debug/pprof/heap浏览器打开 http://localhost:3001,Top(占用最多的函数)、Graph(调用图)、Flame Graph(火焰图)都有。分析内存时火焰图尤其直观,每一格的宽度就是内存占用。想离线存文件:
go tool pprof -output=heap.svg http://localhost:6060/debug/pprof/heap绘制 Graph 和火焰图需要 Graphviz:官网(https://www.graphviz.org/download/)下载,bin 目录加进环境变量,重开终端生效。
交互模式
交互模式适合精确到行:
go tool pprof http://localhost:6060/debug/pprof/goroutineleak这条命令自己发请求、自己解析,最省事也最跨平台。进去之后常用的就这几个:
top按采样值排序列出最重的函数。list 函数名把采样值标注到源码行上,定位泄漏最常用。traces打印所有调用栈原文。web生成调用图用浏览器打开。
Windows 下用原生 curl 配 -o 落盘:
curl.exe -o leak.prof "http://localhost:6060/debug/pprof/goroutineleak"进交互模式后开头几行有 File(profile 来自哪个二进制)、Build ID(哪次构建)、Type(profile 类型)、Time(快照时刻),够把这份 profile 和某次构建对上号。
交互模式示例
这里我们拿 goroutineleak 这一新出的 profile 作为示例:
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)
// ch := make(chan result, len(ws))
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() {
// Start pprof server
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// Repeatedly trigger the leak
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)
}
}运行该示例程序时,执行:
curl -o leak.prof "http://localhost:6060/debug/pprof/goroutineleak"即会从示例程序的 6060 端口获取对应的 .prof 分析文件,接着执行:
go tool pprof leak.prof
File: ___go_build_goroutineLeak_go.exe
Build ID: C:\Users\xxx\AppData\Local\JetBrains\GoLand2026.2\xxx\GoLand\___go_build_goroutineLeak_go.exe2026-09-04 10:21:02.0969552 +0800 CST
Type: goroutineleak
Time: 2026-09-04 10:21:15 CST
Entering interactive mode (type "help" for commands, "o" for options)
(pprof)接着就可以对有协程的函数执行分析了,示例里面是 processWorkItems 开启的协程,我们执行:
File: ___go_build_goroutineLeak_go.exe
Build ID: C:\Users\xxx\AppData\Local\JetBrains\GoLand2026.2\xxx\GoLand\___go_build_goroutineLeak_go.exe2026-09-04 10:21:02.0969552 +0800 CST
Type: goroutineleak
Time: 2026-09-04 10:21:15 CST
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list processWorkItems
Total: 29
ROUTINE ======================== main.processWorkItems.func1 in D:\GoLand\1.27.1test\goroutineLeak.go
0 29 (flat, cum) 100% of Total
. . 31: go func() {
. . 32: res, err := processWorkItem(w)
. 29 33: ch <- result{res, err}
. . 34: }()
. . 35: }
. . 36:
. . 37: var results []workResult
. . 38: for range len(ws) {
(pprof)pprof 就会根据分析文件,读取出 curl 产出 .prof 文件时,对应程序运行时产生 goroutine 泄露的具体代码行了。
我们根据结果可以看出,当时有 29 次泄露,在总进程里 processWorkItems 泄露占了 100%,其 33 行的 ch <- result{res, err} 共占据了 29 次。
泄漏的原因也很简单:
ch是无缓冲channel,每个worker发送时必须等主goroutine同步接收;w == 5那项返回error,接收循环return nil, r.err提前退出,不再接收;- 剩下的
worker醒来后往ch发送,永远等不到接收,卡死。goroutine和它引用的内存都无法回收,随时间累积。
修复只需一行,将 ch 从无缓冲 channel 改成有缓冲 channel 即可:
ch := make(chan result, len(ws))缓冲区装下全部结果,即使 processWorkItems 提前返回,所有 worker 也能把结果放进缓冲后正常退出。
根据上面例子,我们虽然不一定能直接从分析结果看出问题所在(下文的实验台示例里面就能感受的出),但对应问题出处的范围该分析直接帮助我们定位到了,这对于工程师修复该类问题提供了很有效的帮助。
什么症状使用什么 profile
日常排查大部分时候就是查表的事:
| 症状 | 首选 profile | 看什么 |
|---|---|---|
| goroutine 数量只涨不跌 | goroutineleak | 直接给出泄漏清单,list 到行 |
| 内存缓慢上涨,重启恢复 | heap + goroutine | inuse_space 的分配大户;goroutine 栈是否只增不减 |
| API P99 变慢,CPU 却不高 | mutex + block | sync.(*Mutex).Lock 的等待占比、锁被谁长期持有 |
| CPU 被打满 | cpu(?seconds=30) | 热点函数——正则回溯、json.Marshal、反射都是常客 |
| 偶发"灵异卡顿" | trace | Goroutine analysis 里的 Network wait / Syscall 耗时 |
trace 的抓法不同:
curl.exe -o trace.out "http://localhost:6060/debug/pprof/trace?seconds=5"落盘 trace.out,再 go tool trace trace.out 打开。
pprof 还有 label 机制(runtime/pprof.Do),配合中间件把采样按接口维度打标,单独看某个接口的内存占用——属于用到再查的深水区,这里只提一句,我也没实际用过。
两个真实项目的泄漏 demo
官方博客的 Additional examples 一节列了九个真实世界的泄漏模式,这里取两个出自大型项目真实 issue 的:一个来自 Kubernetes,channel 和锁互锁;一个来自 etcd,发送输给了竞态。每个都是完整独立的程序,分开保存、各自 go run。
两个 demo 的 main 里保活那两条注释,是写这种 demo 才会撞见的坑,官方博客没展开讲:空 select{} 会被检测器报为泄漏;没有 GC 活动时泄漏数据不会更新。
Kubernetes:channel 和锁的相互阻塞
出自 Kubernetes 的真实 issue,锁和 channel 混用,两个 goroutine 互相等对方让路:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"sync"
"time"
)
type Connection struct {
closeChan chan bool
}
type idleAwareFramer struct {
resetChan chan bool
writeLock sync.Mutex
conn *Connection
}
func (i *idleAwareFramer) monitor() {
var resetChan = i.resetChan
for range i.conn.closeChan {
i.writeLock.Lock() // ← 若 WriteFrame 正持锁卡在发送上,这里永远等不到
close(resetChan)
i.resetChan = nil
i.writeLock.Unlock()
break
}
}
func (i *idleAwareFramer) WriteFrame() {
i.writeLock.Lock()
defer i.writeLock.Unlock()
if i.resetChan == nil {
return
}
i.resetChan <- true // ← 没有任何接收者 → 永远阻塞,且锁不释放
}
func NewIdleAwareFramer() *idleAwareFramer {
return &idleAwareFramer{
resetChan: make(chan bool),
conn: &Connection{
closeChan: make(chan bool),
},
}
}
func Kubernetes6632() {
i := NewIdleAwareFramer()
go func() {
i.conn.closeChan <- true // 触发关闭信号
}()
go i.monitor()
go i.WriteFrame()
}
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// 循环放大命中率:demo 是竞态命中才泄漏,单跑一次未必中
for range 100 {
Kubernetes6632()
}
// 保持进程存活,写法有两个讲究:
// 1) 不能用 select{} 保活——空 select 本身就是检测器覆盖的阻塞原语,
// 会把 main 自己报成泄漏;
// 2) 留一个低速率分配循环。泄漏检测搭 GC 的便车:
// 程序完全静止、GC 不跑,检测结果就不刷新。(当然也可以用 sleep 控制,还安全一些)
for {
_ = make([]byte, 1<<20)
time.Sleep(100 * time.Millisecond)
}
}死锁链条读一遍:
WriteFrame拿到writeLock,向resetChan发送,但是没有任何接收者,会触发锁等待直接卡死。monitor收到关闭信号,去Lock()——锁在WriteFrame手里,阻塞卡死。- 两个
goroutine互相等对方,双双泄漏。
profile 的形状和单一卡点不一样,两段 ROUTINE、各占一半:
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) list AwareFramer
Total: 28
ROUTINE ======================== main.(*idleAwareFramer).WriteFrame in D:\GoLand\xxx\k8sLeak.go
0 14 (flat, cum) 50.00% of Total
. . 32:func (i *idleAwareFramer) WriteFrame() {
. . 33: i.writeLock.Lock()
. . 34: defer i.writeLock.Unlock()
. . 35: if i.resetChan == nil {
. . 36: return
. . 37: }
. 14 38: i.resetChan <- true // ← 没有任何接收者 → 永远阻塞,且锁不释放
. . 39:}
. . 40:
. . 41:func NewIdleAwareFramer() *idleAwareFramer {
. . 42: return &idleAwareFramer{
. . 43: resetChan: make(chan bool),
ROUTINE ======================== main.(*idleAwareFramer).monitor in D:\GoLand\xxx\k8sLeak.go
0 14 (flat, cum) 50.00% of Total
. . 21:func (i *idleAwareFramer) monitor() {
. . 22: var resetChan = i.resetChan
. . 23: for range i.conn.closeChan {
. 14 24: i.writeLock.Lock() // ← 若 WriteFrame 正持锁卡在发送上,这里永远等不到
. . 25: close(resetChan)
. . 26: i.resetChan = nil
. . 27: i.writeLock.Unlock()
. . 28: break
. . 29: }
(pprof) 一段卡 channel 发送、一段卡锁获取,各占 50%——看到这个形状,就可以按图索骥去找那对互相牵制的原语了。
官方给的修复:monitor 收到关闭信号后,另起一个 goroutine 先清空 resetChan,再去拿锁——让发送方有机会送完退出、还回锁。更通用的教训是:别让"持有锁"和"可能阻塞的 channel 操作"出现在同一段临界区里。
etcd:输给竞态的发送
出自 etcd 的真实 issue,run/Status/Stop 三个方法并发时,Status 的发送输掉了和 Stop 的竞态:
package main
import (
"log"
"net/http"
_ "net/http/pprof"
"time"
)
type node struct {
status chan chan struct{}
stop chan struct{}
done chan struct{}
}
func (n *node) Status() struct{} {
c := make(chan struct{})
n.status <- c // ← 若 run 已随 Stop 退出,这次发送永远没人接
return <-c
}
func (n *node) run() {
for {
select {
case c := <-n.status:
c <- struct{}{}
case <-n.stop:
close(n.done) // ← run 收到 stop 后关闭 done 并退出
return
}
}
}
func (n *node) Stop() {
select {
case n.stop <- struct{}{}:
case <-n.done:
return
}
<-n.done
}
func Etcd6857() {
n := &node{
status: make(chan chan struct{}),
stop: make(chan struct{}),
done: make(chan struct{}),
}
go n.run()
go n.Status()
go n.Stop()
}
func main() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
// 循环放大命中率:demo 是竞态命中才泄漏,单跑一次未必中
for range 100 {
Etcd6857()
}
// 保持进程存活,写法有两个讲究:
// 1) 不能用 select{} 保活——空 select 本身就是检测器覆盖的阻塞原语,
// 会把 main 自己报成泄漏;
// 2) 留一个低速率分配循环。泄漏检测搭 GC 的便车:
// 程序完全静止、GC 不跑,检测结果就不刷新。
for {
_ = make([]byte, 1<<20)
time.Sleep(100 * time.Millisecond)
}
}时序读一遍:run 持续接收 status 上的请求,同时随时可能收到 stop——收到就关闭 done 并退出。Stop 发出信号后等 done 关闭。竞态在于:Stop 和 run 可能先完成同步并双双退出,Status 发出的请求没人接收,n.status <- c 永久阻塞。
profile 只报一个卡点:
(pprof) list Status
Total: 17
ROUTINE ======================== main.(*node).Status in D:\GoLand\xxx\etcdLeak.go
0 17 (flat, cum) 100% of Total
. . 16:func (n *node) Status() struct{} {
. . 17: c := make(chan struct{})
. 17 18: n.status <- c // ← 若 run 已随 Stop 退出,这次发送永远没人接
. . 19: return <-c
. . 20:}
. . 21:
. . 22:func (n *node) run() {
. . 23: for {
(pprof) 官方给的修复:把发送包进 select,另一个分支等 done——和 Stop 竞争失败时优雅退出:
func (n *node) Status() struct{} {
c := make(chan struct{})
select {
case n.status <- c:
case <-n.done: // ← 竞争失败就走这条分支退出,不再干等
return struct{}{}
}
return <-c
}两个 demo 都是竞态命中才泄漏,所以各自 main 里用循环放大命中率。
总结
把检测器和 pprof 串成一条完整的排查流程:
- 接入只要三行——
net/http/pprof匿名导入加一个ListenAndServe,八个端点全部就位,1.27 新增的goroutineleak自动可用。 - 新检测器借 GC 标记根做活性归纳,精确但只覆盖
channel、select和sync原语上的永久阻塞,IO 阻塞不算,全局引用的原语会漏报,只能事后检测。 两个真实 demo 的共性值得记住:泄漏点都落在一条等不到接收的发送上。
- Kubernetes 的发送者手里还握着锁,把等待传染给了拿锁方。
- etcd 的发送者输给了竞态,对手退场后无人接收。
前者的解决方法是别在临界区里做阻塞的
channel操作,后者的解决方法是select里给竞争失败方留一条done分支。检测器只负责把卡点指到行,问题出处还得工程师对着检测器对应代码区域找。
对于泄露场景的相关实例,有兴趣的话建议还是看官方原文的例子,有些像 Mutex、 goroutine、channel、select 混合使用的复杂场景,我经验不足,还是不太能做很好的分析,这里就不继续展开了。
总之官方愿意在 Go 原生下作并发问题检测工具还是很好的,能用工具找,总比工程师挠破头对着整个项目用 pprof 对着慢慢找要快很多,找到了也只是定位到区域,还得工程师用并发经验和处理经验去解决问题。
RoLingG | 博客
评论(0)