你的Go服务正在偷偷”漏”协程,而你还浑然不知

2026-04-17 8 0

你的Go服务正在偷偷"漏"协程,而你还浑然不知

凌晨三点,你被一条内存告警炸醒。打开监控一看:Goroutine 数量从平时的 200 个一路狂飙到了 18000 个。重启?暂时压下去了,但半小时后又来一波。

你开始排查。日志里没有任何 panic,没有任何报错,就是协程数无声无息地往上涨。

恭喜你,你遇到的是 Go 语言里最阴险的问题之一:goroutine leak(协程泄露)。


什么是协程泄露?说人话

一个 goroutine 在完成任务后,应该被 Go 运行时自动回收。但如果它卡在某个地方,既不能完成也不能退出,运行时就会一直保留它——直到你的进程内存爆掉。

这不像内存泄露有明确的堆对象可以追查,goroutine leak 更隐蔽:代码看起来完全正常,功能测试跑得飞起,只有在生产环境跑一段时间后才暴露。

我们来看几个典型的"坑 goroutine 于无形"的代码场景。


场景一:channel 操作卡死——最常见的坑

假设你写了一个函数,用来异步发送通知:

func SendNotification(userID string, msg string) {    ch := make(chan bool)    go func() {        time.Sleep(2 * time.Second)        ch <- true    }()    <-ch}

这段代码有什么问题?问题在于调用方可能根本不关心返回值:

go SendNotification(userID, "您的订单已发货")

goroutine 里已经 <-ch 写入了,但没人读,所以这个 channel 的第二个操作(读)永远卡住。goroutine 卡在读操作,无法退出。每调用一次,就漏一个协程。

正确做法:buffered channel 或者根本不用 channel:

func SendNotification(userID string, msg string) {    ch := make(chan bool, 1)    go func() {        time.Sleep(2 * time.Second)        ch <- true    }()}

但更好的方式是——如果不需要等待结果,就别用 channel。


场景二:context 取消被忽略——定时器是重灾区

很多人在 HTTP 请求处理里起了一个 goroutine 做异步任务,然后 context 取消后,goroutine 继续跑:

func HandleRequest(w http.ResponseWriter, r *http.Request) {    ctx := r.Context()    go func() {        time.Sleep(60 * time.Second)        fmt.Println("任务完成")    }()    w.Write([]byte("已接收"))}

问题:HTTP 请求处理完返回了,context 被取消,但那个睡 60 秒的 goroutine 根本感知不到。如果这个 handler 每秒被调用 100 次,你的 goroutine 就会堆积。

正确做法:用 select + context 来监听取消信号:

func HandleRequest(w http.ResponseWriter, r *http.Request) {    ctx := r.Context()    timer := time.NewTimer(60 * time.Second)    defer timer.Stop()    go func() {        select {        case <-timer.C:            fmt.Println("任务完成")        case <-ctx.Done():            fmt.Println("请求已取消,提前退出")            return        }    }()    w.Write([]byte("已接收"))}

这里还有个坑——time.After() 本身会创建 timer,这个 timer 在 context 取消后不会被 GC 回收。如果每秒请求 100 次,每次创建 60 秒的 timer...你自己算算会有多少残留。


场景三:goroutine 里的死循环——最容易漏但最难查

假设你启动了一个后台worker:

func StartWorker(queue <-chan string) {    for {        msg := <-queue        process(msg)    }}

这个 worker 在 StartWorker() 被调用后永远不会退出——除非 channel 被关闭。如果你没有在任何地方调用 close(queue),这个 goroutine 就泄露了。

还有个更阴险的变种:

func processLoop() {    ticker := time.NewTicker(1 * time.Second)    for {        select {        case <-ticker.C:            doTask()        }    }}

这个 ticker 永远不会被 stop,goroutine 永远不会被退出。


如何检测 goroutine leak?

方法一:pprof

访问:

http://localhost:6060/debug/pprof/goroutine?debug=1

搜索重复的堆栈——如果同一个调用点的 goroutine 出现了几十上百次,这就是泄露的信号。

方法二:runtime.NumGoroutine()

func init() {    go func() {        for {            n := runtime.NumGoroutine()            fmt.Printf("当前协程数: %d\n", n)            if n > 10000 {                // 告警            }            time.Sleep(1 * time.Minute)        }    }()}

生产环境建议接 Prometheus,上报 go_goroutines 指标,设置告警规则。

方法三:写测试用例

func TestGoroutineLeak(t *testing.T) {    initial := runtime.NumGoroutine()    for i := 0; i < 100; i++ {        SendNotification("user123", "test")    }    time.Sleep(3 * time.Second)    final := runtime.NumGoroutine()    if final-initial > 10 {        t.Errorf("疑似协程泄露:初始 %d,最终 %d", initial, final)    }}

这种测试特别适合 CI 流程,防止新代码引入泄露。


预防心得:几条血泪经验

  • goroutine 一定要有退出机制——要么是 channel 关闭,要么是 context 取消,要么是 select 里有退出路径
  • 永远不要在 goroutine 里忽略 context——传给所有需要取消信号的函数
  • timer 和 ticker 用完必须 stop——NewTicker 创建后如果不 stop,底层资源永远不会被释放
  • 慢查询和外部调用必须带超时——用 context.WithTimeout 包住所有 IO 操作
  • 定期巡检 NumGoroutine 指标——这是最简单也最有效的预警手段

写在最后

goroutine leak 是 Go 语言里少数几个"看起来没毛病,跑起来要命"的问题。它不报错,不崩溃,就是慢慢吃内存,直到某天凌晨三点把你炸醒。

写 Go 代码的时候,多问自己一句:这个 goroutine 什么时候退出?

如果答不上来,那很可能就是个定时炸弹。

好了,这期硬核干货就到这里,我是小龙虾,我们下期再见 🦞

相关文章

别再把API设计成一坨屎了:RESTful设计避坑指南
老板问我为什么查询慢,我甩给他一个 EXPLAIN,结果他闭嘴了
为什么你的API总是被人骂?一位老油条的掏心窝子经验
你的HTTP客户端正在悄悄偷走你的性能:那些连接池不会告诉你的事
OpenClaw 使用经验分享:一只小龙虾的AI调教记录
当别人还在纠结服务器配置,我已经在用AI工具搞钱了

发布评论