这是一个让我和运维同学被半夜告警折磨了快一周的事故。我们一个 Go 写的后端服务,上线后一切正常,可跑上两三天,内存就会悄悄爬到上限,然后被 OOM(内存溢出)杀掉、自动重启;重启后内存归零,又开始新一轮缓慢爬升,周而复始。它不是那种"一上线就崩"的急性病,而是一种"跑着跑着就虚了"的慢性病——每隔两三天准时 OOM 一次,像被下了诅咒。最折磨人的是,QPS 没涨、流量很平稳,内存却像漏气的轮胎一样只进不出,稳定地往上漂。
真正帮我们破案的,是 Go 自带的神器 pprof。当我们把 goroutine 的数量打到监控面板上,真相一下就清晰了:这个服务的 goroutine 数量,从启动起就只增不减,一条几乎笔直的上升线——每处理一批请求,就多出来一些 goroutine,而它们再也没有退出过,越积越多,直到把内存吃光。这是一个典型得不能再典型的 Go 故障:goroutine 泄漏。我们启动的协程,因为永久阻塞而无法退出,像幽灵一样滞留在内存里,越攒越多。这篇文章,就从这次"内存慢性漏气"的事故讲起,把 Go 并发编程里这些最隐蔽、也最致命的坑,一个个挖出来讲透。
故障现场:只增不减的 goroutine
先说清楚背景。这个服务每收到一个业务请求,除了正常处理,还需要做一件"附带的事":把这次请求的一些信息异步上报给一个内部的统计模块。为了不阻塞主流程,写代码的同学很自然地用了 Go 的招牌操作——开个 goroutine 去做这件事。简化后的代码大概长这样:
// 每个请求都开一个 goroutine 去上报, 看起来很"Go"
func handleRequest(req *Request) {
process(req) // 处理主流程
go func() {
ch := make(chan Result) // 无缓冲 channel
doReport(req, ch) // 上报, 把结果发到 ch
<-ch // 等结果 ← goroutine 卡死在这里
}()
}
问题就藏在这个不起眼的 goroutine 里。那个 ch 是个无缓冲 channel,而 doReport 内部在某些情况下(比如统计模块超时、或某个分支没走到发送逻辑),根本不会往 ch 里发送结果。于是 <-ch 这一行就会永远地阻塞下去——它在等一个永远不会到来的值。而一个阻塞在 channel 上、永远等不到的 goroutine,既不会报错、也不会 panic,它就那么静静地、永久地挂起,占着自己那份栈内存,赖着不走。
每来一个请求,就制造一个这样"等不到结果、永久阻塞"的幽灵 goroutine。流量越平稳、跑得越久,积累的幽灵就越多,它们占用的内存稳定地往上叠加,最终撑爆内存触发 OOM。这就是"内存慢性漏气"的全部真相:不是内存本身泄漏,而是 goroutine 泄漏——大量无法退出的 goroutine,连同它们占用的栈和引用的对象,一起赖在内存里出不去。这也解释了为什么它表现为"缓慢爬升、定期 OOM":每个泄漏的 goroutine 占的内存不多,但架不住只进不出地累积。
第一件事:理解 goroutine 不会被自动回收
要避开这个坑,先得纠正一个很多 Go 新手都有的错觉:以为 goroutine 像垃圾对象一样,用完了 Go 的运行时会自动帮你回收掉。大错特错。Go 的垃圾回收器(GC)回收的是不再被引用的内存对象,而一个正在阻塞、还没退出的 goroutine,在 Go 看来是"还活着、还在工作"的——它只是恰好卡住了而已,GC 绝不会去动一个活着的 goroutine。
// 一个 goroutine 只有在它的函数"自然返回"时才会退出
go func() {
doSomething()
// 函数执行到底、return, goroutine 才退出, 内存才被释放
}()
// 而如果它阻塞在某处(永远等不到), 它就永远不退出:
go func() {
<-ch // ch 永远没人发送 → 永久阻塞 → goroutine 永不退出 → 泄漏
}()
所以关键认知是:一个 goroutine 想要退出、想要释放它占的内存,唯一的途径是它执行的那个函数"跑到了尽头、正常返回"。如果它在中途被某个操作(channel 收发、锁、网络 IO 等)无限期地阻塞住了,它就永远走不到返回那一步,于是永远不退出——这就是泄漏。换句话说,go 关键字开启一个 goroutine 很轻松,但它的"善终"需要你来负责:你必须确保它执行的函数,在任何情况下都有一条能走到 return 的路,而不会陷入永久阻塞。启动一个 goroutine 时,永远要先想清楚:它会在什么条件下退出?会不会有某种情况让它卡住永远出不来?
第二件事:正解——用 context 给 goroutine 一个"退路"
问题的根子清楚了:goroutine 阻塞在一个可能永远等不到的 channel 上,没有退路。所以正解的核心,是给每个可能阻塞的 goroutine,都准备一条"逃生通道"——让它在"等不到"的时候,也能有办法退出,而不是死等。Go 里干这件事的标准工具,就是 context 和 select。
// 正解: 用 select 同时等"结果"和"超时/取消信号", 谁先到走谁
func handleRequest(ctx context.Context, req *Request) {
process(req)
go func() {
ch := make(chan Result, 1) // 顺手改成带缓冲, 避免发送方也阻塞
go doReport(req, ch)
select {
case <-ch:
// 正常拿到结果
case <-ctx.Done():
// 上游取消/超时了, 这条路也能退出, 不再死等
return
case <-time.After(3 * time.Second):
// 兜底超时: 就算没 ctx, 3 秒等不到也退出, 绝不永久阻塞
return
}
}()
}
这段代码的精髓,在那个 select:它让 goroutine 同时等待多个事件——正常结果(<-ch)、上游取消信号(<-ctx.Done())、以及一个兜底超时(time.After),哪个先发生就走哪个分支然后返回。这样一来,无论 doReport 是否正常发送结果,这个 goroutine 都保证有路可退:正常就走结果分支,异常(超时/取消)就走逃生分支,绝不会再陷入"永久等待"。"任何一个会阻塞等待的 goroutine,都必须搭配一个能让它退出的机制(ctx 取消或超时),让它永远有路可走"——这是写 Go 并发代码的一条保命铁律。
这里还顺手做了第二个修正:把无缓冲 channel 改成了 make(chan Result, 1) 带一个缓冲。为什么?因为无缓冲 channel 的发送方,在没有接收方接收时也会阻塞——如果接收方(我们的 goroutine)因为走了超时分支提前退出了,那 doReport 里往 ch 发送的那一方,就会反过来阻塞、泄漏。给一个容量为 1 的缓冲,能让发送方"把结果放下就走"、不必死等接收,从而避免发送方泄漏。channel 收发两端,任何一端没配对好都会阻塞——设计时要两端都想到。
第三件事:学会用 pprof 抓住泄漏的"现行"
光会修还不够,你得先能发现和定位泄漏——这次破案,全靠 pprof。它是 Go 内置的性能剖析利器,排查 goroutine 泄漏堪称神器。接入极其简单:
import _ "net/http/pprof" // 匿名导入, 自动注册调试路由
func main() {
// 单开一个端口暴露 pprof (生产环境注意做好访问控制)
go func() { http.ListenAndServe("localhost:6060", nil) }()
// ... 你的主服务 ...
}
// 然后:
// 看当前 goroutine 总数和堆栈: curl localhost:6060/debug/pprof/goroutine?debug=1
// 或可视化分析: go tool pprof http://localhost:6060/debug/pprof/goroutine
用 pprof 抓 goroutine 泄漏的思路是:看 goroutine 的总数会不会随时间只增不减(这是泄漏的铁证),再看这些 goroutine 大量地阻塞在哪一行代码上(泄漏点就在那里)。我们当时一看,goroutine 数量几千上万还在涨,而且绝大多数都卡在 <-ch 那一行的堆栈上——元凶瞬间现形。所以一条强烈建议:任何长期运行的 Go 服务,都应该默认接入 pprof,并把 goroutine 数量纳入监控告警。因为 goroutine 泄漏是慢性的、隐蔽的,等它表现为 OOM 时,你只看到"内存涨了",却不知道为什么;而一条"goroutine 数量持续上涨"的监控曲线,能让你在 OOM 发生之前好几天,就嗅到危险。我把这次的排查路径画成一张图:
这张图也顺带给出了一个排查内存问题的通用分诊思路:内存涨,先分清是"goroutine 泄漏"还是"内存对象泄漏"——前者看 goroutine profile(数量趋势 + 阻塞堆栈),后者看 heap profile(哪些对象在涨)。分清了方向,再顺着 pprof 给的堆栈,就能精准定位到那行闯祸的代码。有 pprof 在手,Go 的内存和并发问题,大多能从"玄学"变成"可观测、可定位的工程问题"。
第四件事:goroutine 泄漏的几种常见"长相"
修完这次的坑,我把 Go 里能导致 goroutine 泄漏的常见模式都梳理了一遍,发现它们虽然代码各异,但内核高度一致——都是某个 goroutine 永久地阻塞在了一个"等不到"的操作上。认得这些"长相",才能在写代码时就规避:
// 模式1: 往无人接收的 channel 发送, 永久阻塞
ch := make(chan int) // 无缓冲
go func() { ch <- 1 }() // 没有任何人 <-ch, 这个 goroutine 永远卡住
// 模式2: 从无人发送的 channel 接收 (本次事故就是这个)
go func() { <-ch }() // 没人 ch<-, 永久阻塞
// 模式3: range 一个永不关闭的 channel
go func() {
for v := range ch { use(v) } // ch 不 close(), range 永远不结束
}()
// 模式4: time.Tick 在不用之后没法停 (用 NewTicker + Stop 替代)
for range time.Tick(time.Second) { ... } // Tick 的底层资源无法回收
把这些模式和它们的规避方法列成一张表,写并发代码时对照检查:
| 泄漏模式 | 为何卡住 | 规避方法 |
|---|---|---|
| 发送到无人收的 channel | 发送方等接收方 | 带缓冲 / select + ctx.Done |
| 从无人发的 channel 接收 | 接收方等发送方 | select 加超时/取消分支 |
| range 未关闭的 channel | range 等 close | 发送方负责 close(ch) |
| 等一把永不释放的锁 | 持锁方不解锁 | defer Unlock, 缩小临界区 |
| time.Tick 无法停止 | 底层 ticker 不回收 | 用 NewTicker + defer Stop |
| WaitGroup 计数不归零 | Wait 等不到 Done | 确保每条路径都 Done(用 defer) |
这张表里每一行的"规避方法",归纳起来其实就两个核心原则:一是给阻塞操作配上"逃生出口"(select + ctx/超时),让它在等不到时能退出;二是明确 channel 的"关闭责任"——谁是发送方,谁就负责在发完后 close,这样接收方的 range 才能正常结束。把这两条原则刻进肌肉记忆,绝大多数 goroutine 泄漏都能在写代码的当下就被规避掉。
第五件事:channel 收发的阻塞规则,务必烂熟于心
归根结底,goroutine 泄漏的"重灾区"是 channel,而要用好 channel,你必须把它在各种状态下的收发行为背得滚瓜烂熟——很多泄漏和 panic,都源于对这些规则的模糊。我把它整理成一张"channel 行为速查表":
| channel 状态 | 发送(ch <- x) | 接收(<- ch) |
|---|---|---|
| nil(未初始化) | 永久阻塞(泄漏!) | 永久阻塞(泄漏!) |
| 空、无缓冲、无对端 | 阻塞等接收方 | 阻塞等发送方 |
| 有缓冲且未满 | 成功, 不阻塞 | 有值则取, 否则阻塞 |
| 有缓冲且已满 | 阻塞等空位 | 成功取一个 |
| 已关闭(closed) | panic! | 立刻返回零值, ok=false |
这张表里有几个最容易致命的格子,值得单独拎出来记牢:一是 nil channel 的收发都永久阻塞——一个忘了用 make 初始化的 channel(零值是 nil),任何收发都会让 goroutine 当场卡死,这是最隐蔽的泄漏源之一。二是向已关闭的 channel 发送会直接 panic——这也是为什么"关闭责任要明确归发送方":只有发送方知道何时不再发送、可以安全关闭,而绝不能由接收方去关(否则发送方可能 panic),更不能重复关闭(也会 panic)。三是从已关闭的 channel 接收不会阻塞、也不报错,而是立刻返回零值——这正是 for range ch 能在 channel 被 close 后优雅结束的原因。把这张表吃透,你对 channel 的掌控力会上一个台阶,这一类坑也就基本绝缘了。
一张"启动 goroutine 前必问"的决策图
把这次踩坑的核心,浓缩成一个写代码时的自检流程。每当你准备敲下 go 这个关键字时,在心里(或代码 review 时)过一遍这张图:
这张图的灵魂只有一句话:每启动一个 goroutine,都要能清楚地回答"它在所有情况下,都一定能退出吗?"如果答案是"不一定"或者"我没想过",那它就是一颗潜在的泄漏炸弹。Go 把"开启并发"这件事做得太轻松了——一个 go 就行,轻松到让人忘了"开启容易,善终难"。而这张图,就是逼你在享受这份轻松之前,先把"善终"的责任担起来。
我立下的几条 Go 并发铁律
这次"内存慢性漏气"的事故后,团队的 Go 规范里郑重加了这么几条:
- 启动即想退出:写下每个 go 之前,必须想清楚它在所有情况(含异常)下如何退出,绝不允许存在"可能永久阻塞"的路径。
- 阻塞必配退路:任何会阻塞的等待(channel 收发、外部调用),都用 select 搭配 ctx.Done() 或超时,给它逃生出口。
- 明确 channel 关闭责任:谁发送谁负责 close;绝不由接收方关闭、绝不重复关闭、绝不向已关闭的 channel 发送。
- 用 context 串起调用链:请求级的 goroutine 都接收并尊重 ctx,上游取消时能级联退出,不留孤儿。
- 默认接入 pprof:长期运行的服务都暴露 pprof,并把 goroutine 数量纳入监控告警,趋势异常上涨即预警。
- 警惕 nil channel 与无缓冲死锁:channel 务必 make 初始化;清楚无缓冲收发会互相阻塞,设计时两端都要想到。
- 慢性内存问题先看 goroutine:遇到"内存缓慢上涨、定期 OOM",优先排查 goroutine 泄漏,而非一头扎进堆对象。
这几条里,第一、二条是治本的——只要"启动即想退出、阻塞必配退路"成了肌肉记忆,goroutine 泄漏就基本绝迹了;而第五条"默认接入 pprof + 监控 goroutine 数"是治标兼预警的关键。我尤其想强调监控:goroutine 泄漏最可怕的不是它会发生,而是它"悄无声息地慢性发生",等你以 OOM 的形式发现它时,已经被它折磨了好几个周期。一条简单的"goroutine 数量持续上涨"告警,能让你在第一次 OOM 之前就抓住它——这是用极小的成本,换取对一整类慢性顽疾的早期感知。
写在最后:轻松开启的背后,是审慎的责任
这次被 goroutine 泄漏折磨一周的经历,让我对 Go 的并发模型,生出了一种"敬畏中带着警醒"的复杂感情。Go 最为人称道的,就是它把并发做得无比简单——一个 go 关键字,就能轻松开启一个协程,几行 channel 就能优雅地通信。这种简单是革命性的,它极大地降低了写并发程序的门槛。可这次事故让我看到了这份"简单"的另一面:它把"开启并发"变得太容易了,容易到让人忽略了"管理并发生命周期"这件同样重要、却远没那么显眼的责任。我们随手一个 go 就把协程发射出去,却很少停下来想:它会怎么结束?它会不会卡住?谁来为它的"善终"负责?
所以我现在写 Go 的心态成熟了:我把每一个 go 都看作一个郑重的承诺——我承诺这个被我开启的 goroutine,无论遇到什么情况,都有一条明确的、能走到尽头的退路;我承诺它不会变成一个赖在内存里出不去的幽灵。开启一个 goroutine 的成本几乎为零,但为它的整个生命周期负责的成本,需要我用审慎的设计去支付。这份"开启容易,善终难"的认知,让我对 go 这个轻巧的关键字,多了一份不敢轻慢的郑重。
这个道理,其实超越了 Go、超越了并发,指向一个更普遍的工程智慧:越是被工具"简化"了的操作,越要警惕它背后被一并"隐藏"了的责任。框架帮你简化了资源管理,但资源该释放时还得释放;语言帮你简化了并发,但并发的生命周期还得你来管;云平台帮你简化了部署,但系统的可靠性还得你来兜。工具的"易用",降低的是"使用"的门槛,却从不免除"用好"的责任——而那些被简化、被隐藏起来的责任,恰恰最容易被遗忘,也恰恰最容易在某个深夜,以一次 OOM、一次泄漏、一次事故的形式,提醒你它的存在。所以,如果你也在写 Go,我想把这次踩坑最想说的话送给你:尽情享受 go 关键字带来的轻松,但请永远记得,你按下的每一个 go,都是一个需要你负责到底的生命。别让它变成内存里那个等不到结果、却永远走不掉的幽灵——让你开启的每一个并发,都能有始有终、善始善终。
一个延伸:把"可观测性"当成服务的标配
这次事故还让我把"可观测性"这件事,从"可有可无的锦上添花",提到了"长期服务标配"的高度。回想整个排查过程,真正让我们从"两眼一抹黑地猜"切换到"一眼定位元凶"的转折点,就是接入了 pprof、把 goroutine 数量打上了监控面板。在那之前,我们对着"内存涨、定期 OOM"这个现象瞎猜了好几天——是不是缓存没清?是不是连接没关?是不是哪里内存泄漏?全是猜测,无从验证;而有了 pprof,一条"goroutine 只增不减"的曲线和一份"都卡在某一行"的堆栈,瞬间就把范围从"整个服务"收窄到了"那一行代码"。
这个对比给我的触动很深:一个系统能不能被快速地排障,在很大程度上,不取决于工程师有多聪明,而取决于这个系统本身有多"可观测"——它有没有把自己内部的运行状态(指标、日志、链路、profile),以一种可被观察、可被度量的方式暴露出来。一个黑盒系统,出了问题只能靠猜、靠重启碰运气;而一个可观测的系统,问题会自己"浮现"在你的监控曲线和 profile 数据里。所以我现在搭任何长期运行的服务,都会把可观测性当成和业务功能同等重要的"地基"来建:接入 pprof、暴露关键指标(goroutine 数、内存、GC、连接池……)、配好告警。这些前期投入,在你某个深夜被告警叫醒、需要和时间赛跑地定位问题时,会成倍地回报你。那次慢性漏气的事故教给我的,除了 goroutine 泄漏本身,更是这份"让系统能被看见"的工程自觉——因为你无法修复一个你看不见的问题。
愿你的每一个 Go 服务,都既有 go 关键字带来的轻盈并发,也有 pprof 和监控给予的清澈视野——既跑得轻快,又看得明白。当并发的自由与可观测的自律两者兼备时,那些曾经神出鬼没的慢性顽疾,才会真正失去藏身之所。这,大概就是这场漏气事故,留给我最完整的一课了。
把这份认知带进日常,你写下的每一行并发代码,都会比从前更让人安心。
—— 别看了 · 2026