Go goroutine泄漏分析

举报
yd_298312457 发表于 2026/11/04 16:33:58 2026/11/04
【摘要】 goroutine 数量一直涨,却找不到谁在泄漏?试试 Go 1.27 的 goroutineleak profile服务跑了一阵,runtime.NumGoroutine() 从一个稳定区间慢慢往上走。请求量已经回落,数字却没跟着降。先看普通的 goroutine profile,里面既有等待请求的工作协程,也有定时器和连接池;找到一批卡在 channel 上的栈,也不能立刻断言它们永远不...

goroutine 数量一直涨,却找不到谁在泄漏?试试 Go 1.27 的 goroutineleak profile

服务跑了一阵,runtime.NumGoroutine() 从一个稳定区间慢慢往上走。请求量已经回落,数字却没跟着降。先看普通的 goroutine profile,里面既有等待请求的工作协程,也有定时器和连接池;找到一批卡在 channel 上的栈,也不能立刻断言它们永远不会醒。问题就在这里。栈快照回答了谁在等,我们还想知道谁已经没有机会等到结果。

Go 1.27 正式提供的 goroutineleak profile 正好回答后一类问题。先别急着把它接到服务里,下面用一个刻意制造的阻塞观察它的行为。这个示例只依赖标准库,需用 Go 1.27 构建运行。本文没有在当前环境实际运行示例,后文涉及 profile 内容和数量变化的描述均为预期现象。

先造一个会留下来的等待者

package main


import (

"fmt"

"log"

"net/http"

_ "net/http/pprof"

"runtime"

"time"

)


func leakOne() {

ch := make(chan struct{})

ready := make(chan struct{})

go func() {

close(ready)

<-ch // 没有人会向 ch 发送数据或关闭它

}()

<-ready

}


func main() {

go func() {

log.Fatal(http.ListenAndServe("127.0.0.1:6060", nil))

}()

ticker := time.NewTicker(200 * time.Millisecond)

defer ticker.Stop()

n := 0

for range ticker.C {

leakOne()

n++

runtime.GC() // 缩短演示中等待可达性检查的时间

if n%10 == 0 {

fmt.Printf("created=%d goroutines=%d\n", n, runtime.NumGoroutine())

}

}

}

把代码存为 main.go,执行 go run main.go。每次滴答都会启动一个新 goroutine。ready 只保证它已经开始执行,不负责解除对 ch 的等待。leakOne 返回后,调用方不再持有 ch;新协程仍阻塞在接收操作上。运行一会儿,终端预期会看到 created 和 goroutines 继续增加,具体数值受调度、HTTP 服务和运行时后台协程影响,不能把两列机械地相减。

这里特意每轮调用 runtime.GC(),是为了让演示更快出现结果,不是说线上服务必须这样做。Go 1.27 的发布说明指出,泄漏判断借助垃圾回收器的可达性信息。触发一次 GC 后再看 profile,比刚创建协程就立刻抓取更稳妥。

抓取以后先看什么

示例导入 net/http/pprof,它会注册 /debug/pprof/ 下的处理器。另开一个终端执行下面的命令,得到可交给 pprof 分析的文件,再看调用栈聚合结果。

curl -o leak.pb.gz http://127.0.0.1:6060/debug/pprof/goroutineleak

go tool pprof -top leak.pb.gz

预期结果中,main.leakOne.func1 附近会出现样本,数量会随多轮创建而增加。不同构建和抓取时刻的函数名、行号及样本数可能不同,这里不伪造一段固定输出。-top 的价值是把同一阻塞位置聚在一起,告诉我们优先回到哪段代码;接着看 leakOne 中那次 <-ch,才能解释为什么它不可能完成。若想核对正在等待的全部 goroutine,可以同时抓普通 /debug/pprof/goroutine profile。两者回答的问题不同,后者包含正常等待的协程。

读 -top 时别把第一行当成“罪魁祸首”。某个函数名出现在栈里,通常只说明这批协程停在那条调用路径上。需要顺着调用栈往上找,弄清谁创建了任务,谁承诺提供数据,谁可能提前返回。在示例里,关键事实是 leakOne 返回后再没有发送者;换成服务代码,可能是请求取消以后生产者停了,消费者却还在等。profile 帮你缩小搜索范围,代码中的所有权关系仍要自己核实。

真正排查服务时,我会先把普通 goroutine 数量的时间趋势和业务负载放在一起看。如果负载回落以后数量还持续抬升,就在同一时段取 goroutineleak profile,隔一段时间再取一次,比较哪些阻塞栈持续累积。取样间隔要覆盖任务的正常完成时间。一个需要等几十秒的任务,在刚启动时看起来也是“没结束”;只拿某一秒的 goroutine 总数下判断,容易把正常排队认成泄漏。找到栈以后回到上游代码,检查 channel 的发送者、关闭者和退出路径。只有栈名,没有创建和退出逻辑,仍不足以说明修法。

这个例子里,修复思路很直接。接收方需要一个能结束等待的条件,例如由拥有者关闭 channel,或者在 select 中同时等待取消信号。实际服务还要确认谁负责发出取消、何时发出,以及正常路径会不会误停任务。把无缓冲 channel 改成有缓冲 channel,可能让某次发送不再卡住,却未必能解决所有等待者的生命周期问题。

为什么它能判断“等不到”

Go 1.27 官方发布说明给出的定义相当严格。一个 goroutine 阻塞在 channel、sync.Mutex、sync.Cond 等并发原语上,且该原语不可能再使它解除阻塞,才属于这里讨论的泄漏。运行时借助 GC 判断可达性。如果某个被阻塞协程等待的原语,从任何可运行 goroutine,或这些 goroutine 能唤醒的其他 goroutine 出发都不可达,便没有执行者能去改变它的状态。示例中的 ch 只剩阻塞接收者持有,所以符合这类情况。

这也说明为什么 goroutineleak profile 和普通栈快照不能互相替代。栈快照能告诉你一个 goroutine 此刻停在 <-ch,却不知道发送者是不是稍后就会运行。泄漏 profile 多利用了可达性关系,试图筛出已经没有唤醒路径的一部分等待者。Go 1.26 时它还是需在构建时设置 GOEXPERIMENT=goroutineleakprofile 的实验功能;Go 1.27 已将 profile 设为正式可用,runtime/pprof 中名为 goroutineleak,net/http/pprof 提供对应端点,旧的实验开关也已删除。

看不到时别急着排除泄漏

官方同时写明,这种方法不可能识别所有永久阻塞。最容易误判的一种情况,是等待的 channel 或锁仍能从全局变量到达,或被某个可运行 goroutine 的局部变量持有。代码逻辑也许永远不会再发送或解锁,但可达性检查只能看到“还有人有机会碰到它”,无法证明那个人将来一定不碰。比如一个全局任务队列还保存着 channel 的引用,负责投递结果的路径却早已停用。人知道业务条件已经变了,运行时只能依据对象引用作判断。此时普通 goroutine profile 可能堆满相同栈,goroutineleak 却没有把它们列出来。

还有一些数量增长根本不符合它的检测对象。协程可能一直在计算、循环重试、等待外部 I/O,或长期执行一个没有退出条件的任务。仅凭 goroutine 数量上涨和泄漏 profile 为空,不能给它们判无罪。继续看普通栈、任务创建率和完成率、取消信号是否传递,以及等待外部系统的超时设置,才知道它们卡在哪里。

使用 HTTP 端点时,也要沿用 pprof 的基本谨慎。上面的示例只绑定 127.0.0.1,避免把调试接口直接暴露出去。线上抓取则应放在已有的访问控制之内,并留意 profile 可能包含函数名和调用路径。抓取文件最好连同构建版本和采样时间一起保存,否则几天后再对照代码,行号和部署版本可能已经对不上。

如果一次抓取没有样本,先确认运行的确实是 Go 1.27 构建的程序,再等下一次 GC 和阻塞状态形成后重试。它能给出一批很有把握的线索,但空结果并不等于没有泄漏。最后仍要回到那条没有完成条件的代码路径,把退出机制补上。

参考资料

1. Go 1.27 Release Notes,Goroutine leak profile,https://go.dev/doc/go1.27#goroutineleak-profiles

2. Go 1.26 Release Notes,Experimental goroutine leak profile,https://go.dev/doc/go1.26#goroutineleak-profiles

【版权声明】本文为华为云社区用户原创内容,未经允许不得转载,如需转载请自行联系原作者进行授权。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。