子兮子兮

goroutine 泄露并不会立刻杀死服务,它更像慢性病,直到某一天系统突然“扛不住了”。
这是一次发生在生产环境的真实问题排查过程,记录下来,一方面是给自己留档,另一方面也希望能给后来遇到类似问题的人一点参考。
最初的问题并不明显。
唯一异常的是:
发生这类现象,经验上第一反应就是:存在资源泄露问题。
在 Go 服务中,最容易被忽略的泄露就是 goroutine。
通过 /debug/pprof/goroutine 查看 goroutine 数量:
curl http://127.0.0.1:6060/debug/pprof/goroutine?debug=1
观察到一个关键现象:
这基本可以确定:goroutine 没有正常退出。
接下来不是看数量,而是看它们在“干什么”。
go tool pprof http://127.0.0.1:6060/debug/pprof/goroutine
在调用栈中,发现大量 goroutine 卡在:
chan receive
并且调用路径高度一致。
这说明问题不是偶发,而是某一类逻辑在稳定地产生泄露。
最终定位到如下代码(已简化):
func asyncNotify(data Data) {
ch := make(chan Result)
go func() {
result := doRequest(data)
ch <- result
}()
select {
case <-time.After(1 * time.Second):
return
case res := <-ch:
handle(res)
}
}
表面逻辑:
关键点在这里:
ch := make(chan Result) // 无缓冲 channel
当发生超时时:
select 直接 return,但 goroutine 仍在执行;doRequest 返回结果,尝试 ch <- result,此时已经没有接收者。结果:
goroutine 永远阻塞在发送 channel 的那一行。
每一次超时,都会留下一个“活着但无法退出”的 goroutine。
这个问题在测试环境几乎无法复现,原因包括:
而在生产环境:
超时路径被频繁触发,泄露才逐渐累积。
ch := make(chan Result, 1)
这样即使外层不再接收,发送也不会阻塞。
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
ch := make(chan Result, 1)
go func(ctx context.Context) {
select {
case <-ctx.Done():
return
default:
result := doRequest(data)
ch <- result
}
}(ctx)
关键思想:
channel 只是通信手段,不是生命周期管理工具。这次事故之后,我们总结了几条可操作的经验:
select + timeout 一定要检查是否会留下阻塞发送;channel;context;goroutine 泄露不会立刻让系统崩溃,但它会在你最没有防备的时候,悄悄掏空系统的稳定性。
如果你在生产环境看到:
请一定第一时间想到它。
| 内容声明 | |
|---|---|
| 标题: 一次 goroutine 泄露的排查过程 | |
| 链接: https://zixizixi.cn/troubleshooting-process-of-a-goroutine-leak | 来源: iTanken |
本作品采用知识共享署名-相同方式共享 4.0 国际许可协议进行许可,转载请保留此声明。
| |