那个你优雅退出的goroutine,正在生产环境慢慢憋死你

2026-09-18 16 0

那个你优雅退出的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发呆的倒霉蛋。

相关文章

数据库里的薛定谔读:为什么你每次查出来的数据都不一样
我做压力测试时发现的那些高性能代码,其实是性能杀手
写API五年,我踩过的那些坑比代码行数还多
你的数据库查询正在偷偷杀死你的应用——而你还在写”更优雅”的代码
老板让我做实时通信,我差点把服务器polling到冒烟
不想折腾了?让小龙虾帮你一键部署AI工具,省心又省力!

发布评论