Xiaojun 的技术笔记

Go 协程泄漏:六类生产场景与根因

Go约 8 分钟读完
目录

概述

goroutine 泄漏(goroutine leak)指 goroutine 因永久阻塞而无法退出,其栈及引用的对象无法被 GC 回收。Go 1.27 之前运行时不提供开箱即用的泄漏检测(1.27 起新增 goroutineleak profile,见生产阶段),泄漏通常在测试中不可见,在长时间运行的服务中缓慢累积,最终表现为:

  • 内存 / goroutine 数持续阶梯式或线性上涨,重启后回落;
  • GC 压力和 CPU 上升,延迟抖动,最终 OOM。

Uber 对其 Go monorepo(7500 万行代码、2500+ 微服务)的研究(CGO 2024,arXiv:2312.12002)将此类问题称为 partial deadlock:阻塞的发送方(接收方)永远等不到对端。其结论具有代表性:

  • 单测阶段工具 goleak 在存量代码中发现 857 处泄漏,一年内拦截约 260 处新增泄漏;
  • 生产侧工具 LeakProf 发现 24 处泄漏、修复 21 处,最高提速 34%,内存下降 9.2 倍;
  • 修复其中 2 个缺陷使服务峰值内存分别下降 2.5 倍和 5 倍,容器内存配额下调 25%(Uber 工程博客)。

本文归纳线上最常见的六类场景,并给出根因与修复方式。


无缓冲 channel 发送阻塞

这是线上占比最高的一类,根因都是:接收方先退出,发送方没有退出路径。Uber 博客将其细分为三种形态。

提前返回

func fetch(ctx context.Context) error {
    errCh := make(chan error)            // 无缓冲
    go func() { errCh <- doWork() }()    // 子协程发送

    if err := validate(); err != nil {
        return err                       // 提前返回,无人接收 → 子协程泄漏
    }
    return <-errCh
}

父函数存在多个 if ... return 分支,只有部分路径会接收 errCh。泄漏的是子协程,但它的栈仍引用着 errCh 和闭包捕获的对象(如请求体、连接),这些都无法被回收。

超时分支

done := make(chan struct{})              // 无缓冲
go func() { work(); done <- struct{}{} }()

select {
case <-done:
case <-ctx.Done():
    return ctx.Err()                     // 超时返回,子协程阻塞在 done <- 上
}

真实案例:Azure ARO-RP 项目的一次修复中,50s 超时触发后 workOne 返回,而 execute 协程阻塞在向无缓冲的 allJobsDone 发送上。泄漏的不仅是 goroutine,还有其引用的整个 Monitor 结构体(K8s clientset、HTTP 连接池、TLS 状态),每次超时泄漏一份。修复为改用带缓冲 channel,并补充了“取消 context 且无人读取”的回归测试。

注:该案例来源于代码提交说明,非完整事故复盘。

N 发 M 收

多个 goroutine 向同一 channel 发送,父协程只接收一次(N > M):

for _, item := range items {
    go func(it Item) { dataCh <- process(it) }(item)
}
return <-dataCh                          // 只收一个,其余 N-1 个永久阻塞

发送阻塞的修复

方案 说明
channel 容量设为 1(或 len(items)) 发送不再依赖接收方,协程可退出。成本最低,Ardan Labs 与 Uber 均推荐
发送处使用 select + ctx.Done() 调用方离开时让子协程退出。若工作函数也接收 ctx 并在内部检查,还能中途停止昂贵的计算,适合生产端开销大的场景

两者区别:缓冲只是“让发送完成”,协程仍会把活干完再退出;select + ctx.Done() 是让协程在调用方离开时主动退出。要真正中途停掉计算,还需把 ctx 传给工作函数,发送处的 select 只能解除发送阻塞。


HTTP Body 未关闭

案例:Coder 内存上涨

Coder 工程博客记录了完整排查过程:

  1. 现象:dogfood 集群 coderd 每次重启后内存与 goroutine 数稳定爬升,斜率与白天/夜间业务负载无关,说明来源是后台定时任务而非 API;
  2. 定位:pprof 显示约 820 个 readLoop 和 820 个 writeLoop goroutine,即 net/http 客户端连接池的读写协程——它们只是受害者;
  3. 缩小范围:监控呈每小时一次的阶梯上涨。团队把 4 个每小时任务的周期改为质数分钟(47/53/59/67),使各任务的影响可区分,最终锁定“检查容器镜像 tag”任务;
  4. 根因:容器镜像仓库客户端库 go-containerregistry 中缺少一处 resp.Body.Close(),上游以一行代码修复(PR #1482)。

Body 泄漏原因

  • Body 未关闭(且未读完)时,连接无法归还连接池,该连接的 readLoop/writeLoop 两个协程永久存活;
  • http.Get / http.DefaultClient 默认没有超时,对端挂起则请求永久阻塞;
  • 每次请求新建 http.Transport,导致连接池无法复用。

Body 泄漏修复

client := &http.Client{Timeout: 10 * time.Second}  // 全局复用
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := client.Do(req)
if err != nil {
    return err
}
defer resp.Body.Close()
io.Copy(io.Discard, resp.Body) // 需要复用连接时,应读完

同时在 Transport 上配置 ResponseHeaderTimeout、IdleConnTimeout;CI 引入 bodyclose linter。


range 未关闭的 channel

for job := range queueJobs {   // 生产者结束后未 close,消费者永久阻塞
    handle(job)
}

Uber 博客列为独立类型。根因是 channel 的关闭责任不明确。

修复:由唯一的生产方(或 wg.Wait() 之后的协调者)close(ch),并遵循“谁创建、谁关闭,只关闭一次”。


Ticker / Timer 未停止

func loop() {
    t := time.NewTicker(time.Second)   // 缺少 defer t.Stop()
    for range t.C { ... }
}

time.Tick(d)  // 无法 Stop,Go 1.23 之前在非全生命周期场景下必然泄漏

要点:

  • 后台循环函数退出时未 Stop,且循环缺少 ctx.Done() 分支,协程与 ticker 同时泄漏;
  • select 循环中使用 time.After,Go 1.23 之前未触发的 timer 在到期前不会被回收,高频循环会累积内存;Go 1.23 起无引用的 timer 和 ticker 都可被 GC(time.Tick 也不再必然泄漏),但热路径仍建议用 NewTimer + Stop/Reset,Ticker 也应显式 Stop。

修复:

t := time.NewTicker(d)
defer t.Stop()
for {
    select {
    case <-ctx.Done():
        return
    case <-t.C:
        tick()
    }
}

后台协程无生命周期

典型表现:为每个请求、连接、订阅 go func(),但没有统一的退出信号。

  • gRPC DialContext 泄漏(grpc-go PR #1424,2017):DialContext 在 balancer 返回地址或连接建立前超时,lbWatcher 循环 break 后 doneChan 未被关闭,相关协程无法退出。Go 官方在其 goker 泄漏基准用例中收录了 grpc-go 的多个类似缺陷(如 grpc-go #660、#862、#1275),可作为学习素材;
  • 长连接 / WebSocket / 订阅:连接断开时,读协程退出,但写协程仍阻塞在 outCh 上,或反之;
  • 无界并发:每个请求起 N 个协程,下游变慢时协程堆积,表现类似泄漏。

修复原则:

  1. 每个 go 语句必须能回答“它在何时、由谁终止”;
  2. 使用 context 传递取消信号,使用 errgroup / WaitGroup 管理生命周期;
  3. 用 worker pool 或 errgroup.SetLimit 限制并发数;
  4. 服务关闭时(graceful shutdown)等待所有后台协程退出。

按上述原则改写后的示例:context 负责通知,Wait() 负责等待,两者缺一不可。

func fetchAll(ctx context.Context, items []int) ([]int, error) {
    g, ctx := errgroup.WithContext(ctx) // 任一子任务返回错误,ctx 自动取消
    g.SetLimit(4)                       // 限制并发数

    results := make([]int, len(items))
    for i, it := range items {
        g.Go(func() error {
            v, err := process(ctx, it) // 工作函数检查 ctx,取消后能提前退出
            if err != nil {
                return err
            }
            results[i] = v // 每个协程只写自己的下标,无需加锁
            return nil
        })
    }

    if err := g.Wait(); err != nil { // 等所有子协程退出,返回时不残留协程
        return nil, err
    }
    return results, nil
}

func process(ctx context.Context, it int) (int, error) {
    select {
    case <-time.After(10 * time.Millisecond): // 模拟耗时计算
        return it * 2, nil
    case <-ctx.Done():
        return 0, ctx.Err()
    }
}

注意两点:ctx 只通知,子协程必须自己检查 ctx.Done()(如 process),否则取消后仍不会退出,Wait() 也会跟着卡住;示例依赖 Go 1.22+ 的循环变量按次迭代语义,更早版本需在循环内重新绑定 i, it := i, it。


WaitGroup 与锁误用

  • sync.WaitGroup:Add 与 Done 不匹配(如 panic 或提前 return 跳过 Done),Wait() 永久阻塞,其所在协程泄漏。应使用 defer wg.Done();
  • sync.Mutex:加锁后在某条错误路径遗漏 Unlock,后续所有获取者阻塞。应 defer mu.Unlock();
  • 嵌套持锁并发送 channel,形成锁与 channel 的循环等待。

检测与排查

开发阶段

  • goleak:在 TestMain 中调用 goleak.VerifyTestMain(m),单测结束时校验是否存在残留协程。Uber 数据显示其可拦截大量新增泄漏;
  • 对每个并发函数补充“调用方取消 / 超时 / 提前返回”的测试用例。

生产阶段

  1. 监控:采集 runtime.NumGoroutine()(或 go_goroutines 指标,client_golang 默认提供),对持续增长设置告警;
  2. 抓取 goroutine profile:暴露 net/http/pprof,间隔数分钟取两次快照对比:
    curl -s 'http://host:6060/debug/pprof/goroutine?debug=1' > g1.txt
    # 数分钟后
    curl -s 'http://host:6060/debug/pprof/goroutine?debug=1' > g2.txt
    重点关注数量最多且持续增长的栈,以及状态为 chan send / chan receive / select / semacquire 的长期阻塞协程;
  3. 区分受害者与根因:如 Coder 案例,堆积的 readLoop/writeLoop 只是症状,需顺着调用方定位未关闭的请求;
  4. 自动化:Uber 的 LeakProf 周期性采集 pprof,按源码位置聚合阻塞协程,超过阈值即告警给服务负责人,是可借鉴的思路;
  5. Go 1.27+:goroutine leak profile。该 profile 在 Go 1.26 中是实验特性(需 GOEXPERIMENT=goroutineleakprofile),1.27 起正式可用,goroutineleakprofile 实验开关已删除:
    p := pprof.Lookup("goroutineleak") // runtime/pprof
    p.WriteTo(os.Stdout, 1)
    已引入 net/http/pprof 的服务可直接访问 /debug/pprof/goroutineleak。它借助 GC 可达性分析:goroutine 阻塞的并发原语若无法被任何可运行的 goroutine 触及,即判定为泄漏,直接给出泄漏协程及其栈,无需再靠两次快照对比。局限是只能识别“确定永久阻塞”的情形,若阻塞的 channel / 锁仍可通过全局变量或存活协程的局部变量访问到,则会漏报,因此不能替代 goleak 与监控;低于 1.27 的版本仍需使用前述手段。

总结与 Review 清单

场景 根因 核心修复
无缓冲 channel 发送阻塞 接收方提前退出(返回 / 超时 / 只收一次) 缓冲 channel;select + ctx.Done()
HTTP Body 未关闭 / 无超时 连接无法归还池;请求无限等待 defer Body.Close();Client 超时;复用 Transport
range 未关闭的 channel 关闭责任不明 生产方 close;wg.Wait() 后统一关闭
Ticker / Timer 未停止 缺少 Stop 与退出分支 defer t.Stop();ctx.Done()
后台协程无生命周期 没有取消信号与等待机制 context + errgroup;并发上限
WaitGroup / Mutex 误用 异常路径遗漏 Done / Unlock defer

Review 时对每个 go 语句自问:

  1. 这个协程在哪些条件下退出?所有分支(含错误、超时、取消)都覆盖了吗?
  2. 它阻塞的每个 channel 操作,对端是否一定存在?
  3. 调用方提前返回后,协程是否会被取消或自行结束?
  4. 并发量是否有上限?

参考资料