如果你写过Go代码,你一定用过 go func()。这玩意儿太好用了——轻量、简单、起步快。但是好用归好用,它埋的坑可一点不少。今天我们就来聊聊 Goroutine 泄露这个话题,保证让你看完脊背发凉,然后赶紧去检查自己的代码。
什么是 Goroutine 泄露?
简单来说,Goroutine 泄露就是启动了某个 Goroutine,但是它永远都不会退出,也永远不会被垃圾回收器回收。这些 Goroutine 会一直占用内存和调度资源,直到进程挂掉。
一个 Goroutine 的栈初始只有 2KB,但是它可以随着执行增长到很大。更恐怖的是,如果你创建了成千上万个泄露的 Goroutine,你的程序内存会呈指数级增长,直到 OOM。
别以为这是危言耸听——我见过太多生产环境的故障根因都是 Goroutine 泄露。有的是定时任务,有的是 HTTP 请求处理,有的是消息消费。几乎每个写 Go 的人都踩过这个坑。
坑一:channel 阻塞
最经典的泄露场景就是 channel 没有人接收,或者没有人发送。
func process() {
ch := make(chan string)
go func() {
// 发送数据到 channel
ch <- "processed"
}()
// 忘记接收数据!Goroutine 会永远阻塞
}
上面这段代码看起来很傻,但实际开发中它可能藏得很深。比如你在一个复杂的业务逻辑里开了个后台 Goroutine 去做某些事情,然后因为某个条件分支提前 return 了,忘了通知它。
正确的姿势: 使用带缓冲的 channel,或者使用 select 加上 default 分支,或者使用 context 来控制超时。
func process(ctx context.Context) {
ch := make(chan string, 1)
go func() {
select {
case ch <- "processed":
case <-ctx.Done():
return // 优雅退出
}
}()
// ... 业务逻辑
}
坑二:goroutine 里面的 goroutine
有时候一个 Goroutine 里面会启动更多的 Goroutine,形成了一个复杂的生命周期。当外层的 Goroutine 应该退出时,内层的那些可能还在傻傻地等着。
func fetchData(urls []string) {
for _, url := range urls {
go func(u string) {
resp, _ := http.Get(u)
defer resp.Body.Close()
// 这里又启动了一个 goroutine 处理响应
go processResponse(resp)
}(url)
}
}
如果外层的循环结束了,但是 processResponse 还在运行,它持有的资源就彻底失控了。
建议: 使用 sync.WaitGroup 来管理所有子 Goroutine,或者用 context 来统一控制生命周期。
坑三:time.Sleep 的陷阱
有些人喜欢用 time.Sleep 来做定时任务,觉得简单粗暴有效。但这种方法的问题在于,你无法优雅地停止它。
func startWorker() {
for {
time.Sleep(time.Hour) // 想停停不掉
doWork()
}
}
如果你想停止这个 worker,你只能等那漫长的 Sleep 结束。更惨的是,如果这个 worker 里面还有嵌套的 Goroutine,它们可能永远都停不下来。
正确做法: 用 time.Ticker 或者 time.Timer,配合 select 和 context 来实现可取消的定时任务。
func startWorker(ctx context.Context) {
ticker := time.NewTicker(time.Hour)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doWork()
case <-ctx.Done():
return // 优雅退出
}
}
}
坑四:goroutine 闭包捕获变量
这是 Go 面试必问的经典问题,但实际开发中依然有无数人踩坑。
for i := 0; i < 10; i++ {
go func() {
fmt.Println(i) // 这里的 i 是外部变量的引用!
}()
}
这段代码的输出是 10 个 10,而不是 0 到 9。因为当 Goroutine 执行时,循环已经结束了,i 的值就是 10。
虽然这个问题很基础,但我见过太多复杂的业务代码里埋着这种雷。当你启动一个 Goroutine 去异步处理请求时,如果你的闭包捕获了某些共享变量,而且没有做好同步,那就等着出乱子吧。
如何检测 Goroutine 泄露?
1. pprof
Go 自带的 pprof 工具可以帮大忙。开启 net/http/pprof,然后访问 /debug/pprof/goroutine?debug=1,你就能看到所有 Goroutine 的堆栈信息。定期抓取对比,数量持续增长的就是泄露。
2. 监控报警
在生产环境接入 Prometheus,用 go_goroutines 指标做监控。设置阈值报警,超过正常值就开始排查。
3. context 超时
所有异步操作都要带超时,超时了就得给我滚蛋。这是最后一道防线。
最佳实践总结
1. 永远使用 context——不管是 HTTP 请求、数据库操作还是消息消费,把 context 往下传,它是唯一能帮你优雅停止一切的东西。
2. 善用 select + default——处理 channel 时想想如果没有人配合会怎样。
3. WaitGroup 不是万能的——它只能帮你等,不能帮你取消。真要取消,还得靠 context。
4. 给所有异步操作加超时——没有无限等待的 Goroutine,如果有,那一定是bug。
5. 定期体检——上线前用 pprof 跑一跑,上线后监控指标盯着点。
写在最后
Goroutine 是 Go 最迷人的特性,也是最危险的特性。它太简单了,简单到让人忘乎所以。但正是这种简单,才需要我们更加谨慎。
记住一句话:每一个 go func() 都对应着一份责任。你启动了它,就得想办法优雅地结束它。
别让你的程序里住着成千上万个永远睡不醒的 Goroutine——它们不是Worker,它们是内存泄漏。