最直接确认 goroutine 持续增长的方式是观察 runtime.numgoroutine() 是否在无新请求时单向上升;可通过 /debug/goroutines 端点手动检查或 prometheus 长期监控,需区分瞬时高峰与真实泄露,select {} 是典型僵尸 goroutine 源头。

怎么确认 Goroutine 在持续增长
最直接的方式是看 runtime.NumGoroutine() 的返回值是否随时间单向上升。在服务稳定运行、无新请求接入的情况下,如果这个数字还在缓慢爬升,基本可以判定存在泄露。
建议在 HTTP 服务中暴露一个健康检查端点,定期打印该值:
http.HandleFunc("/debug/goroutines", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "goroutines: %d", runtime.NumGoroutine())
})
配合 curl http://localhost:8080/debug/goroutines 手动验证,或用 Prometheus + go_goroutines 指标长期观测。
- 注意区分“瞬时高峰”和“持续增长”:短时并发请求会拉高数量,但应随请求结束快速回落
- 若使用
pprof,/debug/pprof/goroutine?debug=1能看到所有 goroutine 的栈,但数据量大时不易定位——更适合做快照比对 - 某些日志库(如 zap)的异步写入器、未关闭的
context.WithCancel子 context,都可能让 goroutine 卡在 channel receive 或 timer 等待上
为什么 select {} 是常见泄露源头
写成 go func() { select {} }() 看似无害,实则是典型的“僵尸 goroutine”:它永远阻塞,无法被调度器回收,且不响应任何退出信号。
这类写法常出现在以下场景:
- 错误地把 “等待某个条件” 写成无限等待,忘了加
case - 启动后台协程时,没设计退出机制,比如轮询任务漏了
time.AfterFunc或time.Ticker.Stop() - channel 关闭后仍尝试接收(
会永久阻塞),尤其在多个 goroutine 共享同一 channel 时容易遗漏关闭逻辑
修复原则很简单:所有长期运行的 goroutine 必须有明确的退出路径,通常绑定 context.Context。
如何用 pprof 定位泄漏 goroutine 的调用栈
运行时开启 pprof 后,访问 /debug/pprof/goroutine?debug=2(注意是 debug=2,不是 1)可获取带完整栈帧的 goroutine 列表,按状态分组(running、syscall、chan receive 等)。
重点关注状态为 chan receive、select、semacquire 且栈顶函数重复出现的条目:
- 如果大量 goroutine 停在
io.ReadFull或net.Conn.Read,可能是连接未关闭或超时未设 - 停在
sync.(*Mutex).Lock且调用链含自定义 handler,说明有死锁或临界区过长 - 停在
runtime.gopark+ 自定义函数名,大概率是你代码里写了select {}或未设超时的time.Sleep
对比两个时间点的 debug=2 输出,用 diff 工具查新增栈,比单纯看总数更准。
goroutine 泄露对性能的真实影响有多大
单个 goroutine 内存开销约 2KB(初始栈),看似不大,但问题在于累积效应和调度开销:
- 当 goroutine 数量超过 10k,调度器扫描就绪队列的代价明显上升,
GOMAXPROCS高时尤其明显 - GC 周期需扫描所有 goroutine 的栈,goroutine 越多,STW 时间越长(尤其 Go 1.21+ 默认启用
GCAssist) - Linux 下每个 goroutine 对应一个用户态线程(M),虽不等于内核线程,但大量 goroutine 仍会增加
epoll_wait、信号处理等系统调用负担
最容易被忽略的是:goroutine 泄露往往伴随内存泄露(比如闭包捕获了大对象),而内存压力又反过来加剧 GC 频率,形成恶性循环。所以不能只盯着 NumGoroutine,要同步观察 heap profile 和 allocs profile。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











