你有没有遇到过这种情况:服务上线后内存曲线像股票 K 线一样只涨不跌,重启一下立刻回落到水线上;监控上的 goroutine 数量从几千慢慢爬到几十万;机器还没崩,但你的进程已经开始"喘"。
恭喜你,你大概率遇到了 Go 里的 goroutine 泄漏。
很多人第一次听到这个词,觉得是"并发写代码没加锁"那种玄学。但说实话,goroutine 泄漏跟锁没关系,它是 Go 最容易被忽视、也最难在线上排查的坑之一。今天我就把它拆开揉碎了讲,顺带把 context 这个被 90% 的人用成摆设的东西讲明白。
一、先说结论:goroutine 为什么会被"漏掉"
Go 的 runtime 里,每个 goroutine 初始只占 2KB 栈。听起来很便宜对吧?但它便宜的前提是它会结束。
goroutine 只有在两种情况下真正退出:
- 函数 return,goroutine 正常结束;
- runtime.Goexit()(比如
t.Fatal()内部就是这个)。
如果一个 goroutine 卡在某个永远不返回的地方——比如一个没人关闭的 channel 读、一个写满的 channel 写、一个永远不会完成的 HTTP 请求、一个没人调用的 sync.WaitGroup.Wait()——它就会一直活着,占着栈、占着它引用的所有内存,而且永远不会被 GC 回收。goroutine 是 GC 的根,根活着,它抓着的整条引用链就都活着。
这就是为什么 goroutine 泄漏会连带造成内存泄漏。你以为漏的是一个 goroutine,实际漏的是它闭包里捕获的一整个对象图。有人在 handler 里把
*sql.DB或者整个请求体捕获进去,那泄露的就不是 2KB 栈那么简单了。
二、四大经典泄漏姿势,逐个拆
姿势一:往一个没人消费的 channel 里写
func startConsumer() {
ch := make(chan int)
// 忘了 go func(){ for v := range ch { ... } }()
}
func handler() {
ch := make(chan int) // 每次请求新建一个,无人接收
ch <- 1
// 永久阻塞在这里,goroutine 活着
}
这是最经典的。写阻塞在无缓冲 channel 上,goroutine 永远出不来。修法也很直接:
ch := make(chan int, 1) // 给缓冲区
go func() { for v := range ch {} }() // 保证有消费者
select {
case ch <- 1:
case <-ctx.Done():
return
}
姿势二:往一个永远不会关闭的 channel 里写
// 生产者
for {
select {
case requests <- job:
default:
// 满了就丢弃
}
}
这个还好。真正致命的是"发送方比接收方多"或者"接收方比发送方多"但关闭的责任没定清楚。Go 的规矩是:永远由发送方 close(ch),接收方只负责 range。如果你在多个 goroutine 里 close 同一个 channel,程序会 panic(close of closed channel);如果接收方先跑掉了,发送方就永远阻塞在 send 上。
更狠的坑:for range ch 依赖 close 才结束。有人图省事,在 channel 用完后不 close,于是消费方 goroutine 永远挂着。代码看起来"没问题",但进程里多了一把永远不落的锁。
姿势三:HTTP 请求没有超时
// 千万别这么写
resp, err := http.Get("http://slow-service/api")
http.Get 用的是 http.DefaultClient,没有超时。如果对端服务挂住(不是拒绝连接,是接受连接后不返回),你的 goroutine 就跟着它一起挂住。流量一上来,几千个 goroutine 就这么堆在那儿了。
正确姿势永远是显式 timeout + 显式 context:
ctx, cancel := context.WithTimeout(parentCtx, 3*time.Second)
defer cancel() // 忘了这行 = 泄漏
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
注意那句 defer cancel()。context.WithTimeout 会在超时定时器触发时自动 cancel,但如果提前 return(比如请求成功了就直接返回),定时器还在那儿挂着,等着那个时间点才释放资源。在高 QPS 下,这就是"看不见的定时器堆积"。所以官方文档第一行就写着:Always call cancel。
姿势四:WaitGroup 的 Add 和 Done 不配对
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func() {
defer wg.Done()
process(item)
}()
}
wg.Wait() // 如果某个 goroutine 提前 return 忘了 Done,这里就永远卡住
还有一个更隐蔽的 Go 版本坑:在 wg.Wait() 之后调用 wg.Add()。如果 Add 发生在 Wait 已经把计数器归零之后,计数器会"复活",而 Wait 已经返回了,后续的 Done 反而会让计数器变负数,触发 panic。这事儿的诡异程度,见过的人都懂。
三、context 到底该怎么用
现在重点来了。context.Context 在 Go 1.7 进入标准库,本意是"跨调用边界的取消信号 + 截止时间 + 请求域数据"。但在实际项目里,它经常被写成下面这样:
// 反面教材:context 只用来传了个 ID
ctx := context.WithValue(ctx, "userID", userID)
然后调用链里压根没人检查 ctx.Done()。这样一来,客户端断开了、请求超时了,服务端还在闷头干活,把结果写进一个没人读的地方。
正确的用法有三条铁律:
一、ctx 是第一个参数,且不存进结构体。
func Query(ctx context.Context, id int64) (*User, error)
存进结构体意味着你之后没法换、没法组合、没法统一取消。Google 的官方 code review 指南明确写着:Do not store Contexts inside a struct type。原因很简单——context 是请求域的,一旦你的结构体活过请求,这个 context 就会成为悬挂引用。
二、取消信号必须往下传。
你在 handler 里接了 r.Context(),就必须把它传给你的 DB 查询、HTTP 调用、消息消费,而不是在中间层"造一个新的"。派生用 WithCancel / WithTimeout,永远从父 context 派生:
func Handle(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
// 这个请求取消时,下面所有下游都会被取消
result, err := service.Query(ctx, id)
}
三、Values 用自定义类型,别用 string 键。
type ctxKey string
const UserIDKey ctxKey = "user_id"
ctx = context.WithValue(ctx, UserIDKey, userID)
用裸 string 键的唯一好处是"方便",唯一坏处是"会撞车"。定义 type ctxKey struct{} 是更好的做法,因为这种零大小类型永远不会和别人的键相等。
顺便说一句:
context.WithValue应该只装请求域的元数据(trace ID、用户身份、租户 ID),不要往里塞业务参数。你如果开始用 context 传 orderId,那不如直接加个函数参数——参数会出现在函数签名里,而 context 里的东西会静悄悄地消失。
四、怎么在生产环境把它抓出来
光靠读代码不够,得有工具。Go 自带的东西已经够用了:
// 1. pprof 看 goroutine 快照
curl http://localhost:6060/debug/pprof/goroutine?debug=2
// 2. 把它接到 Prometheus 上
import "github.com/prometheus/client_golang/prometheus/promhttp"
_ = promhttp.Handler()
我推荐的组合是:
- goroutine 数量监控:超过基线(比如启动时的 10 倍)就告警。这是最廉价也最有效的泄漏探针。
- 周期性 goroutine profile diff:定期抓两份快照,做差集,看哪些 goroutine 在"只增不减"。这比看总数精准得多。
- 代码里加栈采样:在可疑的 goroutine 入口塞个标记,泄漏时直接从 profile 里看出是谁的锅。
我用过一个土但极其好用的技巧:给每个后台 goroutine 挂一个唯一的名字,泄漏了直接搜名字,一目了然:
func goWithName(name string, fn func()) {
go func() {
defer func() {
if err := recover(); err != nil {
log.Printf("[%s] panic: %v", name, err)
}
}()
fn()
}()
}
goWithName("sync-user-cache", func() { ... })
五、我的几条实战判断
写了这么多,最后给几条带观点的总结:
第一,goroutine 不是线程,别拿"线程很贵所以要复用"那套思路。Go 的 goroutine 便宜,但"每个请求起一个 goroutine"这种写法在有阻塞点的地方依然是隐患。便宜的 goroutine 加上永不释放的阻塞点,等于慢性自杀。
第二,defer cancel() 这行代码的性价比高到离谱。一行代码,消灭一整类泄漏。我见过至少三个项目的 context 泄漏事故,根因全是忘了这行。代码审查时看到 context.With 没跟 cancel,直接当 bug 打回。
第三,任何可能阻塞的第三方调用,默认超时。不要相信"对方服务很快"。MySQL 有 wait_timeout,Redis 有 timeout,HTTP 有 Client.Timeout。没有超时的调用,就是一颗定时炸弹,炸的时候还炸在最不该炸的路径上。
第四,别用 time.Sleep 做重试退避,用 timer + select ctx.Done()。这样上游一取消,你的 goroutine 立刻退出,而不是在那儿老老实实睡满 30 秒。这在流量突增时是救命的。
好的并发代码,是让人看不出来有并发的代码。而 goroutine 泄漏恰恰相反:它会让你在半夜被一条告警短信叫醒,然后盯着一个 pprof 快照发呆四小时。
六、一句话收尾
goroutine 泄漏不是玄学,它是"本该结束的 goroutine 被某个阻塞点卡住了"。你只要记住三句话:
- 每个
context.With*后面都要跟一个defer cancel(); - 每个阻塞调用都要有超时;
- 每个 channel 都要想清楚谁 close、谁消费。
把这三条当成肌肉记忆写进你的 code review checklist,goroutine 泄漏这种"只涨不跌"的慢性病,基本就跟你绝缘了。
至于剩下的复杂场景——worker pool、优雅退出、context 树形编排——那就是另一个故事了。先把基础打好,别一上来就想着造一个花里胡哨的调度框架,绝大多数场景根本不需要。
代码写多了你会发现,绝大部分"高级架构"问题,最后都会退化成同一个问题:这个东西什么时候能结束?