内存只涨不降、几万协程卡死:Go goroutine 泄漏避坑

有个 Go 后台服务跑了几周后运维找上门:进程内存像吹气球一样一天涨一点、从不回落,最后逼近上限被 OOM 杀掉,重启再涨周而复始。我先按内存泄漏的老套路查对象没揪出元凶,直到顺手看了眼 runtime.NumGoroutine 返回的协程数量当场倒吸一口凉气:这个数字也在只涨不降,从启动时几十个涨到几万个还在稳步往上爬。真相浮出水面——不是对象泄漏,是 goroutine 泄漏:每处理一个请求代码都 go 出去一个协程做异步活,可这些协程卡在一个没人会再往里写数据的 channel 上,痴痴等一个永远不会到来的值;主流程在某条出错分支里提前 return 走了、再没人接收,协程算完结果想发却发现接收方早已离场,永远卡死,每个都占着栈内存和捕获的变量,几万个累积起来内存自然爆了。这篇文章从这次 goroutine 撑爆内存的事故出发,讲透协程泄漏:它如何卡在 channel 上、用 context 给协程退出之路、结果通道留缓冲 1 让发送方解脱、WaitGroup 的 Add/Done/Wait 正确姿势、用信号量与 worker pool 限并发、用 NumGoroutine 与 pprof 检测定位,以及 for-select 里 time.After 的隐蔽泄漏和用 errgroup 打包取消等齐收错。

有个 Go 写的后台服务,跑了几周后运维找上门:这进程的内存像被吹气球一样,一天涨一点,从不回落,最后逼近上限被 OOM 杀掉,重启,再涨,周而复始。我先按内存泄漏的老套路查对象,没揪出明显的元凶。直到我顺手看了眼 runtime.NumGoroutine() 返回的 goroutine 数量,当场倒吸一口凉气:这个数字也在只涨不降,从启动时的几十个,涨到了几万个,而且还在稳步往上爬。

真相浮出水面:不是对象泄漏,是 goroutine 泄漏。每处理一个请求,代码里都会 go 出去一个协程去做点异步的活儿,可这些协程因为某种原因永远阻塞、永远不退出——它们卡在一个没人会再往里写数据的 channel 上,痴痴地等一个永远不会到来的值。每个卡住的 goroutine 都占着自己的栈内存、还攥着它捕获的那些变量不放,几万个累积起来,内存自然就爆了。它们像一群进了门却再也没出来的客人,把屋子越挤越满。

这就是 Go 并发编程里最隐蔽、也最容易被忽视的一类问题:goroutine 泄漏go 关键字让开启一个协程的成本低到几乎可以忽略,低到你会忘记问一句——它会不会结束?什么时候结束?而一个永不结束的 goroutine,就是一处永不释放的资源泄漏。这篇文章,就从这次"goroutine 越积越多撑爆内存"的事故出发,把协程泄漏的成因和防治,一次讲透。

先摆几个关于 goroutine 的想当然

动手复盘前,先把我自己曾经深信、后来被这次泄漏教育的几个念头摆出来。

想当然的念头 残酷的真相
"goroutine 很轻量,随便开,开完不用管" 轻量不等于免费, 不退出的 goroutine 就是持续的内存泄漏
"函数返回了,它里面开的 goroutine 也会跟着结束" goroutine 生命周期独立, 主函数返回它照样阻塞在那
"channel 收发总会配对的" 一方没了/没人收发, 另一方就永久阻塞, 协程卡死
"加了超时控制就万无一失" 超时只让调用方走人, 后台那个 goroutine 可能还在傻等
"goroutine 泄漏顶多浪费点协程" 每个都占栈内存 + 捕获的变量, 累积起来直接 OOM

这些念头的共同病根,是被 go 的极致轻量蒙蔽,把"开一个 goroutine"当成了一件无需负责、无需善后的小事,却忘了任何被启动的东西,都需要有一条明确的退出之路。要看清这次事故,得先理解 goroutine 是怎么"卡死"的。

第一件事:goroutine 是怎么泄漏的——卡在 channel 上

goroutine 泄漏,绝大多数情况下都和 channel 阻塞有关。Go 的 channel 有个基本规则:对一个无缓冲 channel,发送方会一直阻塞,直到有接收方来收;接收方也会一直阻塞,直到有发送方来发。这种"必须配对"的同步语义很优雅,但也埋着陷阱——一旦配对的另一方因为某种原因不出现了,这一方就会永远阻塞下去。而一个永远阻塞在 channel 上的 goroutine,既不会执行完、也不会被回收,它就那么"挂"着,占着资源,直到进程结束。

我那次的代码,就是典型:请求处理函数里 go 出一个协程,让它把计算结果发到一个 channel 上;主流程本该从这个 channel 接收结果,但在某条出错的分支里,主流程提前 return 走了,再没人来接收。于是那个协程算完结果、想往 channel 里发,却发现接收方早已离场——它就永远卡在了那个发送操作上。下面这张图,把这个泄漏的形成过程画出来:

看懂这张图,事故的根就清楚了:问题不在协程本身的逻辑,而在于它的退出依赖一个不一定会发生的事件(有人来接收),而当那个事件因为出错分支没发生时,它就失去了所有退出的可能。每开一个 goroutine,都要能回答"它在什么情况下、通过什么路径退出",回答不上来,就是一个潜在的泄漏。接下来,我们就看怎么给每个 goroutine 都铺好退出的路。

第二件事:用 context 给每个 goroutine 一条退出之路

根治 goroutine 泄漏的核心思想,是永远不要启动一个无法被通知退出的 goroutine。在 Go 里,实现这件事的标准武器是 context。它就像一个"取消信号的广播站":你把一个 context 传给 goroutine,当任务该结束时(请求完成、超时、出错),取消这个 context,所有监听它的 goroutine 就能收到信号,主动收手退出。

// 反例:协程的退出全靠"有人来接收", 出错分支一走就泄漏
func handle() {
    ch := make(chan int)
    go func() {
        ch <- heavyCompute()   // 没人接收就永远卡在这里
    }()
    if somethingWrong() {
        return                 // 提前返回, 上面那个协程永久泄漏
    }
    use(<-ch)
}

// 正解:用 context, 给协程一条"被通知就退出"的路
func handleSafe(ctx context.Context) {
    ch := make(chan int, 1)    // 带缓冲, 即便没人收也能发进去(下节细说)
    go func() {
        select {
        case ch <- heavyCompute():   // 有人收, 正常发送
        case <-ctx.Done():           // 被取消, 立即退出, 不再痴等
        }
    }()
    select {
    case res := <-ch:
        use(res)
    case <-ctx.Done():               // 主流程也尊重取消信号
        return
    }
}

这里的关键,是那个 select 加上 <-ctx.Done() 分支:它让 goroutine 在"完成自己的活儿"和"收到退出信号"之间二选一,无论哪个先到都能脱身。context 把 goroutine 的退出,从"被动依赖外部事件"变成了"主动响应取消信号"——这是 Go 并发里最该养成的习惯。在 Web 服务里,每个请求的 http.Request 自带一个 context,请求结束或客户端断开时它会自动取消,你只要把它一路传下去,goroutine 就能跟着请求的生命周期一起干净退场。

第三件事:理解缓冲 channel,别让发送方白白卡死

上一段代码里有个容易被忽略的细节:make(chan int, 1) 那个缓冲大小 1,其实大有讲究。前面说过,无缓冲 channel 的发送方必须等到有接收方才能发出去;而带缓冲的 channel,只要缓冲没满,发送方就能把数据放进去、立刻返回,不必等接收方。这个差别,在防泄漏时常常是决定性的。

// 无缓冲:发送方必须等接收方, 接收方没了就永久阻塞
ch1 := make(chan int)      // 容量 0
// go func(){ ch1 <- 1 }() 若无人接收, 该协程永远卡住

// 带缓冲容量 1:即便暂时没人接收, 也能先把值放进缓冲, 发送方解脱
ch2 := make(chan int, 1)
go func() { ch2 <- 1 }()   // 直接放进缓冲返回, 协程顺利结束, 不泄漏
// 这就是"为结果通道留 1 个缓冲位"能避免发送方泄漏的原因

一个非常实用的经验:当你 go 一个协程去算结果、再通过 channel 送回来时,给这个结果 channel 留一个大小为 1 的缓冲位。这样,即便主流程因为超时或出错没来接收,协程也能把结果往缓冲里一塞就退出,而不会卡在发送上泄漏。我那次的事故,只要把结果 channel 从无缓冲改成缓冲 1,那个协程就能顺利退场。

当然,缓冲 channel 不是银弹:缓冲多大要根据场景定,缓冲满了发送方照样阻塞;它也不能替代 context 做取消。正确的姿势,是 context(管取消)和合理的缓冲(管发送方解脱)配合使用——前者负责"该停就停",后者负责"发完就走",两者一起,才能让 goroutine 在各种情况下都有路可退。

第四件事:用 WaitGroup 确保"等所有协程都收工"

另一类常见的泄漏与混乱,源于"开了一批 goroutine,却没等它们干完就走人"。sync.WaitGroup 就是用来解决这个问题的:它像一个计数器,每开一个协程就 Add(1),每个协程干完就 Done(),主流程用 Wait() 阻塞到计数归零——确保所有协程都收了工,主流程才继续。但 WaitGroup 用错也会出乱子,有几个铁规矩。

func processAll(items []Item) {
    var wg sync.WaitGroup
    for _, item := range items {
        wg.Add(1)                  // 必须在 go 之前 Add, 不能放进协程里
        go func(it Item) {         // 把 item 作为参数传入, 别闭包共享循环变量
            defer wg.Done()        // 用 defer 保证无论怎么退出都会 Done
            handle(it)
        }(item)
    }
    wg.Wait()                      // 等全部协程收工, 再继续
}
// 三个要点:Add 在 go 之前; Done 用 defer; 循环变量当参数传进去

这三个要点都是血泪经验:Add 必须在 go 语句之前调用——若放进协程内部,主流程可能在协程还没来得及 Add 时就 Wait 通过了;Donedefer 兜底,确保协程哪怕 panic 或提前 return 也会减计数,否则 Wait 永远等不到归零、主流程就泄漏在那;循环变量要作为参数传进去,这正是上一篇 JS 闭包陷阱在 Go 里的同源版本(老版本 Go 里 for 循环变量也是共享的)。WaitGroup 管的是"等齐",context 管的是"喊停",二者各司其职。

第五件事:给并发数设上限,别无节制地开协程

还有一种"广义的泄漏":不是协程不退出,而是瞬间开出海量协程,把内存和调度器压垮。比如来了一批一百万条的数据,你 for 一遍每条都 go 一个协程,瞬间一百万个 goroutine 一起涌出——即便它们最终都会退出,这一刻的内存峰值也足以让进程崩溃。正确做法是用带缓冲 channel 当信号量,或用 worker pool,把同时运行的协程数限制在一个合理的上限

// 用带缓冲 channel 当信号量, 限制最多 N 个协程同时运行
func processLimited(items []Item, limit int) {
    sem := make(chan struct{}, limit)   // 缓冲 = 并发上限
    var wg sync.WaitGroup
    for _, item := range items {
        wg.Add(1)
        sem <- struct{}{}               // 占一个名额(满了就在此阻塞等待)
        go func(it Item) {
            defer wg.Done()
            defer func() { <-sem }()    // 干完释放名额, 让后面的进来
            handle(it)
        }(item)
    }
    wg.Wait()
}
// 无论多少任务, 同时运行的协程都不会超过 limit, 内存峰值可控

这个模式的精髓,是把"无限并发"约束成"受控并发":sem 这个缓冲为 limit 的 channel,就是一池有限的"许可证",拿到许可才能跑,跑完归还,从而把同时在飞的协程数死死压在上限以下。面对大批量任务,永远要问一句:我会不会一下子开出太多协程?设一个并发上限,是保护服务不被自己压垮的基本功。

第六件事:学会主动检测 goroutine 泄漏

最后,泄漏要能被看见,才能被治理。我那次是靠偶然瞥见 NumGoroutine() 才发现的,但这不该靠运气。Go 提供了趁手的工具:runtime.NumGoroutine() 能实时看协程总数,把它接入监控,数字只涨不降就是泄漏的强烈信号;pprof 则能导出所有 goroutine 的堆栈,让你一眼看出"成千上万个协程都卡在同一行代码上"——那行,就是泄漏点。

import _ "net/http/pprof"   // 匿名导入, 自动注册 pprof 调试路由

func main() {
    // 开一个调试端口, 暴露 pprof
    go func() { http.ListenAndServe("localhost:6060", nil) }()
    // 访问 http://localhost:6060/debug/pprof/goroutine?debug=2
    // 就能看到所有 goroutine 的完整堆栈, 大量卡在同一处即为泄漏点
    // ... 你的正常业务 ...
}
// 另外: 监控里持续上报 runtime.NumGoroutine(), 设"只涨不降"告警

有了这套手段,goroutine 泄漏就从"神秘的内存增长"变成了"可定位、可告警的明确问题"。到这儿,这次事故的成因和防治都齐了,我把它收成一张决策图:

把这套理解建立起来,goroutine 泄漏就再难在你的服务里悄悄滋长。最后,拧成几条可直接照做的铁律:

  1. 开每个 goroutine 前,先想清楚它怎么退出,答不上来就是潜在泄漏。
  2. 用 context 给协程一条取消之路,在 select 里永远留一个 <-ctx.Done() 分支。
  3. 给结果 channel 留缓冲 1,让发送方即便没人接收也能发完即走。
  4. 用 WaitGroup 等齐协程,记牢 Add 在 go 前、Done 用 defer、循环变量当参数传。
  5. 大批量任务必须限并发,用信号量或 worker pool 压住同时运行的协程数。
  6. NumGoroutine() 接入监控,只涨不降就告警,别等 OOM 才发现。
  7. 会用 pprof 定位泄漏点,看大量协程卡在哪一行,那里就是病根。

一张 goroutine 泄漏速查表

把常见的泄漏成因、信号和对策汇成一张表,排查时对照着走。

泄漏成因 典型信号 对策
协程卡在无人接收的 channel 发送 大量协程堆在同一 send 处 结果通道缓冲 1 + ctx.Done 分支
协程卡在永远收不到的 channel 接收 大量协程堆在同一 recv 处 select 加 ctx.Done / 超时分支
主流程提前 return 丢下协程 NumGoroutine 随请求量只增 用 context 贯穿请求生命周期
WaitGroup 没 Done(panic/早退) Wait 永久阻塞 Done 用 defer 兜底
瞬间开海量协程 内存峰值骤增甚至崩溃 信号量 / worker pool 限并发
for-select 里 time.After 反复新建 定时器对象堆积 用 time.NewTimer 复用并 Stop

一个特别隐蔽的坑:for-select 里的 time.After

表里最后一行,值得单独拎出来讲,因为它太隐蔽、又太常见。很多人写带超时的循环监听时,会这样写:在一个 for 循环里 select,其中一个分支是 case <-time.After(d)。问题在于,time.After 每次被求值都会创建一个新的定时器,而这个定时器要到时间到了才会被释放。在一个高频循环里,每转一圈就 new 一个,旧的还没到期就被丢下——它们会在底层堆积,直到各自到期,造成一种隐蔽的资源泄漏。

// 反例:for-select 里用 time.After, 每轮新建定时器, 堆积泄漏
for {
    select {
    case msg := <-ch:
        handle(msg)
    case <-time.After(time.Second):  // 每轮都 new 一个 Timer, 旧的没释放
        doTimeout()
    }
}

// 正解:用 time.NewTimer 复用同一个定时器, 并在用过后 Reset
timer := time.NewTimer(time.Second)
defer timer.Stop()
for {
    select {
    case msg := <-ch:
        handle(msg)
        if !timer.Stop() { <-timer.C }  // 安全地停掉并清空
        timer.Reset(time.Second)        // 复用, 重置计时
    case <-timer.C:
        doTimeout()
        timer.Reset(time.Second)
    }
}

这个坑的隐蔽之处在于:代码逻辑完全正确、功能也对,只是在高频场景下悄悄漏资源,平时根本看不出来,直到压力上来才暴露。它再次印证了 Go 并发的一个特点——很多泄漏不是"写错了",而是"没意识到背后还创建了需要被释放的东西"。无论是 goroutine、channel,还是定时器,只要它在循环里被反复创建,就该多问一句:它会被释放吗?谁来释放?

更省心的方案:用 errgroup 把"取消 + 等齐 + 收错"打包

前面我们用 context 管取消、WaitGroup 管等齐、还得自己处理协程里的错误——三样东西分开写,容易顾此失彼。Go 官方扩展库里有个 errgroup,把这三件事优雅地打包在了一起,是并发协作场景的利器。它能并发跑一组任务,任意一个出错就自动取消其余所有任务的 context,并等所有任务收工、把第一个错误返回给你。

import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) ([]string, error) {
    g, ctx := errgroup.WithContext(ctx)   // 派生出带取消的 ctx
    results := make([]string, len(urls))
    for i, url := range urls {
        i, url := i, url                   // 捕获各自的循环变量
        g.Go(func() error {                // 启动受管的协程
            data, err := fetchWithCtx(ctx, url)
            if err != nil {
                return err                 // 一个出错, ctx 自动取消其余
            }
            results[i] = data
            return nil
        })
    }
    if err := g.Wait(); err != nil {       // 等全部收工, 返回首个错误
        return nil, err
    }
    return results, nil
}
// 取消、等齐、收错三合一, 还天然避免了出错时其余协程继续空跑

errgroup 重写后,代码不仅更短,而且把"一个失败就尽快让大家都停下来"这种正确语义内建了进来——再不会出现"某个子任务早失败了,其它协程还在傻跑、白白消耗资源"的情况。对于"并发做一批活儿、要么都成、要么有一个错就整体收手"的常见模式,errgroup 几乎是标准答案。

这也呼应了一个更普适的工程心得:当你发现自己在反复手写"context + WaitGroup + 错误收集"这套样板时,大概率已经有现成的、经过千锤百炼的抽象在等着你了。用好这些标准库与官方扩展,不仅少写代码,更重要的是,它们把那些容易写错的并发细节(取消的传播、等齐的时机、错误的归集)都替你处理对了。站在巨人的肩膀上,比自己从头造一个漏洞百出的轮子要明智得多。

写在最后

这次"goroutine 越积越多撑爆内存"的事故,给我最深的触动,是它揭示了 Go 一个迷人却危险的反差:go 这个关键字,把启动并发的门槛降到了前所未有的低——低到你敲下它时,几乎不会去想"善后"二字。可恰恰是这种极致的轻便,让我们太容易忘记,每一个被启动的 goroutine,本质上都是一个需要被负责到底的资源,它需要一条明确的、在任何情况下都走得通的退出之路。开启的便利,和退出的责任,这两者之间的落差,正是泄漏滋生的温床。

所以写 Go 并发,我后来养成了一个雷打不动的习惯:每当手指敲下 go,就在心里立刻追问一句——"这个协程,会在什么情况下、通过什么路径退出?"如果答案是"等某个 channel"、"等某个事件",就再追一句:"那个 channel、那个事件,在所有分支下都一定会到来吗?万一不来,它怎么脱身?"这套自问,几乎能在写下的当下,就拦住绝大多数泄漏。并发编程的功力,不在于你能开多少协程,而在于你能不能让每一个开出去的协程,都干净利落地回来。愿你我写下的每一个 go,都既有出发的洒脱,也有归途的笃定。

如果你手上也有跑着 goroutine 的 Go 服务,不妨今天就花二十分钟做三件小事。第一,把 runtime.NumGoroutine() 接进你的监控指标,先盯一阵子看它在请求高峰后会不会回落——如果只涨不降,泄漏基本就坐实了。第二,全局搜一下代码里的 go 关键字,逐个问自己:这个协程的退出路径是什么?有没有可能某个分支会把它丢下不管?重点排查那些"协程发结果、主流程收结果"的配对,看接收方在所有出错/超时分支下是否都还在。第三,接一个 pprof 调试端口,压测时导一份 goroutine 堆栈,看有没有一大堆协程齐刷刷卡在同一行——那就是泄漏点。这三步成本都不高,却能帮你在下一次 OOM 之前,就把潜伏的泄漏揪出来。

Go 的并发模型,以简洁和优雅著称,go、channel、select 寥寥几个原语,就能编织出强大的并发逻辑。但简洁的表象之下,藏着同样需要被认真对待的责任:每一个并发原语,都对应着一份"善后"的义务。channel 要有人收发,goroutine 要有路退出,定时器要被释放,并发数要有上限。这次泄漏教会我的,正是去敬畏这份藏在便利背后的责任——真正的并发高手,不是开协程开得最多最快的人,而是能让每一个并发单元都善始善终、来去清楚的人。愿你我都能带着这份对"善后"的自觉,把 Go 并发的优雅,用得既酣畅、又安心。

回头看,这次泄漏之所以拖了几周才被发现,正是因为它"温柔"——不报错、不崩溃、不留明显痕迹,只是让内存一天天悄悄上涨,温水煮青蛙般地逼近极限。这恰恰是最危险的一类问题:它不会在你眼前炸开,而是潜伏着,等一个流量足够大、时间足够长的时刻才发作。也正因如此,对并发资源的"善后意识",才更要内化成一种下意识的习惯,而不能指望它出事时来提醒你。把这份警觉刻进日常,你的服务才能在长时间、高负载的运行里,依然稳如磐石。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

所有按钮都触发最后一项:JS 闭包与 var 陷阱

2026-5-30 11:26:54

技术教程

单线程也崩:Java 遍历删除 fail-fast 异常避坑

2026-5-30 11:38:04

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索