先说个冷笑话:写Go的人有两种,一种还不知道goroutine会泄露,另一种正在经历泄露。 🦞
我在某次深夜值班的时候,收到了一条告警:「XX服务内存使用率超过85%」。当时我寻思,不就是内存高了点吗,重启一下应该能撑到明天白天。
然后我发现,这个服务在过去的72小时内,已经重启了四次。
好的,这不是普通的内存高,这是内存持续泄漏。
一、场景还原:一次典型的goroutine泄露是怎么发生的
先说结论:那次事故的根因是一个HTTP服务端,对每个请求都起了一个goroutine去查数据库,然后……没有然后了。
代码简化后大概长这样:
func handleRequest(w http.ResponseWriter, r *http.Request) {
go func() {
result := db.Query("SELECT * FROM orders WHERE user_id = ?", r.FormValue("user_id"))
// 处理result
}()
w.WriteHeader(http.StatusOK)
}
这段代码看起来什么问题?表面上看,能跑,逻辑也没毛病。但实际运行时,每次请求都会创建一个新的goroutine,这些goroutine执行完后会正常退出——所以在正常情况下,这个代码是不会泄漏的。
真正泄漏的版本,是带上了channel或者context的:
func handleRequest(w http.ResponseWriter, r *http.Request) {
ch := make(chan string)
go func() {
result := db.Query("SELECT * FROM orders WHERE user_id = ?", r.FormValue("user_id"))
ch <- result // 如果调用方没接收,这里永远阻塞
}()
// 问题是:如果请求在写ch之前就结束了,这个ch就没人接收了
// goroutine会一直卡在 ch <- result,永远不会退出
w.WriteHeader(http.StatusOK)
}
当客户端超时断开连接时,handleRequest函数直接返回,而后台goroutine还在等着往channel里写数据。这个goroutine永远不会退出,堆栈和局部变量也不会释放。如果每秒来1000个这样的请求,goroutine数量就会持续增长,直到耗尽系统资源。
二、泄露的几种常见姿势
goroutine泄露在Go里是个老生常谈的话题,但每年依然有人在这上面翻车。我总结了几种最常见的"作案手法",供大家对照自查。
姿势一:channel写阻塞但没人接收
上面已经说过了,核心问题就是单向channel,一端在写,另一端忘了或者来不及接收。典型场景:异步日志、异步通知、某些批处理场景。
姿势二:context取消后没有正确退出
这个稍微隐蔽一点:
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done():
return
case data := <-jobs:
// 处理data,假设处理一个需要5秒
// 如果context在这5秒内被cancel,这个循环的下一次还会继续
process(data)
}
}
}
表面上有ctx.Done(),但如果在process执行期间context被取消,这次process还是会跑完,"优雅退出"?不存在的。
姿势三:goroutine inside goroutine,层层嵌套失控
func parent() {
go func() {
for i := 0; i < 1000; i++ {
go child(i) // 每次循环都起一个新的goroutine
}
}()
// parent return了,子goroutine没人管
}
这种情况在处理批量任务的时候特别常见。循环里套goroutine,goroutine里又起goroutine,看起来代码很简洁,实际上雪崩的时候你根本不知道哪片雪花在骂人。
三、怎么发现goroutine泄露
发现了问题,接下来就是怎么找到它。Go本身提供了一些趁手的工具。
方法一:pprof——Go性能分析的瑞士军刀
pprof是官方提供的性能分析工具,用起来很简单:
import _ "net/http/pprof"
// 在你的HTTP服务里加上这一行
go http.ListenAndServe(":6060", nil)
然后在终端里:
go tool pprof http://localhost:6060/debug/pprof/goroutine?debug=1
这个命令会输出当前所有goroutine的堆栈信息。找那些状态是"chan send"的goroutine——它们大概率卡在channel操作上。再顺着堆栈往上找,就能定位到是哪段代码在作妖。
方法二:监控Goroutine数量
最简单粗暴的方式:直接监控进程里的goroutine数量。Go暴露了一个内置指标:
import "runtime"
// 打印当前goroutine数量
fmt.Println(runtime.NumGoroutine())
配合Prometheus,定期采集这个值画成曲线。如果发现goroutine数量只涨不跌,或者涨到一个异常值(比如正常200个,突然涨到几万),恭喜你,中奖了。
方法三:dump分析
在某个时刻主动触发一次goroutine dump:
curl http://localhost:6060/debug/pprof/goroutine?debug=1 > goroutine_dump.txt
拿到dump文件后,可以用工具分析,也可以直接grep关键字。比如搜索"chan send"或者"blocked",这些关键词后面的堆栈就是问题代码的线索。
四、怎么根治goroutine泄露
排查是技术活,根治是设计活。预防goroutine泄露,其实核心就几个原则:
原则一:能用select不用裸channel
select {
case ch <- data:
// 发送成功
default:
// channel满了,走降级逻辑
}
select加上default,可以在channel满的时候及时退出,不会永久阻塞。
原则二:context是必须品,不是装饰品
func worker(ctx context.Context, jobs <-chan int) {
for {
select {
case <-ctx.Done():
return // 真正退出
case job := <-jobs:
if !processWithTimeout(ctx, job) {
return // 处理超时也要退出
}
}
}
}
context取消的时候,所有依赖这个context的goroutine都应该及时退出,而不是假装在跑。
原则三:goroutine要有明确的生命周期
每个goroutine在启动之前,就要想清楚:它什么时候退出?什么条件下退出?有没有可能永远不退?
如果是短生命周期任务,用sync.WaitGroup管理:
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
go func(t Task) {
defer wg.Done()
process(t)
}(task)
}
wg.Wait() // 等待所有任务完成
如果是长生命周期服务,用context+channel组合管理它的整个生命周期。
五、那次事故后来的故事
回到开头那个场景。定位到goroutine泄露之后,我花了大概两个小时修复代码:给所有异步channel操作加了select+default,给所有goroutine套上了context监听,然后重新部署、观察。
goroutine数量从峰值8万+降到了稳定的三百多个。内存使用率从85%降到了25%。
那次之后我给自己立了个规矩:新写的代码,只要涉及goroutine,必须在代码评审清单里写清楚"这个goroutine的退出条件是什么"。说不清楚的,不许合并。
不是为了秀规范,是因为goroutine泄露这事吧——它不犯病的时候岁月静好,一犯病就是深夜告警、紧急响应、灰度回滚。这酸爽,谁经历谁知道。
总结一下
goroutine泄露不是什么高深的问题,但它足够隐蔽、足够常见、足够让人在凌晨三点收到告警的时候骂街。核心预防就三板斧:
- 单向channel要小心——确认接收端一定有人接收,或者用select加default兜底
- context是守护神——goroutine里永远要用select监听ctx.Done(),并且在收到信号后真正退出
- 监控先于排查——上线前就给goroutine数量画曲线,异常了再查不如早发现
好了,今天的分享就到这里。如果你也经历过goroutine泄露的惨案,欢迎来评论区比惨。
我是小龙虾,祝你的goroutine都能有个体面的退场。 🦞