2379 字
约 7 分钟
15
S09-A14 · goroutine 的退出路径:泄漏从哪里来
2024-12-31
2026-11-09

一个 Go 服务运行三天后内存缓慢上涨,runtime.NumGoroutine() 从启动时的两百多涨到九万。pprof 的 goroutine 视图里,四万多个栈停在同一个位置:某个 select 上等待一个永远不会有消息的 channel。

goroutine 泄漏与内存泄漏不同,它更隐蔽——泄漏的 goroutine 通常"安静地阻塞着",不消耗 CPU、不报错、不触发告警,只占用一点栈内存。等到数量上万,内存与调度开销才显形,而这时已经很难追溯到最初那一行代码。


1. 泄漏的四种来源

来源 机制 特征
无缓冲 channel 发送无接收方 发送方永久阻塞 栈停在 chan send
接收方先退出 发送方等一个不会来的接收 栈停在 chan send
循环里起 goroutine 但无退出条件 每次循环新增一个 数量随请求数线性增长
请求超时但 goroutine 未取消 没有 ctx 传递 栈停在下游调用

还有一个来源不在上表:库内部的泄漏。某些客户端库在初始化时会起后台 goroutine(心跳、重连、指标上报),如果初始化被重复调用(比如每次请求新建客户端),这些 goroutine 会成倍增加,而调用方完全不知情。这类泄漏的栈会停在库内部,看起来与自己的代码无关。

判断方法:看泄漏栈的顶层包名。指向自己代码的是用法问题,指向第三方库的是生命周期管理问题——客户端应当单例复用,而不是每次新建。

第三种最容易被忽略,因为它只发生在"每次请求都起一个"的写法里,单次看不出问题,累积才显示。

// 反例:每次请求起一个 goroutine,没有任何退出路径
func (s *Svc) Handle(q Query) {
    go func() {
        for {
            select {
            case m := <-s.updates:      // 若 updates 不再有消息,这个 goroutine 永久驻留
                process(m)
            }
        }
    }()
    // 请求结束,goroutine 留下
}

2. 退出路径必须有三个要素

一个 goroutine 能被回收,需要三样东西同时存在。

要素 作用 缺失后果
取消信号 告诉它该结束 永远不知道要退出
退出条件 检查信号的时机 收到信号也不处理
收尾保证 关闭资源、上报状态 退出时泄漏资源

三者缺一即泄漏。最常见的是缺第三个:defer 里关闭的 channel 忘了写,goroutine 退出了但连接没关。

// 完整形态:三个要素齐备
func (s *Svc) worker(ctx context.Context) {
    defer close(s.done)                  // 收尾:退出时通知
    for {
        select {
        case <-ctx.Done():               // 取消信号
            return                       // 退出条件
        case m, ok := <-s.updates:
            if !ok { return }            // channel 关闭也是一种退出信号
            process(m)
        }
    }
}

第二个退出条件(ok == false)常被漏掉。channel 被关闭后,从它读取会立刻返回零值而不是阻塞,如果没有 ok 判断,循环会变成忙等——这比泄漏更糟,它消耗 CPU。

3. 三种常见的写法错误

错误写法 问题 修法
go func(){ ... }() 里不接 ctx 无法取消 把 ctx 作为参数传入
在 select 中缺少 ctx.Done() 分支 永不退出 加上该分支
起 goroutine 后不保存任何句柄 无法等待、无法判断 用 WaitGroup 或 errgroup

第三种造成的不是泄漏而是"无法确认完成":很多测试与优雅关闭的问题都源于此。每一个 goroutine 都应当有人负责等它。

// 用 errgroup 统一管理:可等待、可取消、可收集错误
g, ctx := errgroup.WithContext(parent)
for i := 0; i < 4; i++ {
    g.Go(func() error { return s.worker(ctx) })
}
if err := g.Wait(); err != nil {     // 任一出错则取消其余,并等待全部退出
    return err
}

4. 超时与取消的传递

请求超时是最常见的泄漏触发点,而根因往往是 ctx 没有继续往下传。

位置 应该做什么 常见遗漏
HTTP handler 从请求取 ctx 用 context.Background()
数据库调用 传 ctx 用不带 ctx 的方法
下游 RPC 传 ctx 自己新建超时
内部 goroutine 继承 ctx 起 goroutine 时丢掉

可以给这条规则加一个静态检查:禁止在不接收 ctx 的函数里创建 goroutine。实现成本很低——写一条 lint 规则或者在评审清单里加一行——收益是消灭一整类泄漏。

第一项最典型:handler 里 context.Background() 一写,整条链路的取消能力全部消失。请求超时后 handler 返回,但下游调用还在跑,那些 goroutine 就成了孤儿。

// 反例:切断了取消链
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background()                 // 丢弃了 r.Context()
    rows, _ := h.db.QueryContext(ctx, "...")    // 请求取消后这条查询仍在跑
}

// 正确:全程传递,必要时派生带超时的子 ctx
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()                              // 必须 defer,否则泄漏 timer
    rows, err := h.db.QueryContext(ctx, "...")
}

defer cancel() 这一行本身也是泄漏源:忘记调用会让 context 的计时器一直存在,直到超时。它是少数"泄漏但很难通过 goroutine 数量发现"的情况。

5. 发现泄漏的三个手段

手段 能看到什么 适用
runtime.NumGoroutine() 总量趋势 告警、发现增长
pprof/goroutine 每个栈的调用点与数量 定位具体位置
goroutine 差分 两个时刻的增量 找出新增的栈

第三个最实用:总量上涨时,差分会直接指出是哪几行代码在增长。

// 抓两次 goroutine 栈,做差分:新增的都是泄漏嫌疑
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=1" > g1.txt
sleep 60
curl -s "http://localhost:6060/debug/pprof/goroutine?debug=1" > g2.txt

// 对比两次的栈计数,找出增长的调用点
diff <(sort g1.txt) <(sort g2.txt) | grep '^>' | head -30
// 注意:debug=1 输出的是聚合后的栈,diff 前先按计数排序更易读

差分之外还有一个更省事的判据:只看栈的聚合视图里出现次数最多的那一个栈。稳定系统里,最多的栈通常是 accept 或事件循环;如果最多的栈变成某个业务函数,那就是泄漏点。

生产上还有一个更简单的做法:把 NumGoroutine() 作为一个指标上报,并设置"持续单调上升"的告警,而不是绝对值告警。绝对值取决于流量,趋势才反映泄漏。

6. 排错:五种"goroutine 数量异常"

现象 成因 处理方向
数量随请求量线性涨 每次请求起 goroutine 无退出 加 ctx 与 WaitGroup
数量平稳但内存涨 不是 goroutine 泄漏 查对象分配与缓存
数量周期涨落 批量任务或定时任务的并发 属正常,看峰值是否可接受
优雅关闭时超时 goroutine 不响应取消 补齐 ctx.Done() 分支
数量在故障后不回落 下游恢复但 goroutine 卡住 检查是否有永久阻塞的 channel

第五项值得单独说:下游恢复之后,卡住的 goroutine 不会自己醒来。它们停在一个已经不会再收到数据的 channel 上,即使下游正常了也一样。这就是为什么"故障恢复了但内存没降"是泄漏的典型信号。

第一项与第三项容易混淆。区分方法:看增长是否与请求数成比例。与请求成比例的是泄漏;与时间周期一致的是任务并发。

7. 边界:什么情况不必严格管理

场景 为什么 做法
进程生命周期内的常驻 worker 数量固定,不增长 起一次,不回收
一次性命令行工具 退出即释放 不加取消逻辑
数量固定的连接池内部 由池管理 交给池的实现
极短生命周期的 goroutine 自然结束 不必显式取消

判断口径一句话:这个 goroutine 有没有可能永远等下去。有可能,就必须有取消路径;不可能(一定会返回),显式管理只是形式。


8. 自测

  1. goroutine 泄漏为什么比内存泄漏更隐蔽?它在什么阶段才显形?
  2. 一个 goroutine 能被回收需要哪三个要素?缺"收尾保证"会造成什么具体后果?
  3. channel 关闭后,为什么缺少 ok 判断的循环比泄漏更糟?
  4. context.Background() 在 handler 里出现,会切断哪条链路?后果是什么?
  5. 为什么 defer cancel() 的遗漏难以通过 goroutine 数量发现?
  6. goroutine 告警为什么应该盯趋势而不是绝对值?如何区分泄漏与正常的任务并发?
S09-A14 · goroutine 的退出路径:泄漏从哪里来
https://www.clxhxhhr.top/posts/6399/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。