ELI5 · 说人话 · GO 面试
goroutine 泄漏不是「内存漏了」,是活儿派出去了,人再也没回来。
它在那儿站着,永远等一个不会来的东西。
别小看
一个 goroutine 的栈才 2KB,一万个也就 20MB——真正吃掉内存的是它手里攥着的东西。GC 从卡住的 goroutine 出发还能走到那些对象,就一个都不敢回收。所以现象往往是:内存曲线爬坡、goroutine 数曲线也爬坡,重启就好,跑三天就 OOM。
怎么卡住的
发不出去
无缓冲 channel,接收方已经 return 走了。ch <- x 这一行就是终点站。
收不到
生产者中途 panic 或提前退出,channel 既没数据也没 close,接收方就站着等。
循环没出口
常驻的 for { select { … } } 里少了 case <-ctx.Done(),请求早断了它还在转。
等不到齐
wg.Add(3) 但有一条路径提前 return、没走到 Done(),wg.Wait() 就等成了永久。
四种长得不一样,症状是同一个:栈顶永远停在 chan send / chan receive / select / semacquire。看 pprof 时,认这几个词就够了。
现场
func fetch(url string) string {
ch := make(chan string)
go func() {
ch <- get(url) // 超时后没人收
}() // 卡死在这一行
select {
case r := <-ch:
return r
case <-time.After(time.Second):
return "timeout"
}
} // 函数返回了,goroutine 没有
func fetch(ctx context.Context,
url string) string {
ch := make(chan string, 1)
go func() {
ch <- get(url) // 缓冲里一放
}() // 立刻收工
select {
case r := <-ch:
return r
case <-ctx.Done():
return "timeout"
}
} // 两个都能走掉
这是线上最经典的一例:接口 QPS 1000、超时率 2%,一分钟就漏 1200 个 goroutine,一天下来一百多万。而修复只是给 channel 加一格缓冲——让发送方不必等人接。
怎么抓
装监控先把探头挂上,只多两行。
import _ "net/http/pprof"
go func() { http.ListenAndServe("localhost:6060", nil) }()
数一下第一行就是总数,不用打开任何界面。
$ curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' | head -1
goroutine profile: total 40213
隔五分钟再数涨了才是泄漏。只是「数字大」不算——高并发服务本来就几万个。
# 40213 → 47986 → 55702,斜率稳定 = 确诊
存两份快照要的是「多出来的那批」,不是全部。
$ curl -so t1.pb.gz 'localhost:6060/debug/pprof/goroutine'
$ sleep 300
$ curl -so t2.pb.gz 'localhost:6060/debug/pprof/goroutine'
只看差量-base 把老底子减掉,剩下的就是这五分钟新漏的。
$ go tool pprof -http=:8080 -base t1.pb.gz t2.pb.gz
看完整栈debug=2 会把每个 goroutine 的状态和已经等了多久都写出来。等了 12 分钟的那一堆,就是嫌疑人。
$ curl -s 'localhost:6060/debug/pprof/goroutine?debug=2' > g.txt
$ grep -c 'chan receive' g.txt
读这三处就够
总数在第一行。跑两次对比它,这一个数字就能定性有没有泄漏。
四万个共用一条栈。这不是四万个 bug,是同一个 bug 被复制了四万份。
第一行带你文件名的就是案发地点。上面全是 runtime,跳过。
红线
写 go 之前先答一句:它什么时候退出、谁叫它停。答不上来就先别写这一行——这是唯一能从源头堵住的办法。
超时的那一刻,发送方就成了孤儿。给 channel 一格缓冲,或者发送侧也套一层 select + ctx.Done()。
任何跑一辈子的循环都必须留门:case <-ctx.Done(): return。上游一取消,它就该收工。
每转一圈新建一个 timer,到点之前都不释放,循环快就是一场堆积。改用 time.NewTimer + Reset,配 defer Stop()。
泄漏在测试里就能抓。测试入口挂上 goleak.VerifyTestMain(m)(uber-go/goleak),谁漏了 CI 直接把他挂掉。
面试就这么答
goroutine 泄漏就是启动了但永远不会退出的 goroutine。它自己只占 2KB 栈,真正的代价是它引用的对象 GC 全回收不了,所以表现为内存和 goroutine 数一起单调上涨、重启才好。
成因基本是四类:往没人收的 channel 发、从没人发的 channel 收、常驻 for-select 缺 ctx.Done 分支、WaitGroup 少 Done 一次。栈顶永远停在 chan send / chan receive / select / semacquire。
定位靠 net/http/pprof:goroutine?debug=1 第一行看总数确认趋势,隔几分钟取两份快照用 pprof -base 看差量,再用 debug=2 看完整栈和等待时长,找栈里第一行属于业务代码的位置。
预防上,我们的约定是每个 go 都要有明确的退出路径,跨调用一律传 context,单测挂 goleak 兜底。
go 一个字就出去了,
回来的路得你自己写。