线上 Go 服务最隐蔽的性能问题之一,就是 goroutine 泄漏:协程本该完成任务后退出,却因为 channel 没人收、context 没人 cancel,长期挂起不释放。表现通常是实例内存缓慢上涨、goroutine 数量持续暴涨,最终拖垮吞吐甚至 OOM。本文结合一次真实排查,把泄漏的成因、定位和防御一次讲透。
一、什么是 goroutine 泄漏
Go 的 goroutine 非常轻量,但“轻量”不等于“免费”。每个 goroutine 都占用栈内存(初始 2KB 起,可增长)并登记到调度器。当一个 goroutine 永远卡在阻塞操作上、再也走不到退出逻辑时,它就成了一个“僵尸协程”——既不再干活,也不会被回收,这就是 goroutine 泄漏。泄漏累积到几十万、上百万时,内存和调度开销都会显著恶化。
二、两类典型泄漏场景
1. channel 写入没有接收方
最常见的写法:启动一个 goroutine 向无缓冲 channel 写数据,但函数返回前没有消费端,协程永远阻塞在发送上。
func leak() {
ch := make(chan int) // 无缓冲
go func() {
ch <- 1 // 永远阻塞:没有接收方
}()
// 函数返回,但上面的 goroutine 仍在等待,泄漏
}
修复方式要么给 channel 加缓冲,要么用 select 配合 default 或 timeout,保证发送方在无人接收时能及时退出。
2. context 忘记 cancel 导致 select 永久阻塞
使用 context.WithCancel 时,如果不调用 cancel(),子协程里的 select { case <-ctx.Done(): } 永远等不到信号,协程一直挂着。这是 HTTP 请求处理、后台任务里最易踩的坑。
func handle() {
ctx, cancel := context.WithCancel(context.Background())
go worker(ctx)
// 缺少 defer cancel(),worker 中的 select 永远阻塞
// 正确写法:defer cancel()
}
更稳妥的是直接使用 context.WithTimeout,即便忘记 cancel,定时器到期也会自动释放,把“可能泄漏”变成“必然回收”。
三、怎么发现:监控 + pprof
泄漏不会一上来就炸,所以必须靠监控提前发现。最便宜的指标就是 runtime.NumGoroutine():把它作为 gauge 暴露给监控系统,一旦曲线偏离基线持续上扬,基本就是泄漏信号。更深入的定位则交给 pprof 火焰图——它不仅能看 CPU,还能抓取完整的 goroutine 堆栈。
// 暴露协程数,配合 Prometheus 做增长率告警
func goroutineGauge() int {
return runtime.NumGoroutine()
}
// 同时开启 net/http/pprof,按需抓取快照
import _ "net/http/pprof"
// 访问 http://host:6060/debug/pprof/goroutine?debug=1 查看全部堆栈
四、实战:一次协程暴涨告警
某次发布后,监控显示单实例 goroutine 从 200 一路涨到 8 万。登录机器后用 pprof 抓取 goroutine profile,按数量排序定位“元凶”堆栈:
# 抓取 goroutine 快照(默认采 1 分钟内的活跃堆栈)
go tool pprof http://localhost:6060/debug/pprof/goroutine
# 进入交互界面后
(pprof) top # 按数量排序,找出堆积最多的调用栈
(pprof) traces # 展开完整调用链,定位到具体业务函数
(pprof) web # 生成调用关系图,快速圈定泄漏点
top 很快指出 90% 的协程都卡在同一个 select 分支——核对代码发现是一处忘记 cancel() 的 context。根因是某个错误分支提前 return,绕过了 defer cancel(),导致那一条路径上的 worker 全部泄漏。修复后 goroutine 数在几分钟内回落到正常基线。
这类排查思路与 Go 并发与 channel 实战 中强调的“谁创建、谁负责退出”原则一脉相承:协程的生与死必须成对管理。修复后的服务通常用 Docker 容器化部署,并在 GitHub Actions 流水线里跑单测,把泄漏挡在发布之前。
五、修复与防御清单
定位只是第一步,真正的目标是让它不再发生。下面是一份可在 Code Review 和单测阶段落地的防御清单:
| 风险点 | 检测手段 | 防御措施 |
| channel 写入无接收方 | pprof goroutine 堆栈定位 | 用带缓冲 channel,或 select + default/timeout |
| context 未 cancel | goleak 单元测试 | defer cancel(),优先用 WithTimeout |
| 无界协程池 | NumGoroutine 监控告警 | 信号量 / errgroup / 限制最大并发 |
| 协程内 panic 卡死 | 监控 + recover 兜底 | 协程入口统一 defer recover() |
强烈建议在单测里引入 go.uber.org/goleak,它的 goleak.VerifyTestMain 能在测试结束时自动检测是否还有未退出的 goroutine,把泄漏拦截在 CI 阶段:
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
类似“用完不释放”的坑在 Redis 连接池与泄漏 中也常见:都是资源生命周期管理不到位。把“创建即规划退出”刻进编码习惯,比事后救火成本低得多。
六、小结
goroutine 泄漏的本质是“协程的生命周期没有被正确关闭”。排查三板斧:用 runtime.NumGoroutine() 做监控告警发现异常,用 pprof 的 goroutine profile 定位堆栈,用 goleak 在单测阶段拦截回归。写并发代码时牢记——每一个 go 关键字背后,都要有清晰的退出路径。把这条纪律固化到 容器化部署与 CI 流水线,线上协程暴涨的告警就会越来越少。




