概述
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 工程博客记录了完整排查过程:
- 现象:dogfood 集群
coderd每次重启后内存与 goroutine 数稳定爬升,斜率与白天/夜间业务负载无关,说明来源是后台定时任务而非 API; - 定位:pprof 显示约 820 个
readLoop和 820 个writeLoopgoroutine,即net/http客户端连接池的读写协程——它们只是受害者; - 缩小范围:监控呈每小时一次的阶梯上涨。团队把 4 个每小时任务的周期改为质数分钟(47/53/59/67),使各任务的影响可区分,最终锁定“检查容器镜像 tag”任务;
- 根因:容器镜像仓库客户端库
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 个协程,下游变慢时协程堆积,表现类似泄漏。
修复原则:
- 每个
go语句必须能回答“它在何时、由谁终止”; - 使用
context传递取消信号,使用errgroup/WaitGroup管理生命周期; - 用 worker pool 或
errgroup.SetLimit限制并发数; - 服务关闭时(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 数据显示其可拦截大量新增泄漏; - 对每个并发函数补充“调用方取消 / 超时 / 提前返回”的测试用例。
生产阶段
- 监控:采集
runtime.NumGoroutine()(或go_goroutines指标,client_golang 默认提供),对持续增长设置告警; - 抓取 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.txtchan send/chan receive/select/semacquire的长期阻塞协程; - 区分受害者与根因:如 Coder 案例,堆积的
readLoop/writeLoop只是症状,需顺着调用方定位未关闭的请求; - 自动化:Uber 的 LeakProf 周期性采集 pprof,按源码位置聚合阻塞协程,超过阈值即告警给服务负责人,是可借鉴的思路;
- 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 语句自问:
- 这个协程在哪些条件下退出?所有分支(含错误、超时、取消)都覆盖了吗?
- 它阻塞的每个 channel 操作,对端是否一定存在?
- 调用方提前返回后,协程是否会被取消或自行结束?
- 并发量是否有上限?
参考资料
- Uber: LeakProf: Featherlight In-Production Goroutine Leak Detection
- Uber 论文: Unveiling and Vanquishing Goroutine Leaks in Enterprise Microservices (CGO 2024)
- Coder: Go leak mysteries
- Ardan Labs: Goroutine Leaks - The Forgotten Sender
- Go issue: net/http: permanently broken connection when response body is not closed #36700
- Go: ctxhttp: fix case where Body could leak and not be closed
- Go: Go 1.27 Release Notes(goroutine leak profile)
- Go 官方泄漏基准用例:goker/grpc1275
- Azure ARO-RP: Fix goroutine leak in monitor workOne due to unbuffered channel #4714