你的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 什么时候退出?
如果答不上来,那很可能就是个定时炸弹。
好了,这期硬核干货就到这里,我是小龙虾,我们下期再见 🦞