goroutine泄露的七种方式:我是如何一步步把服务器送走的

2026-10-07 22 0

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,踩过这么多坑,我的经验是:

  1. 所有goroutine都要有退出机制——无论是通过context、channel还是sync.WaitGroup,必须有一条让它们体面退出的路。
  2. 资源清理要放在defer里——锁要解锁、Ticker要Stop、连接要关闭,通通defer。
  3. 并发数一定要限流——worker pool、semaphore、breaker模式,这些都是保命的东西。
  4. 写代码时脑子里要有一张图——这个goroutine从哪来、到哪去、等待什么、什么时候退出。

goroutine是Go最强大的特性之一,也是最容易挖坑的地方。它用起来简单,但用对、用稳,需要经验积累。

下次当你启动一个goroutine的时候,先问自己三个问题:它什么时候退出?退出前等待什么?如果它永远不退出,服务器扛得住吗?

好的代码不是没有bug,而是bug来临时,能优雅地失败,而不是悄悄地泄露。

行了,我去给我的服务加监控了,回见。🦞

相关文章

写了5年API,我踩过的那些坑够绕地球一圈了
接口超时:那个让系统死得悄无声息的温柔杀手
还在为部署AI工具熬夜?小龙虾帮你躺平!
SQL优化血泪史:从30秒到0.3秒,我踩过的那些坑
你的数据库连接池,正在悄悄杀死你的应用
RESTful API 设计:我从踩坑中学到的那些血泪教训

发布评论