那个你优雅退出的goroutine,正在生产环境慢慢憋死你
上周五晚上十点半,我正在看一集烂剧给自己放松,突然收到告警——CPU占用率飙到97%,服务开始大量超时。我赶紧打开监控,一看goroutine数量:从正常的200多个,涨到了14000多个。翻了半天代码,找到元凶的那一刻,我差点把键盘扔了——是一个三方SDK里藏着的、没人知道的goroutine,它的退出条件永远不可能满足。
这不是我第一次被goroutine泄漏坑了。估计你也有类似的经历:系统跑着跑着,goroutine数量只涨不跌,GC越来越慢,最后OOM或者被内核OOM Killer直接送走。今天咱们就好好聊聊这个东西——goroutine泄漏是怎么产生的,怎么检测,以及怎么优雅地把它送走。
goroutine泄漏的本质:没人管的孩子
goroutine是Go语言最引以为傲的特性之一,创建成本极低(只需要2KB栈空间),切换代价小,理论上你可以轻松创建成千上万个。但问题来了——创建容易,销毁难。
当你启动一个goroutine,它就开始执行了。但如果它的退出条件永远无法满足,或者启动它的代码根本不知道它什么时候该停止,它就会变成一个孤儿——占用着内存和调度资源,但不再产出任何有价值的工作。
这不是内存泄漏,因为内存最终还是会被GC回收。但它是goroutine泄漏——goroutine本身是资源,而你丢失了对它的追踪和控制权。
几个让你中招的经典场景
场景一:channel永远没人读
这是最常见的一种。代码里开了goroutine往channel写数据,但读数据的那边不知道什么原因不读了——可能是早期逻辑变动,可能是异常分支跳过了读取。
func process(data []string) {
ch := make(chan string, 100)
go func() {
for _, d := range data {
ch <- d // 如果没人读,这里永远阻塞,goroutine永远不会退出
}
}()
// 业务逻辑...
// 假设这里抛出了异常,函数提前返回
// ch 永远没人读,goroutine卡在 ch <- d 这一行
}
这种情况编译器救不了你,静态分析也检测不出来。只有运行时才会暴露——当你看到goroutine数量开始爬坡的时候。
场景二:context.WithCancel 你真的cancel了吗
很多人写了类似这样的代码:
func parent(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // 看起来很优雅
go child(ctx)
// 如果这里提前return,defer会帮你cancel
// 但如果child启动了自己的goroutine,cancel只取消ctx,child goroutine未必退出
}
问题在于:context.WithCancel只能取消监听这个context的代码。如果child函数内部又起了新的goroutine,而新的goroutine并没有监听这个context——cancel对它毫无作用。
func child(ctx context.Context) {
// 这个监听了ctx,会被cancel
go someLongRunningTask(ctx)
// 这个没监听ctx,cancel管不着!
go func() {
for {
time.Sleep(time.Second)
// 永远循环,cancel对它无效
}
}()
}
你以为你cancel了context,所有goroutine都会优雅退出——实际上只有那些真正监听它的才会。
场景三:goroutine持有引用,导致级联泄漏
有时候泄漏的不只是一个goroutine,而是一整条引用链。
type Handler struct {
ch chan string
// 假设这个handler被放到了一个全局map里
}
func (h *Handler) process() {
go func() {
for msg := range h.ch {
// 处理消息...
}
}()
}
// 如果Handler被泄露,它的ch永远不会关闭
// goroutine永远卡在 for msg := range h.ch 这一行
这个handler如果被某个cache或者全局map引用着,即使业务上已经不用了,GC也不会回收它——因为goroutine还持有它的引用。这叫闭包引用泄漏,是goroutine泄漏的放大器。
场景四:第三方SDK的隐藏goroutine
这个最坑。我那次生产事故的元凶就是一个HTTP客户端SDK,它在初始化时启动了一个goroutine专门做连接池维护和健康检查。但这个goroutine的退出逻辑依赖于一个flag,而这个flag只有在主动调用SDK的Close方法时才会被设为false。
问题是——很多业务代码根本不知道需要调用Close,或者说代码里确实调用了Close,但因为某些原因Close没执行到。SDK文档里小到几乎看不见的一行字,就让你的goroutine数量在生产环境里慢慢膨胀。
对第三方SDK保持警惕,是Go项目里非常重要的一个习惯。
怎么检测goroutine泄漏
方案一:pprof + runtime.NumGoroutine
最简单的方式是在代码里埋一个监控点:
import (
"net/http"
_ "net/http/pprof"
"runtime"
)
func reportGoroutineCount() {
n := runtime.NumGoroutine()
// 上报到你的监控系统
prometheus.MustRegister(prometheus.NewGaugeFunc(
prometheus.GaugeOpts{
Name: "goroutine_count",
Help: "Current number of goroutines",
},
func() float64 { return float64(runtime.NumGoroutine()) },
))
}
结合pprof的heap profile,你可以拿到当前所有goroutine的堆栈信息,对比一下就能定位是谁创建的。
方案二:goleak——最被低估的工具
Uber开源的goleak是单元测试里检测goroutine泄漏的神器。只要在测试的teardown里加上:
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
每次测试跑完,goleak会自动检测是否有goroutine泄漏并fail测试。对于异步操作多的模块,这个工具能让你在CI阶段就把泄漏问题抓出来,而不是等到生产环境。
我强烈建议所有做异步、并发开发的团队把goleak加到测试框架里。这玩意儿能省掉你无数个深夜救火的周末。
方案三:强制退出检测(适合长期运行的服务)
如果你想在服务层面做个定期检查,可以定时触发一次pprof goroutine profile,然后分析堆栈里的goroutine分布:
func checkGoroutineLeak() {
buf := make([]byte, 2<<20)
n := runtime.Stack(buf, true)
// 解析这个栈,统计各goroutine的状态
// 如果发现大量goroutine卡在同一个ch操作上,说明大概率存在泄漏
}
怎么真正优雅地退出goroutine
检测是手段,修复才是目的。这里有几个实践了无数次的最佳退出模式:
模式一:所有goroutine必须监听context
启动goroutine时,把ctx传进去,让它监听ctx.Done()。这是Go里最标准的取消信号。
func parent(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- doAsyncWork(ctx)
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
return ctx.Err()
}
}
关键点:goroutine内部要写好select { case <-ctx.Done(): return }这样的退出检查。
模式二:使用sync.WaitGroup管理生命周期
func process(ctx context.Context, items []string) error {
var wg sync.WaitGroup
errCh := make(chan error, len(items))
for _, item := range items {
wg.Add(1)
go func(i string) {
defer wg.Done()
if err := handle(i); err != nil {
errCh <- err
cancel() // 出错时取消context
}
}(item)
}
wg.Wait()
close(errCh)
var errs []error
for err := range errCh {
errs = append(errs, err)
}
return errors.Join(errs...)
}
WaitGroup能让你明确知道所有goroutine什么时候退出了——这是goroutine生命周期管理里最可靠的工具之一。
模式三:channel + close模式
当你想让一个goroutine停止时,关闭它的输入channel是最简洁的信号:
func worker(tasks <-chan string, done <-chan struct{}) {
for {
select {
case task, ok := <-tasks:
if !ok {
// tasks channel被关闭了,退出
return
}
process(task)
case <-done:
// 收到外部退出信号
return
}
}
}
关闭一个channel会立即让所有监听这个channel的select分支的ok值变为false——这是Go里最优雅的广播机制。
说点真心话
goroutine泄漏这个问题,说难也难,说简单也简单。难在它往往是静默的——你的代码本地测试没问题,并发测试也没问题,但跑着跑着,生产环境的goroutine数量就开始爬坡了。简单在——只要你遵循几个基本原则,基本可以杜绝:
- 所有goroutine必须能退出,要有明确的退出路径
- context必须传递,并且goroutine要监听它
- WaitGroup配合defer Done(),确保goroutine一定被计数
- 使用goleak做CI级别的泄漏检测
- 对第三方SDK保持警惕,使用前看文档,用完后记得Close
说到底,goroutine泄漏是一个资源生命周期管理的问题。Go把并发写得太容易了,容易到很多人不把它当回事。但当你有一千个请求同时触发,一千个goroutine被启动,却没有一个能被正确回收的时候——你就会知道,出来混迟早是要还的。
下次你再写一个go func()的时候,问自己一个问题:这个goroutine,怎么退出?如果三秒内答不上来——建议先别写,想清楚再动键盘。
毕竟,你不想成为那个半夜被告警吵醒、看着14000个goroutine发呆的倒霉蛋。