go 1.24+ 可用 /debug/pprof/goroutineleak 端点检测 goroutine 泄漏,需启用 godebug=goleak=1,首次访问前应调用 runtime.gc() 确保泄漏可被识别。

Go 服务内存持续上涨、CPU 调度变慢、pprof 显示成千上万 goroutine 卡在 chan receive 或 select 上?基本可以断定是 goroutine 泄漏 —— 它不报错,但比内存泄漏更难察觉、更早压垮服务。
怎么用 pprof 快速确认 goroutine 是否在泄露
pprof 的 /debug/pprof/goroutine?debug=2 是第一道筛子,但它默认只显示正在运行或阻塞的 goroutine,对“已卡死但未被 GC 回收”的泄露 goroutine 不敏感。真正有用的是 Go 1.24+ 实验性支持的 /debug/pprof/goroutineleak 端点(需启用 GODEBUG=gctrace=1,goleak=1):
- 启动时加环境变量:
GODEBUG=goleak=1 go run main.go - 访问
http://localhost:6060/debug/pprof/goroutineleak?debug=1,返回的堆栈里带_Gleaked状态的 goroutine 就是确定泄露项 - 注意:该端点仅在 GC 完成一次“泄漏感知循环”后才可用,首次请求可能为空,建议触发一次手动 GC:
runtime.GC()后再查 - 旧版本 Go(/debug/pprof/goroutine?debug=2 + 人工比对生命周期
goleak 在单元测试里为什么没报出来
goleak.VerifyNone(t) 很容易漏掉真实泄漏,不是工具不行,而是使用姿势错了:
- 它只捕获测试函数退出时“还活着”的 goroutine,如果泄漏 goroutine 在测试结束前就因 channel 关闭/超时退出了,就不会被捕获
- 常见误用:在
defer goleak.VerifyNone(t)之后又启动 goroutine 并立即return,导致 verify 执行时 goroutine 已结束 - 正确做法:确保待测逻辑完整跑完(比如加
time.Sleep(10ms)或等某个 done chan),再让 verify 检查 - 更稳的方式是用
goleak.VerifyTestMain包裹整个TestMain,覆盖所有测试用例的 goroutine 生命周期
哪些 channel 场景 100% 导致 goroutine 卡死
绝大多数 goroutine 泄漏都出在 channel 使用上,以下模式只要出现,几乎必然泄漏:
- 向一个
nil chan发送或接收:var c chan int; → 永久阻塞,<code>runtime不会 panic,goroutine 直接挂住 - 从无缓冲 channel 读,但没人写;或向无缓冲 channel 写,但没人读
- 向有缓冲 channel 写满后继续写,且缓冲区从未被消费(比如
make(chan int, 1)写两次) -
select中所有 case 都不可达,且没有default:例如所有 channel 都为nil或已关闭,就会卡死 - 忘记 close(channel),却在另一端用
for range channel死等
ssh.Dial 或 http.Client 导致的 goroutine 堆积怎么定位
这类泄漏往往不显眼,但线上最常见 —— 表现为大量 goroutine 卡在 io.ReadFull、readLoop、writeLoop 或 net.Conn.Read:
- 排查重点不是业务代码,而是连接生命周期管理:是否每次
ssh.Dial后都调用了client.Close()?是否复用了*ssh.Client但没控制并发数? - HTTP 场景下,检查
http.Transport的MaxIdleConns和MaxIdleConnsPerHost是否过小或为 0,导致连接池耗尽后新建连接不断堆积 goroutine - 用
lsof -p $PID | grep "can't identify protocol"查看句柄数是否逼近上限,间接验证连接泄漏 - 对
golang.org/x/crypto/ssh,务必确保每个*ssh.Session调用session.Wait()或session.Close(),否则底层 readLoop goroutine 不会退出
goroutine 泄漏最难的不是发现,而是确认“它到底算不算泄漏”—— 同一个阻塞状态,在调试阶段可能是正常等待,在高负载下却成了泄漏起点。所以别只看堆栈,要结合超时控制、连接池配置、channel 生命周期三者交叉验证。一旦怀疑,优先加 context.WithTimeout 包裹所有阻塞操作,这是最简单也最有效的兜底手段。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











