一个 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. 自测
- goroutine 泄漏为什么比内存泄漏更隐蔽?它在什么阶段才显形?
- 一个 goroutine 能被回收需要哪三个要素?缺"收尾保证"会造成什么具体后果?
- channel 关闭后,为什么缺少
ok判断的循环比泄漏更糟? context.Background()在 handler 里出现,会切断哪条链路?后果是什么?- 为什么
defer cancel()的遗漏难以通过 goroutine 数量发现? - goroutine 告警为什么应该盯趋势而不是绝对值?如何区分泄漏与正常的任务并发?