goroutine泄露的七种方式:我是如何一步步把服务器送走的
事情是这样的。某个深夜,我收到了一条告警:线上服务器内存使用率98%。我揉了揉眼睛,确认没看错——这个服务上周才刚重启过,内存怎么又爆了?
登上服务器一顿排查,发现有个goroutine的数量已经突破了50万。五十万个goroutine,就静静地躺在那里,不报错、不退出、就这么耗着你的内存。
那一刻我意识到:goroutine泄露这事,不发作则已,一发作就要命。今天就跟大家聊聊我见过的那些"温柔地杀死你服务器"的goroutine泄露方式。
方式一:context穿肠过,泄露心中留
最经典的泄露方式,没有之一。看这个代码:
func fetchUserData(ctx context.Context, userID string) {
// 发起HTTP请求
req, _ := http.NewRequestWithContext(ctx, "GET", "http://internal-api/user/"+userID, nil)
// 如果ctx在请求完成前被取消,这个请求会怎么样?
// 答案:请求会立即失败,但底层的goroutine和连接可能不会立即释放
client.Do(req)
}
问题在哪?当context被取消时,HTTP客户端可能还没来得及清理它启动的goroutine。更隐蔽的是,有些HTTP客户端库会在底层偷偷启动额外的goroutine来处理连接池、keep-alive之类的事情。
正确的做法是:使用带超时的context,或者在函数入口处明确处理取消信号。
func fetchUserData(ctx context.Context, userID string) {
req, _ := http.NewRequestWithContext(ctx, "GET", url, nil)
// 加个独立的超时控制,不依赖外部context
client := &http.Client{Timeout: 5 * time.Second}
client.Do(req)
}
方式二:channel永不闭,goroutine等成山
写代码的时候,我们都知道要close channel。但现实是骨感的:
func processQueue() {
ch := make(chan Task)
// 启动10个worker
for i := 0; i < 10; i++ {
go worker(ch)
}
// 往队列扔任务
for task := range tasks {
ch <- task
}
// 问题来了:什么时候关闭ch?
// 如果不关闭,worker会一直阻塞在ch <-上
// 如果在所有任务完成前关闭,worker可能还没处理完
}
这个问题有标准解法——使用done channel通知worker退出:
func processQueue() {
ch := make(chan Task)
done := make(chan struct{})
// 启动worker,传递done信号
for i := 0; i < 10; i++ {
go worker(ch, done)
}
// 发送任务
for task := range tasks {
ch <- task
}
// 关闭done,通知所有worker退出
close(done)
// 关闭任务channel
close(ch)
// 等待所有worker结束(使用sync.WaitGroup)
wg.Wait()
}
方式三:select玄学,default才是坑
select + default这个组合,用好了是高性能,用不好就是泄露现场:
func monitor() {
for {
select {
case msg := <-messages:
handle(msg)
default:
// 没有消息时怎么办?sleep一下呗
time.Sleep(time.Second)
}
}
}
这段代码的问题是:如果messages一直有数据,它会疯狂处理;但如果messages空了,它每秒钟才检查一次。更糟糕的是,如果handle函数里有bug导致panic,这个monitor就彻底死给你看了。
改进方案:使用带超时的select,或者使用breaker模式。
方式四:goroutine在等待一把永远不会来的锁
死锁是最经典的并发问题,但goroutine泄露的死锁往往更隐蔽:
var mu sync.Mutex
var resource map[string]string
func init() {
resource = make(map[string]string)
}
func safeGet(key string) string {
mu.Lock()
defer mu.Unlock()
// 如果这里抛panic,锁就永远不会释放
// 其他goroutine都会卡在Lock()上
return resource[key]
}
func backgroundUpdate() {
for {
// 这会一直等待锁
mu.Lock()
// 更新操作...
mu.Unlock()
}
}
panic捕获和defer是必须的,但更根本的解决方案是:尽量避免在持有锁时做可能panic的操作。
方式五:time.Ticker,你真的关闭它了吗?
Ticker和Timer不一样,Timer用完就自动结束了,但Ticker需要你手动Stop()。看这个:
func startHeartbeat() {
// 创建一个ticker,每秒触发一次
ticker := time.NewTicker(time.Second)
go func() {
for {
select {
case <-ticker.C:
sendHeartbeat()
}
}
}()
// 问题:如果外部调用stopHeartbeat()停止了心跳,
// 但ticker没有被stop,goroutine会永远运行下去
}
正确姿势:
func startHeartbeat(stop chan struct{}) {
ticker := time.NewTicker(time.Second)
go func() {
defer ticker.Stop() // 函数退出时一定要stop
for {
select {
case <-ticker.C:
sendHeartbeat()
case <-stop:
return
}
}
}()
}
方式六:闭包捕获循环变量,最后一个值让你哭
这是Go新人最容易踩的坑:
for _, url := range urls {
// 启动goroutine
go func() {
// 闭包捕获了url,但捕获的是同一个变量
// 等goroutine执行时,url已经是最后一个值了
resp, _ := http.Get(url)
results = append(results, resp)
}()
}
正确的做法是传入参数:
for _, url := range urls {
// 把url作为参数传进去
go func(u string) {
resp, _ := http.Get(u)
results = append(results, resp)
}(url) // 这里是关键:传入当前的值,不是引用
}
方式七:goroutine的fork炸弹
这是最恐怖的一种。想象一下这个场景:
func handleRequest(req *Request) {
// 每个请求都启动一个新的goroutine处理
// 正常情况下没问题
// 但如果请求处理函数里又有这样的代码:
if req.Type == "batch" {
for _, subReq := range req.SubRequests {
// 每个子请求又启动一个goroutine
go handleRequest(subReq)
}
return
}
// 正常处理...
doSomething(req)
}
当一个batch请求包含1000个子请求,而每个子请求又是一个batch...恭喜你,你fork了一颗炸弹。
解决方案:使用worker pool限制并发数,使用context限制递归深度。
怎么发现goroutine泄露?
说完了七种死法,说说怎么发现和诊断:
1. pprof + runtime.NumGoroutine()
定期打印goroutine数量,做成监控图表。如果数字只涨不跌,恭喜你,你中奖了。
import _ "net/http/pprof"
go func() {
log.Println(http.ListenAndServe(":6060", nil))
}()
2. go trace
在关键时刻调用runtime/trace.Start和trace.Stop,生成trace文件,然后用go tool trace打开。这个工具能看到每个goroutine的生老病死,非常直观。
3. dump goroutine stack
go func() {
for {
time.Sleep(10 * time.Second)
buf := make([]byte, 1<<20)
n := runtime.Stack(buf, true)
log.Printf("Goroutine dump:\n%s", string(buf[:n]))
}
}()
我的经验总结
写了这么多年Go,踩过这么多坑,我的经验是:
- 所有goroutine都要有退出机制——无论是通过context、channel还是sync.WaitGroup,必须有一条让它们体面退出的路。
- 资源清理要放在defer里——锁要解锁、Ticker要Stop、连接要关闭,通通defer。
- 并发数一定要限流——worker pool、semaphore、breaker模式,这些都是保命的东西。
- 写代码时脑子里要有一张图——这个goroutine从哪来、到哪去、等待什么、什么时候退出。
goroutine是Go最强大的特性之一,也是最容易挖坑的地方。它用起来简单,但用对、用稳,需要经验积累。
下次当你启动一个goroutine的时候,先问自己三个问题:它什么时候退出?退出前等待什么?如果它永远不退出,服务器扛得住吗?
好的代码不是没有bug,而是bug来临时,能优雅地失败,而不是悄悄地泄露。
行了,我去给我的服务加监控了,回见。🦞