一个每次请求都起一个 goroutine 却没人保证它能退出的服务,goroutine 越积越多、内存缓慢上涨,跑几天就 OOM:一次 goroutine 泄漏的深度复盘

服务内存缓慢上涨、跑几天就被 OOM,pprof 一看 goroutine 从几十个涨到几十万、只增不减,且全卡在同一行 channel 接收/发送上。根因是每请求起 goroutine 通过无缓冲 channel 返回结果,而主流程超时/取消后提前返回、不再接收,那个负责发送的 goroutine 就永远阻塞在 ch<-result 上(无缓冲发送必须有接收方)、永不退出、永久泄漏。本文讲透 goroutine 泄漏与 channel 阻塞语义,给出 channel 加缓冲、用 context+select 保证 goroutine 在所有情况下都能退出的正解,梳理并发资源管理常见坑,最后落到'任何等待都要有尽头、有获取就有释放、正确地结束比创建更难也同等重要'的认知。

一个每次请求都起一个 goroutine 却没人保证它能退出的服务,goroutine 越积越多、内存缓慢上涨,跑几天就 OOM:一次 goroutine 泄漏的深度复盘

那个问题是"慢性病":我们一个 Go 服务,内存会缓慢地、持续地往上涨,跑个几天就涨到把内存吃光、被 OOM 杀掉,重启后又从头慢慢涨。一开始我以为是普通的内存泄漏(忘了释放什么),用 pprof 抓了一下,结果指向了一个我没想到的方向——goroutine 数量在持续增长,从启动时的几十个,涨到了几十万个,且只增不减。我顺着 pprof 的 goroutine 堆栈一看,头皮发麻:有几十万个 goroutine,全都卡在同一行代码上——一个从 channel 接收数据的操作,result := <-ch,它们都阻塞在这里,永远地等着一个再也不会来的数据。我顺着代码捋清了真相,后背发凉:我们每次请求,都会起一个 goroutine 去做一件事、然后通过一个 channel 把结果发回来;主流程从这个 channel 接收结果。可问题是:当主流程因为超时、或提前返回(比如用户取消、上游断开)而不再接收这个 channel 时,那个负责发送结果的 goroutine,就永远阻塞ch <- result 这一句上了——它要发数据,但再也没有人来收(无缓冲 channel 的发送,必须有接收方才能完成);这个 goroutine 就这么永远卡住、永远不退出,它占用的栈内存、它持有的资源,也永远不释放。每次这种情况发生,就泄漏一个 goroutine;日积月累,几十万个泄漏的 goroutine 把内存吃光了。问题的根,是我起了 goroutine,却没有保证它在任何情况下都能正常退出——它的退出,依赖一个"总有人来收 channel"的假设,而这个假设在超时/取消时不成立。这篇就把这次"goroutine 泄漏"的坑,从头到尾复盘一遍。

故障现场:发送方阻塞在没人接收的 channel 上

问题代码,是一个起 goroutine 通过 channel 返回结果、却没考虑接收方提前离开的写法:

// ✗ 出问题的代码: goroutine通过channel返回结果, 但没保证它一定能退出
func handleRequest(ctx context.Context) (Result, error) {
    ch := make(chan Result)        // ✗ 无缓冲channel
    go func() {
        result := doSlowWork()     // 耗时操作
        ch <- result               // ✗ 把结果发回去——但如果没人收, 这里会永远阻塞!
    }()

    select {
    case result := <-ch:
        return result, nil
    case <-ctx.Done():             // ✗ 超时/取消: 主流程直接返回了!
        return Result{}, ctx.Err() // → 此时上面那个goroutine还在跑/还要发ch, 但【没人收了】
    }
    // 灾难: 当走 ctx.Done() 分支(超时/取消)返回后:
    // - 主流程不再 <-ch 了;
    // - 但那个goroutine做完doSlowWork后, 执行 ch <- result;
    // - 无缓冲channel: 发送必须有接收方配对才能完成; 没人收 → goroutine【永远阻塞在 ch <- result】;
    // - → 这个goroutine永远不退出, 它的栈、它引用的对象, 永远不释放 → 泄漏一个goroutine!
    // - 每次超时/取消都泄漏一个; 高并发下日积月累 → 几十万个泄漏的goroutine → 内存被吃光OOM。
}

// 为什么是"慢性"的:
// - 不是每次都泄漏(只有超时/取消时才漏); 平时大部分请求正常(有人收ch, goroutine正常退出);
// - 所以内存是【缓慢】上涨的, 潜伏期长, 跑几天才OOM——典型的慢性资源泄漏。

// 关键: 起了goroutine就要保证它能退出; 它若阻塞在"没人接收的channel发送"(或没数据的接收)上,
//       就永远不退出、永久泄漏; goroutine泄漏是Go里一类隐蔽的、慢性的资源泄漏。

第一次看到几十万个 goroutine 卡在同一行时,我又震惊又懊恼:"我以为 goroutine 跑完就自己没了,没想到它会因为'发不出去'而永远卡住、永远漏着。"这个坑最隐蔽的地方在于它的慢性和静默:不是每次都漏——只在"超时、取消、上游断开"等"接收方提前离开"的情况下才漏一个;平时大部分请求都正常(goroutine 正常退出)。所以内存是缓慢上涨的,潜伏期长达几天,且不报错、不崩溃,只是慢慢地把资源耗尽而且 goroutine 泄漏比普通内存泄漏更隐蔽——你 new 的对象忘了释放还容易想到,但"一个起了却退不出去的 goroutine"很容易被忽略下面就来拆解,goroutine 为什么会泄漏、怎么防。

第一件事:搞懂 goroutine 泄漏,以及 channel 的阻塞语义

我认真梳理了 goroutine 的生命周期和 channel 阻塞,才彻底理解这个坑。

goroutine 泄漏 与 channel 的阻塞语义

【核心: goroutine阻塞在永远不会满足的channel操作上, 就永不退出、永久泄漏; 起goroutine就要保证它能退出】

1. goroutine 不会"自动回收":
   - goroutine 只有在它的函数【执行完返回】时, 才会结束、释放资源;
   - 如果它【阻塞】在某个永远不会完成的操作上, 它就【永远不结束】 → 泄漏;
   - 泄漏的goroutine: 占着栈内存、它引用的对象无法GC、可能还占着别的资源。

2. channel 操作的阻塞规则(无缓冲channel):
   - 发送 ch <- x: 【必须有接收方】正在 <-ch, 才能完成; 否则发送方【阻塞】等待;
   - 接收 <-ch: 【必须有发送方】正在 ch<-, 才能完成; 否则接收方【阻塞】等待;
   - → 发送和接收必须【配对】; 一方走了, 另一方就永远阻塞。

3. 本文的泄漏链:
   - goroutine 要 ch <- result(发送); 主流程本应 <-ch(接收);
   - 但主流程超时/取消后【提前返回, 不再接收】;
   - → goroutine 的 ch <- result 永远等不到接收方 → 永远阻塞 → 永不退出 → 泄漏。

4. 常见的goroutine泄漏场景:
   - 发送到没人接收的channel(本文);
   - 从没人发送的channel接收(对称的情况);
   - 等待一个永远不会被关闭的channel / 永远不会触发的条件;
   - 没有退出机制的死循环goroutine(没有context/done信号让它停)。

5. 核心原则: "谁启动goroutine, 谁要对它的退出负责"
   - 启动一个goroutine时, 就要想清楚: "它在【所有情况下】(正常、超时、取消、出错)都能退出吗?";
   - 不能只考虑happy path; 要保证任何路径下它都不会永久阻塞。

一句话: goroutine阻塞在永远不会完成的channel操作(没人收的发送/没人发的接收/不会关的channel)上,
   就永不退出、永久泄漏; 起goroutine就要保证它在所有情况下都能退出(用buffered channel/context/select)。

这套机制,是整个坑的根。goroutine 不会自动回收:它只有在函数执行完返回时才结束、释放资源;如果阻塞在永远不会完成的操作上就永不结束、泄漏(占着栈内存、引用的对象无法 GC)。channel 的阻塞规则(无缓冲):发送必须有接收方配对、接收必须有发送方配对,否则阻塞等待——一方走了另一方就永远阻塞。本文的泄漏链:goroutine 要发送 ch<-result、主流程本应接收,但主流程超时/取消后提前返回不再接收,goroutine 永远等不到接收方、永远阻塞、泄漏。常见泄漏场景:发到没人收的 channel(本文)、从没人发的 channel 接收、等永不关闭的 channel、没退出机制的死循环 goroutine核心原则:"谁启动 goroutine 谁对它的退出负责"——启动时就要想清楚"它在所有情况下(正常/超时/取消/出错)都能退出吗",别只考虑 happy path一句话:goroutine 阻塞在永远不会完成的 channel 操作上就永不退出、永久泄漏;起 goroutine 就要保证它在所有情况下都能退出(用 buffered channel/context/select)。

第二件事:正解——用带缓冲 channel、或 context/select 保证 goroutine 能退出

搞懂了原理,正解就清晰了:给 channel 加缓冲让发送不阻塞、或用 context 控制 goroutine 在取消时退出、或在 goroutine 内用 select 同时监听结果和退出信号;确保任何路径下 goroutine 都能退出

// ====== 正解一(最简单): 给channel加缓冲, 让发送不阻塞 ======
func handleRequest(ctx context.Context) (Result, error) {
    ch := make(chan Result, 1)     // ★ 缓冲为1: goroutine发送时即使没人收, 也能放进缓冲、不阻塞
    go func() {
        ch <- doSlowWork()         // ✓ 有缓冲, 发送立刻完成, goroutine能正常退出(哪怕没人收)
    }()
    select {
    case result := <-ch:
        return result, nil
    case <-ctx.Done():
        return Result{}, ctx.Err() // 主流程走了, 但goroutine的结果进了缓冲, 它已正常退出, 不泄漏
    }
}
// → 缓冲为1: 发送方把结果丢进缓冲就走人(不用等接收方), goroutine顺利退出; 没人收的结果随channel被GC。
//   适合"一个goroutine发一个结果"的场景, 简单有效。

// ====== 正解二: 用 context 让goroutine在取消时主动退出 ======
func handleRequest(ctx context.Context) (Result, error) {
    ch := make(chan Result, 1)
    go func() {
        result := doSlowWork(ctx)  // ★ 把ctx传进去, 让doSlowWork能响应取消、提前返回
        select {
        case ch <- result:         // 尝试发送
        case <-ctx.Done():         // ★ 或者: 如果已取消, 就别发了, 直接退出
        }
    }()
    select {
    case result := <-ch:
        return result, nil
    case <-ctx.Done():
        return Result{}, ctx.Err()
    }
}
// → goroutine内部也select监听ctx.Done(): 取消时它不再傻等发送, 而是直接退出 → 不泄漏。
//   更进一步: doSlowWork(ctx)内部也应响应ctx, 取消时尽早停止工作(省资源)。
# ====== 防goroutine泄漏的几个原则 ======

# 1. "起goroutine, 想退出": 启动时就问"它在所有情况(正常/超时/取消/出错)下都能退出吗?"

# 2. 用context传递取消信号: 让goroutine能感知"上游不要了", 主动退出; 长生命周期的goroutine尤其要。

# 3. channel发送防阻塞: 用带缓冲channel、或 select{case ch<-x: case <-ctx.Done():} 防止发送永久阻塞。

# 4. 死循环goroutine要有退出口: for{...} 的goroutine要 select 监听 done/ctx, 能被停掉。

# 5. 用 pprof 监控goroutine数量: /debug/pprof/goroutine 看数量是否持续增长(泄漏的强信号)。
#    - goroutine数量只增不减 = 几乎一定有泄漏; 看堆栈定位它们卡在哪。

# 6. 用 -race 和 goroutine泄漏检测工具(如 uber-go/goleak)在测试里抓泄漏。

# ====== 排查口诀 ======
# 内存缓慢上涨 + goroutine数量持续增长(pprof) → goroutine泄漏 → 看goroutine堆栈, 找"卡在哪"。

# 核心: 起goroutine必须保证它在所有情况下都能退出; channel发送用缓冲/select防阻塞、用context传取消、
#   死循环留退出口; 用pprof监控goroutine数量、goleak测试抓泄漏; "起了就要能退"是铁律。

修复的核心,是"保证 goroutine 在任何情况下都能退出"正解一(最简单):给 channel 加缓冲——make(chan Result, 1),goroutine 发送时即使没人收也能放进缓冲、不阻塞、正常退出;没人收的结果随 channel 被 GC;适合"一个 goroutine 发一个结果"正解二:用 context 让 goroutine 取消时退出——把 ctx 传进去让耗时操作能响应取消,goroutine 内也 select 监听 ctx.Done(),取消时不再傻等发送、直接退出防泄漏原则:"起 goroutine 想退出"、用 context 传取消信号、channel 发送用缓冲/select 防阻塞、死循环 goroutine 留退出口、用 pprof 监控 goroutine 数量、用 goleak 测试抓泄漏排查口诀:内存缓慢上涨+goroutine 数量持续增长就是泄漏,看堆栈找卡在哪归根结底:起 goroutine 必须保证它在所有情况下都能退出;channel 发送用缓冲/select 防阻塞、用 context 传取消、死循环留退出口;用 pprof 监控、goleak 测试;"起了就要能退"是铁律。

第三件事:Go 并发资源管理的其他常见坑

排查后我把 Go 并发资源管理相关的其他常见坑也系统梳理了一遍。

Go 并发资源管理的其他常见坑

# 1. goroutine泄漏(本文): 阻塞在没人配对的channel上永不退出。→ 缓冲/context/select保证能退。

# 2. 死循环goroutine无退出口: for{}没监听done/ctx, 永远跑。→ select监听退出信号。

# 3. channel忘了close: 接收方range一个永不关闭的channel, 永远等。→ 发送完close(由发送方关)。

# 4. WaitGroup用错: Add/Done不匹配 → Wait永久阻塞(也是一种"退不出去")。→ 正确配对。

# 5. context没传递/没响应: 起的goroutine拿不到取消信号, 上游取消了它还在跑。→ 贯穿传ctx并响应。

# 6. 资源(连接/文件)在goroutine里没释放: goroutine泄漏连带这些资源泄漏。→ defer释放+保证goroutine退出。

# 7. 没限制goroutine数量: 每请求一个goroutine, 突发流量起几十万个。→ 用worker pool/限流。

# 8. 监控缺失: 不监控goroutine数量, 泄漏了不知道。→ pprof/metrics监控goroutine数。

# 共同根源: Go起goroutine太容易(一个go就行), 但"goroutine的生命周期管理"(它何时、如何退出)
#   需要你显式负责; 忽视"退出"这一环, goroutine就会泄漏——和"只管申请不管释放"是同一类问题。

# 核心: 把goroutine当"有生命周期、需管理退出"的资源; 用context控制、channel防阻塞、死循环留退出口、
#   限制数量、监控数量; "谁起谁负责它能退出"——goroutine泄漏是Go并发里最该警惕的慢性病。

排查让我把并发资源管理的其他坑也梳理清了。一、goroutine 泄漏(本文)。二、死循环 goroutine 无退出口三、channel 忘了 close(由发送方关)。四、WaitGroup 用错(Wait 永久阻塞)。五、context 没传递/没响应六、goroutine 里资源没释放七、没限制 goroutine 数量(worker pool)。八、监控缺失它们的共同根源是:Go 起 goroutine 太容易(一个 go 就行),但"goroutine 的生命周期管理"(何时、如何退出)需要你显式负责;忽视"退出"这一环 goroutine 就会泄漏——和"只管申请不管释放"是同一类问题核心是:把 goroutine 当"有生命周期、需管理退出"的资源;用 context 控制、channel 防阻塞、死循环留退出口、限制数量、监控数量;"谁起谁负责它能退出"——goroutine 泄漏是 Go 并发里最该警惕的慢性病下面这张图,是这次 goroutine 泄漏坑的成因与解法:

第四件事:goroutine 泄漏的常见场景速查表

这次踩坑后,我把 goroutine 泄漏的常见场景和解法整理成一张表。

泄漏场景 为什么卡住 解法
发到没人收的channel(本文) 无缓冲发送等不到接收方 加缓冲 / select+ctx
从没人发的channel收 接收等不到发送方 确保有发送方 / 超时
range永不close的channel range等待下一个值/关闭 发送完由发送方close
死循环没退出口 for{}永远跑 select监听done/ctx
WaitGroup Add多了/Done漏了 Wait永久阻塞 Add/Done精确配对
等永不触发的条件 条件永远不满足 加超时/取消

这张表把 goroutine 泄漏的场景钉清了。核心是:goroutine 泄漏的场景虽多,本质都是"goroutine 在等一个永远不会发生的事(channel 配对、关闭、条件、计数归零)";它们的解法也有共性——要么"让那件事一定会发生"(确保配对/关闭),要么"给它一个不等了的出口"(超时/取消/select+done)它给我的最大启发是:所有这些泄漏,根子都是"无条件、无限期地等待"——goroutine 一旦进入"死等一个不来的东西"的状态,就回不来了;而避免它的通用思路,是"永远不要无限期地等待,要给一切等待加上'退出条件'(超时、取消信号、最大次数)";这和"调外部接口要设超时"(别无限期等下游)、"循环要有终止条件"是同一个道理——"无限期的等待/循环"是各类"卡死/泄漏/雪崩"问题的共同祸根这给了我一条贯穿并发与系统设计的原则:"任何等待都要有尽头"——等 channel、等锁、等下游响应、等条件满足、等用户操作,都要配上"超时/取消/上限"这个'逃生出口';"绝不允许一个无条件的、永久的等待存在",是写出不会卡死、不会泄漏的并发代码的关键纪律认清泄漏的根是无限期等待、给一切等待加退出条件——是这个坑带给我的并发原则。

第五件事:goroutine 泄漏作为"慢性资源泄漏"的反思

这次让我把 goroutine 泄漏归入了"慢性资源泄漏"这一大类。我把各种慢性泄漏整理成表。

慢性泄漏 泄漏的是什么 共同特征
goroutine泄漏(本文) goroutine及其栈/引用 只增不减, 缓慢涨
内存泄漏 对象引用没释放 内存缓慢涨, 终OOM
连接泄漏 连接没归还连接池 连接耗尽
文件句柄泄漏 文件没close too many open files
磁盘写满 日志只增不清 缓慢涨满

这张表道出了一类问题的共性。核心是:goroutine 泄漏、内存泄漏、连接泄漏、句柄泄漏、磁盘写满,本质都是同一类"慢性资源泄漏"——某种有限的资源,被"申请了却没释放",且这种"只申请不释放"在持续地、缓慢地发生,日积月累终将耗尽资源;它们都潜伏期长、缓慢恶化、到临界点才爆发它给我的深刻启发是:这类问题的根,都是"资源的申请和释放没有配对"——起了 goroutine 没保证它退出、new 了对象没释放引用、借了连接没归还、开了文件没关、写了日志没清理;"有借有还"在编程里是一条极其普适的纪律:凡是"获取了某种资源"的地方,都必须有一个"对应地释放它"的地方,且要保证在所有路径下都会释放这给了我一种处理资源的条件反射:每当我"获取/创建"一个资源(goroutine、内存、连接、文件、锁),都立刻想它的"释放/退出"——"它什么时候、由谁、怎么释放?在出错/超时/取消的路径下,它还能被释放吗?";用 defer(Go)、using(C#)、try-with-resources(Java)、context 等机制,把"释放"和"获取"绑在一起、保证成对发生;"有获取就有释放、有始就有终",是杜绝一切慢性资源泄漏的根本把 goroutine 泄漏归入慢性资源泄漏、坚守"有获取就有释放"的配对纪律——是这个坑带给我的更高层认知。

第六件事:起一个 goroutine 时,我现在的检查习惯

现在每当我要写一个 go func(){}(),我都会按这张图先想清楚:

这张图的精髓,是"起 goroutine 前先想清它在所有情况下怎么退出"判断它会不会自然结束;会阻塞就给阻塞操作加退出口(发送加缓冲/select+ctx、接收/等待加超时、死循环 select 监听 done);再确认资源会释放、配 pprof 监控 goroutine 数这套习惯,让我从"随手 go 一下"变成了"起 goroutine 先想它怎么退"——核心始终是:起 goroutine 必须保证它在所有情况下都能退出,给一切等待加退出口。

我立下的几条规矩

这场"goroutine 泄漏、几天 OOM"的事故,换来了我写 Go 并发时,刻进骨子里的几条铁律:

  1. goroutine 不会自动回收,阻塞了就永不退出。它要执行完返回才结束。
  2. 无缓冲 channel 发送/接收必须配对。一方走了另一方就永久阻塞。
  3. 起 goroutine 必须保证它在所有情况下都能退出。别只考虑 happy path。
  4. channel 发送用缓冲或 select+ctx.Done 防永久阻塞。
  5. 死循环 goroutine 要 select 监听退出信号。用 context 控制生命周期。
  6. 任何等待都要有尽头。超时/取消/上限,绝不无条件永久等待。
  7. 用 pprof 监控 goroutine 数量、goleak 测试抓泄漏。持续增长就是泄漏。

写在最后

回头看,这场由"goroutine 退不出去"引发的、缓慢 OOM 的事故,真正教给我的,远不止"channel 加缓冲、用 context"这一个技巧。它让我对"创造一个东西很容易,但'让它在该结束时干净地结束'往往才是真正的难点和责任",有了一次刻骨的体会。我栽跟头,根源在于 Go 把"创建并发"做得太轻松了——一个 go 关键字,我就毫不费力地、随手地创造出了一个并发执行的 goroutine;轻松到我只享受了"创造"的便利,却完全没意识到随之而来的"它该怎么结束"的责任我以为"起一个 goroutine"是一个"有始有终、跑完就没"的简单动作;却没想到,如果不精心安排,它会变成一个"有始无终、起了就再也回不来"的幽灵——而每一个回不来的幽灵,都占着一份永不归还的资源这让我领悟到一个关于"创建与销毁"的深刻认知:在编程里(乃至很多事情上),"创建/开始"往往是容易的、令人兴奋的,而"正确地销毁/结束"才是困难的、容易被忽视的、却同等重要的另一半——起一个 goroutine 容易、让它优雅退出难;new 一个对象容易、管好它的生命周期难;建一个连接容易、保证它被归还难;启动一个项目容易、善始善终地收尾难;"我们总是热衷于'开始',却常常没为'结束'做好准备"——而正是这个被忽视的"结束",藏着资源泄漏、状态残留、收尾不善等一大堆问题。这给了我一种"有始有终"的自觉:每当我"创建/启动"任何有生命周期的东西时,都要在那一刻就想清楚、安排好它的"结束"——"它会怎么结束?谁负责结束它?在异常情况下它还能结束吗?";"创建时就为销毁负责",而不是"先创建,结束的事以后再说(然后就忘了)";善始善终,尤其是把''安排好,是写出健壮、不泄漏的系统的关键功夫认清"正确地结束"是比"创建"更难也同等重要的责任、创建时就为销毁负责——这,是我用一次 goroutine 泄漏的事故,换来的、关于 Go、也关于如何对待一切"有生命周期之物"的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次敲下 go 关键字时,顺手就想"这个 goroutine 怎么退出"、给它的 channel 加上缓冲或 ctx,那我对着那几十万个卡死的 goroutine 排查的这大半天,就值了。

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

一个把对象方法直接作为回调传给 setTimeout 的写法,执行时 this 变成了 undefined、访问 this 的属性全报错:一次 JavaScript this 绑定丢失的深度复盘

2026-6-2 18:40:24

技术教程

一行 int count = map.get(key) 的赋值,在 key 不存在时悄悄触发了自动拆箱、抛出莫名其妙的空指针:一次 Java 自动装箱拆箱的深度复盘

2026-6-2 18:50:37

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