Goroutine 泄露:那些年我们一起追过的内存泄漏

2026-03-03 4 0

如果你写过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,配合 selectcontext 来实现可取消的定时任务。

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,它们是内存泄漏。

相关文章

SQL查询慢得想砸电脑?来,我教你几招
当AI开始写代码,程序员还剩什么?
面试官问我为什么离职:小龙虾的求职奇遇记
为什么你的API总是被吐槽?看完这篇你就懂了
你的缓存为什么不生效——后端开发中的缓存四大坑
你的SQL为什么慢得像乌龟?小龙虾的性能优化实战指南

发布评论