ELI5 · 说人话 · GO 面试

goroutine 漏了

go 干活() 干完,退出 卡住,回不来 NumGoroutine() 只涨不掉 = 泄漏

goroutine 泄漏不是「内存漏了」,是活儿派出去了,人再也没回来
它在那儿站着,永远等一个不会来的东西。

泄漏 = 只进不出

别小看

漏的不只是 2KB

1MB 读缓冲 一条 TCP 连接 半个大 map 2KB 栈 GC 不敢收 它还「活」着

一个 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 加一格缓冲——让发送方不必等人接

怎么抓

pprof 六步

  1. 1

    装监控先把探头挂上,只多两行。

    import _ "net/http/pprof" go func() { http.ListenAndServe("localhost:6060", nil) }()
  2. 2

    数一下第一行就是总数,不用打开任何界面。

    $ curl -s 'localhost:6060/debug/pprof/goroutine?debug=1' | head -1 goroutine profile: total 40213
  3. 3

    隔五分钟再数涨了才是泄漏。只是「数字大」不算——高并发服务本来就几万个。

    # 40213 → 47986 → 55702,斜率稳定 = 确诊
  4. 4

    存两份快照要的是「多出来的那批」,不是全部。

    $ curl -so t1.pb.gz 'localhost:6060/debug/pprof/goroutine' $ sleep 300 $ curl -so t2.pb.gz 'localhost:6060/debug/pprof/goroutine'
  5. 5

    只看差量-base 把老底子减掉,剩下的就是这五分钟新漏的。

    $ go tool pprof -http=:8080 -base t1.pb.gz t2.pb.gz
  6. 6

    看完整栈debug=2 会把每个 goroutine 的状态和已经等了多久都写出来。等了 12 分钟的那一堆,就是嫌疑人。

    $ curl -s 'localhost:6060/debug/pprof/goroutine?debug=2' > g.txt $ grep -c 'chan receive' g.txt

读这三处就够

栈里哪行是你的

1goroutine profile: total 40213
240012 @ 0x43f5f8 0x4074cb 0x4a1b3d 0x46e2c1
# 0x43f5f7 runtime.gopark+0x137
# 0x4074ca runtime.chanrecv+0x50a
3# 0x4a1b3c main.(*Pool).wait+0x5c pool.go:88
1

总数在第一行。跑两次对比它,这一个数字就能定性有没有泄漏。

2

四万个共用一条栈。这不是四万个 bug,是同一个 bug 被复制了四万份

3

第一行带你文件名的就是案发地点。上面全是 runtime,跳过。

红线

别再踩这几个

「go 出去就不管了」

go 之前先答一句:它什么时候退出、谁叫它停。答不上来就先别写这一行——这是唯一能从源头堵住的办法。

无缓冲 channel 配超时返回

超时的那一刻,发送方就成了孤儿。给 channel 一格缓冲,或者发送侧也套一层 select + ctx.Done()

常驻 for-select 没有 ctx 分支

任何跑一辈子的循环都必须留门:case <-ctx.Done(): return。上游一取消,它就该收工。

在 for 里写 time.After

每转一圈新建一个 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/pprofgoroutine?debug=1 第一行看总数确认趋势,隔几分钟取两份快照用 pprof -base 看差量,再用 debug=2 看完整栈和等待时长,找栈里第一行属于业务代码的位置。

预防上,我们的约定是每个 go 都要有明确的退出路径,跨调用一律传 context,单测挂 goleak 兜底。

go 一个字就出去了,
回来的路得你自己写