我的 Go 服务内存和 goroutine 数量只涨不跌,跑久了必 OOM,最后发现是一堆 goroutine 永远卡在 channel 上、泄漏了出不来的深度复盘
这是一个让我对"goroutine 泄漏"刻骨铭心的故事。我的 Go 服务,跑得好好的,功能也没问题。可它有一个怪病:跑得越久,内存占用越高,而且只涨不跌;同时,我监控的 goroutine 数量,也在持续地、缓慢地往上爬,从几百,涨到几千,再到几万……最终,服务内存耗尽、OOM 崩溃。重启一下,又能撑一阵,然后,周而复始。
我一开始以为是普通的内存泄漏,到处找"哪个对象没释放"。可当我注意到"goroutine 数量也在同步、持续地上涨"这个线索时,才意识到方向错了——问题,出在 goroutine 上:我的程序里,有一些 goroutine,被创建出来之后,就再也没有退出过,它们永久地"卡死"在了那里、积压了下来。这,就是"goroutine 泄漏(goroutine leak)"。我顺着线索深挖,终于找到了那些卡死的 goroutine,以及它们卡死的原因:我有一段代码,会启动一个 goroutine,让它去做点事、然后把结果通过一个 channel 发回来;而主流程,会从这个 channel 接收结果。问题出在一个边界情况:主流程,有时会因为超时、或提前返回,而不再去接收那个 channel 了!可那个 goroutine,还傻傻地,要往这个无缓冲的 channel 里发送结果——而 channel 的发送,在没有接收方时,会永久阻塞!于是,这个 goroutine,就永远地卡在了那条发送语句上,既发不出去、也无法退出,变成了一个永久驻留的"僵尸"。而每来一个这样的请求(主流程提前返回),就泄漏一个这样的 goroutine;它们越积越多,永不退出;而每个 goroutine,都占着内存(栈空间)、还持有着它引用的那些对象(让它们也无法被 GC)——于是,内存和 goroutine 数量,就这么只涨不跌,直到把服务,活活撑爆。我这才痛彻地明白:我一直以为,goroutine 是"轻量、廉价、用完就自己没了"的;殊不知,一个 goroutine,如果它执行的代码,陷入了"永久阻塞"(比如卡在一个没人收的 channel 发送、或没人发的 channel 接收上),它就永远不会退出、永远不会被回收——它"泄漏"了。goroutine 虽然轻量,但不是免费的、更不是"自动消失"的;你启动了一个 goroutine,就要对它的"退出",负责——要确保,它总有一条路,能够走完、退出;否则,它就会变成一个,慢慢蚕食你内存的"幽灵"。
故障现场:goroutine 卡在没人收的 channel 上,永不退出
我把这个"goroutine 泄漏"的现场,用代码摊开给你看:
// ✗ 灾难: goroutine 卡在"没人接收"的 channel 发送上, 永远退不出
func fetchWithTimeout() (string, error) {
ch := make(chan string) // ✗ 无缓冲 channel!
go func() {
result := slowQuery() // 一个慢操作
ch <- result // ✗ 把结果发回 channel —— 但如果没人收呢?
}()
select {
case res := <-ch:
return res, nil
case <-time.After(1 * time.Second): // 超时: 1 秒没结果就返回
return "", errors.New("timeout") // ✗ 主流程返回了, 不再接收 ch 了!
}
}
// 泄漏怎么发生:
// - slowQuery 超过 1 秒 → select 走了 timeout 分支, 主流程返回。
// - 此后, 没有人再从 ch 接收了。
// - 而那个 goroutine, 还在执行 "ch <- result"(无缓冲 channel 发送):
// 没有接收方 → 永久阻塞! goroutine 永远卡在这行, 退不出去。
// - 每次超时, 就泄漏一个这样的 goroutine → 越积越多。
// 后果:
// - goroutine 数量持续上涨(每次超时漏一个, 永不退出)。
// - 每个 goroutine 占内存(栈), 还持有它引用的对象(无法 GC)。
// → 内存只涨不跌 → 最终 OOM。
// 为什么 "ch <- result" 会永久阻塞?
// - 无缓冲 channel: 发送方必须等到"有接收方接收"才能完成发送。
// - 没有接收方 → 发送永远完不成 → goroutine 永久卡在这里。
// 根因: goroutine 陷入永久阻塞(卡在没人收的 channel 发送上), 永不退出 → 泄漏。
// goroutine 轻量但不免费、不自动消失; 启动了就要保证它有"退出的路"。
看着这段代码,我才算真正理解了这场"内存只涨不跌"的根源。问题的核心,是"goroutine 泄漏":我有一些 goroutine,被创建出来后,就永久地卡死、再也没有退出过。具体来说:我启动了一个 goroutine,让它做完慢操作后,把结果通过一个无缓冲的 channel 发回来(ch <- result);而主流程,用 select 接收结果、并带了个超时。问题就出在这个超时的边界情况上:当慢操作超过 1 秒,select 就走了 timeout 分支、主流程返回了——此后,就没有人再从这个 channel 接收了!可那个 goroutine,还傻傻地停在 ch <- result 这行,要往这个无缓冲的 channel 里发送;而无缓冲 channel 的发送,必须等到"有接收方接收"才能完成——既然没有接收方了,这个发送,就永远完不成,goroutine 就永久地卡在了这一行,既发不出去、也退不出来。于是,泄漏就发生了:每来一个会触发超时的请求,就泄漏一个这样的 goroutine;它们越积越多、永不退出;而每个 goroutine,都占着内存(它的栈空间),还持有着它引用的那些对象(让那些对象也无法被 GC);所以,goroutine 数量和内存占用,就这么只涨不跌,直到把服务,活活撑爆、OOM。归根结底:我一直想当然地以为,goroutine 是"轻量、廉价、用完就自己没了"的;殊不知,一个 goroutine,如果它执行的代码,陷入了"永久阻塞"(比如卡在一个没人收的 channel 发送、或没人发的 channel 接收上),它就永远不会退出、永远不会被回收——它"泄漏"了。goroutine 虽然轻量,但不是免费的、更不是"自动消失"的;你启动了一个 goroutine,就要对它的"退出"负责——必须确保它总有一条路能走完、能退出;否则,它就会变成一个,慢慢蚕食你内存的"幽灵"。
第一件事:搞懂 goroutine 会泄漏——永久阻塞就退不出
定位到根源,我必须把"goroutine 为什么会泄漏"彻底搞清楚:
goroutine 泄漏: 陷入永久阻塞的 goroutine, 永不退出、永不回收
# goroutine 什么时候"退出"?
# - 它执行的函数, 正常 return / 跑完 → 退出, 被回收。
# - 如果它"永远卡在某处、跑不完" → 永不退出 → 泄漏!
# 哪些情况会让 goroutine"永久阻塞"(从而泄漏)?
# 1. 向"没人接收"的 channel 发送(无缓冲): ch <- x, 没接收方 → 永久阻塞(本文)。
# 2. 从"没人发送、且没关闭"的 channel 接收: <-ch, 没发送方 → 永久阻塞。
# 3. 等一个"永远不会满足"的条件(死锁的锁、永远不来的信号)。
# 4. for{} 死循环 / select{} 没有任何 case 可走。
# 泄漏的危害(慢性, 但致命):
# - goroutine 永不退出 → 数量持续上涨(每次漏一个就少不了)。
# - 每个 goroutine 占栈内存 + 持有它引用的对象(对象也无法 GC)。
# → 内存只涨不跌 → 跑久了 OOM。
# - 隐蔽: 单次泄漏不明显, 长期运行才暴露(典型"跑几天就崩")。
# 关键认知: goroutine 轻量, 但"不免费、不自动消失"。
# - 启动一个 goroutine, 你就要保证它"有退出的路径"。
# - 永远要问: 这个 goroutine, 在所有情况下, 都能退出吗?
# - 尤其是 channel 操作: 发送有没有人收? 接收有没有人发/会不会关闭?
# 怎么发现 goroutine 泄漏?
# - 监控 goroutine 数量(runtime.NumGoroutine()): 只涨不跌 = 泄漏信号。
# - pprof 的 goroutine profile: 看"大量 goroutine 卡在同一处" → 泄漏点。
# 核心: goroutine 永久阻塞就泄漏(永不退出、占内存)。
# 启动 goroutine 就要保证它有退出路径; 监控 goroutine 数量发现泄漏。
原理终于清晰了。一个 goroutine 什么时候"退出"?——它执行的函数,正常 return / 跑完,就退出、被回收;但如果它"永远卡在某处、跑不完",就永不退出,这就是泄漏。哪些情况会让 goroutine"永久阻塞"、从而泄漏?第一,向"没人接收"的(无缓冲)channel 发送(ch <- x,没接收方就永久阻塞——本文);第二,从"没人发送、且没关闭"的 channel 接收(<-ch,没发送方就永久阻塞);第三,等一个"永远不会满足"的条件(死锁的锁、永远不来的信号);第四,for{} 死循环、或 select{} 没有任何 case 可走。而泄漏的危害是慢性、却致命的:goroutine 永不退出,数量持续上涨;每个都占着栈内存、还持有它引用的对象(让对象也无法 GC);于是内存只涨不跌,跑久了 OOM;而且它隐蔽——单次泄漏不明显,要长期运行才暴露(典型的"跑几天就崩")。由此,我建立起一个关键认知:goroutine 虽然轻量,但"不免费、不会自动消失";你启动一个 goroutine,就要保证它"有退出的路径";要永远问自己:这个 goroutine,在所有情况下,都能退出吗?尤其是 channel 操作——发送,有没有人收?接收,有没有人发、会不会关闭?而怎么发现泄漏?监控 goroutine 的数量(runtime.NumGoroutine()),只涨不跌就是泄漏信号;用 pprof 的 goroutine profile,看"大量 goroutine 卡在同一处",那里就是泄漏点。归根结底:goroutine 永久阻塞就会泄漏(永不退出、占内存);启动一个 goroutine,就要保证它有退出的路径;并通过监控 goroutine 数量来发现泄漏——这,是我用一次"内存只涨不跌、跑久必 OOM"的事故,补上的、关于 goroutine 最关键的一课。
第二件事:正解——给 goroutine 留好"退出的路"
搞懂了原理,正解就清晰了:我必须保证那个 goroutine,在任何情况下,都有一条路能退出。有三种经典的修法:
// ✓ 修法一: 用"带缓冲的 channel"(容量 1), 让发送永不阻塞
func fetchWithTimeoutFixed1() (string, error) {
ch := make(chan string, 1) // ✓ 缓冲容量 1!
go func() {
result := slowQuery()
ch <- result // ✓ 即使没人收, 缓冲区能放下 1 个 → 发送立即完成 → goroutine 退出!
}()
select {
case res := <-ch:
return res, nil
case <-time.After(1 * time.Second):
return "", errors.New("timeout") // 超时返回; goroutine 已能把结果放进缓冲并退出, 不泄漏。
}
}
// ✓ 修法二: 用 context 取消, 让 goroutine 主动感知"该退出了"
func fetchWithCtx(ctx context.Context) (string, error) {
ch := make(chan string, 1)
go func() {
result := slowQuery()
select {
case ch <- result: // 有人收 → 正常发送
case <-ctx.Done(): // ✓ context 被取消 → 走这条路, goroutine 退出, 不卡死
}
}()
select {
case res := <-ch:
return res, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// ✓ 修法三: select 里给 goroutine 一条"超时/done 退出路"
// - 核心都是: 不要让 goroutine"只有一条会阻塞的路"。
// - 永远给它准备一条"无论如何都能走"的退出分支。
// 核心: 给 goroutine 留好退出的路 —— 缓冲 channel(发送不阻塞)
// / context 取消 / select 加 done 分支, 确保它在任何情况下都能退出。
修复的核心,就一句话:不要让 goroutine"只有一条会阻塞的路";要永远给它,准备一条"无论如何都能走"的退出分支。修法一,最简单:把那个无缓冲 channel,改成"带缓冲(容量 1)"的——这样,即使主流程超时、没人接收,goroutine 的发送 ch <- result 也能把结果放进缓冲区、立即完成,goroutine 随即退出,不再卡死、不再泄漏。修法二,更通用:用 context 取消——goroutine 在发送时,用 select 同时监听 ch <- result 和 <-ctx.Done(),这样,一旦 context 被取消(主流程不要了),goroutine 就走 ctx.Done() 分支、主动退出。修法三,同理:在 select 里,给 goroutine 留一条"超时 / done"的退出路。三种修法,殊途同归——给 goroutine 留好退出的路:缓冲 channel(让发送不阻塞)、context 取消、或 select 加 done 分支,确保它在任何情况下,都能退出。
第三件事:监控 goroutine 数量,主动发现泄漏
光会修还不够,我得能主动发现泄漏。我加上了监控:
// ✓ 监控 goroutine 数量 + pprof 定位泄漏点
import (
"runtime"
_ "net/http/pprof" // ✓ 引入 pprof, 暴露 /debug/pprof/goroutine
)
// 1. 定期打印 goroutine 数量: 只涨不跌 = 泄漏信号
go func() {
for range time.Tick(30 * time.Second) {
log.Printf("goroutines = %d", runtime.NumGoroutine()) // 健康: 平稳波动; 泄漏: 持续上涨
}
}()
// 2. pprof 抓 goroutine profile, 看"大量 goroutine 卡在同一处"
// go tool pprof http://localhost:6060/debug/pprof/goroutine
// → 列表里若有一堆 goroutine 都卡在 "chan send" 同一行 → 那就是泄漏点!
// 3. 单元测试里也能查泄漏: 用 goleak
// defer goleak.VerifyNone(t) // 测试结束时, 若有 goroutine 没退出 → 报错
// 经验法则:
// - goroutine 数量"只涨不跌" → 几乎一定有泄漏。
// - pprof goroutine profile 是定位泄漏点的利器(看堆在哪)。
// - 任何"启动 goroutine 等结果"的地方, 都要 review 它的退出路径。
// 核心: 监控 goroutine 数量(只涨不跌=泄漏)+ pprof 定位堆积点,
// 把"慢性泄漏"在它撑爆服务前, 揪出来。
有了监控,泄漏就无所遁形了。第一,定期打印 runtime.NumGoroutine():健康的服务,goroutine 数量是平稳波动的;一旦看到它持续上涨、只涨不跌,几乎可以断定——有泄漏。第二,用 pprof 抓 goroutine profile(引入 net/http/pprof,跑 go tool pprof .../goroutine):如果列表里,有一大堆 goroutine,都卡在同一行(比如 chan send),那一行,就是泄漏点。第三,在单元测试里,可以用 goleak(defer goleak.VerifyNone(t)),测试结束时,若还有 goroutine 没退出,就直接报错,把泄漏挡在上线前。我总结的经验法则是:goroutine 数量"只涨不跌"就几乎一定有泄漏;pprof 的 goroutine profile 是定位泄漏点的利器;任何"启动 goroutine 等结果"的地方,都要 review 它的退出路径。归根结底:监控 goroutine 数量(只涨不跌=泄漏)、再用 pprof 定位堆积点,把这种"慢性泄漏",在它撑爆服务之前,主动揪出来。
下面这张图,是 goroutine 泄漏的发生与堵截全过程:
第四件事:那些会泄漏 goroutine 的常见写法
这次踩坑后,我把团队代码里"容易泄漏 goroutine"的写法,系统梳理了一遍。它们大多长一个样:goroutine 里有一条会阻塞的路,却没给它留退出口。
| 泄漏写法 | 为什么会泄漏 | 正确做法 |
|---|---|---|
| 无缓冲 channel 发结果, 主流程可能不收 | 没人收 → 发送永久阻塞 → 卡死 | 缓冲 channel(容量1)/ select 加 ctx.Done |
| 从 channel 接收, 但发送方可能提前退出/不发 | 没人发又没关闭 → 接收永久阻塞 | 发送方 defer close(ch) / select 加超时 |
| for range ch 消费, 但 ch 永不 close | range 等不到 close → 永远卡住 | 生产端用完务必 close(ch) |
| worker 池里 goroutine 等任务, 没有退出信号 | 任务发完了但 worker 还在等 → 不退出 | 关闭任务 channel / 传 done channel 通知退出 |
| goroutine 里 time.Sleep 死循环, 无退出条件 | 循环永不结束 → 进程在它就在 | 用 select 监听 ctx.Done, 收到就 return |
把它们排在一起,共性就太明显了:几乎每一种泄漏,都是因为"goroutine 里有一条会阻塞的操作(channel 发/收、range、等任务),却没有给它配一条"能退出"的路"。无缓冲 channel 发结果而没人收、从 channel 接收而没人发也不 close、for range ch 而 ch 永不 close、worker 等任务而没有退出信号、死循环 Sleep 而没有退出条件——它们的修法,也万变不离其宗:要么用缓冲让发送不阻塞,要么用 close 让接收/range 能结束,要么用 select + ctx.Done()/done channel,给 goroutine 一个"该退了"的信号和退出口。
第五件事:goroutine 不是免费的——它的内存与生命周期
这次事故,还纠正了我一个根深蒂固的误解:"goroutine 很便宜,随便开"。它确实轻量,但绝不免费,更不会自动消失。
| 关于 goroutine 的认知 | 错误想象 | 实际真相 |
|---|---|---|
| 占用内存 | 几乎不占, 随便开几百万 | 每个初始栈约 2KB, 会按需增长; 海量泄漏 = 海量内存 |
| 生命周期 | 用完自动消失 | 只有函数 return 才退出; 永久阻塞就永不退出 |
| 持有的对象 | goroutine 没了对象就能回收 | 只要 goroutine 活着, 它引用的对象就无法 GC |
| 谁负责退出 | runtime 会帮我回收 | runtime 不会"杀"goroutine; 退出路径要你自己保证 |
| 泄漏的表现 | 会立刻报错崩溃 | 慢性: 数量与内存缓慢上涨, 跑久了才 OOM |
这张表,把我"想当然"的认知,一条条校正了过来。goroutine 的栈,初始约 2KB、会按需增长——单个不起眼,可一旦海量泄漏,就是海量内存;它只有在函数 return 时才退出,永久阻塞就永不退出,绝不会"用完自动消失";只要它还活着,它引用的对象,就无法被 GC(这也是为什么 goroutine 泄漏,常伴随对象内存泄漏);更关键的是,runtime 不会主动"杀掉"一个 goroutine——它的退出路径,只能由你自己来保证;而它泄漏的表现,是慢性的——不会立刻崩,而是数量与内存缓慢上涨,跑久了才 OOM,这也正是它隐蔽、难查的地方。理解了这些,我才算真正学会了敬畏每一个我亲手启动的 goroutine。
第六件事:启动一个 goroutine 前,我现在会怎么决策
现在,每当我准备写下一个 go func(),脑子里都会过一遍这张决策图——核心只有一个问题:它怎么退出?
这张图的灵魂,就是那个必问的问题:我启动的这个 goroutine,到底怎么退出?如果它跑完就 return、没有阻塞操作,那很安全;可一旦它里面有会阻塞的操作(channel 发/收、range、锁),我就必须追问:那个让它阻塞的"对方",一定会出现吗?(有人收吗?有人发吗?会 close 吗?)——如果不一定,就危险,我必须给它留好退出路:发送,用缓冲 channel 或 select+ctx.Done();接收/range,保证 close 或 select+超时;最后,再加上 NumGoroutine + pprof 的监控兜底。只有当我能确信"这个 goroutine,在任何情况下,都能退出"时,我才让它上线。
我立下的几条规矩
这场"内存只涨不跌、跑久必 OOM"的事故,换来了我写并发代码时,刻进肌肉的几条铁律:
- 启动 goroutine 就要对它的"退出"负责。写下每个
go func(),都要先回答:它怎么退出?在所有情况下都能退出吗?答不上来,就别写。 - channel 发送怕"没人收",接收怕"没人发"。无缓冲 channel 的发/收,只要对方可能缺席,就会永久阻塞——用缓冲、close、或 select+done 化解。
- "启动 goroutine 等结果 + 超时返回"是泄漏重灾区。主流程一超时就不收了,goroutine 却还在发——这种模式,channel 务必带缓冲,或发送处 select 加 ctx.Done。
- 用 context 贯穿,让 goroutine 能被"取消"。长生命周期的 goroutine,都该监听
ctx.Done(),收到信号就主动退出。 - 监控 goroutine 数量,只涨不跌就是泄漏。定期打印
runtime.NumGoroutine();异常时用 pprof 抓 goroutine profile 定位堆积点。 - 测试里用 goleak 守门。关键并发逻辑,加
goleak.VerifyNone,把泄漏挡在上线之前。 - 记住 goroutine 不免费。它占栈内存、持有引用对象、不会自动消失——轻量不等于可以随便漏。
附:一个能稳定复现 goroutine 泄漏的最小例子
为了让团队都对泄漏有直观感受,我写了个能肉眼看到 goroutine 数量暴涨的最小复现,跑一下,胜过千言:
package main
import (
"fmt"
"runtime"
"time"
)
// leak: 每次都启动一个 goroutine 卡在没人收的 channel 发送上
func leak() {
ch := make(chan int) // 无缓冲
go func() {
ch <- 42 // ✗ 没人收 → 永久阻塞 → 这个 goroutine 永不退出
}()
// 函数返回, 没人再去收 ch → 上面的 goroutine 泄漏了
}
func main() {
for i := 0; i < 5; i++ {
for j := 0; j < 1000; j++ {
leak() // 制造 1000 个泄漏的 goroutine
}
runtime.GC() // 主动 GC 也救不回来 —— 它们没"死", 不会被回收
fmt.Printf("第 %d 轮后, goroutines = %d\n", i+1, runtime.NumGoroutine())
time.Sleep(200 * time.Millisecond)
}
// 输出会是: 1001 / 2001 / 3001 / 4001 / 5001 ——
// 只涨不跌! 这就是泄漏最直观的样子。
// 把 leak 里的 ch 改成 make(chan int, 1), 数量就会稳定下来。
}
// 核心: 跑一遍就懂 —— 泄漏的 goroutine "只涨不跌", GC 也救不回,
// 因为它们没退出、不算"死", 改成缓冲 channel 即可让它们退出。
这段代码,我建议每个写 Go 的同事都亲手跑一遍。它会清清楚楚地打印出 1001 / 2001 / 3001 / 4001 / 5001——goroutine 数量只涨不跌,而且连主动 runtime.GC() 都救不回来。为什么 GC 也救不了?因为这些 goroutine,并没有"死"——它们只是永久卡住了;而 GC 只回收"不再被引用的对象",它不会、也不能去"杀掉"一个还活着(只是卡住)的 goroutine。这,恰恰是 goroutine 泄漏最反直觉、也最致命的地方:你指望不上任何自动机制来帮你收拾——能救它的,只有你在写下它时,就给它留好的那条"退出的路"。只要把 leak 里的 ch 改成 make(chan int, 1),让发送能放进缓冲、立即完成,那个数字,就会立刻稳定下来。一行之差,天壤之别。
写在最后
回头看,这场把我从"普通内存泄漏"误导了大半天的事故,真正教给我的,其实是一个比"goroutine 泄漏"本身更普遍的道理:并发的世界里,"启动"很容易,"退出"才是真功夫。我们太容易陶醉于 Go "一个 go 关键字就起一个并发"的轻巧,却忘了——每一个被你启动的 goroutine,都是一个需要你负责到底的"生命";它不会因为你忘了它,就自己悄悄消失;恰恰相反,它会固执地、永久地,卡在你没考虑到的那个边界情况上,然后,一个、一个、又一个,慢慢地,把你的服务蚕食殆尽。所以,真正的并发高手,脑子里装的,从来不只是"怎么把活儿并行起来",更是"这些并行的家伙,最后都怎么收场、怎么退出"。启动一个 goroutine,就给它留好一条回家的路——这,是我用一次"内存只涨不跌、跑久必 OOM"的崩溃,换来的、关于 goroutine 最朴素、也最深刻的领悟。如果这篇复盘,能让你在下一次写下 go func() 之前,多问自己一句"它怎么退出?",那我熬的这大半天,就值了。
—— 别看了 · 2026