Goroutine 泄漏排查
问题
Go 服务 Goroutine 数量持续增长不回落,如何排查和解决 Goroutine 泄漏?
答案
什么是 Goroutine 泄漏
Goroutine 被创建后因某种原因无法退出(阻塞在 Channel、锁、IO 等),导致 Goroutine 数量只增不减,最终耗尽内存。
检测手段
1. runtime 监控
// 定时打印 goroutine 数量
go func() {
ticker := time.NewTicker(10 * time.Second)
defer ticker.Stop()
for range ticker.C {
log.Printf("Goroutine count: %d", runtime.NumGoroutine())
}
}()
2. Prometheus 指标
// Prometheus 自带 go_goroutines 指标
// 告警规则
// alert: GoroutineLeak
// expr: go_goroutines > 10000
// for: 10m
3. pprof 分析
# 查看当前所有 goroutine 的调用栈
go tool pprof http://localhost:6060/debug/pprof/goroutine
(pprof) top 20 # 哪些函数创建了最多 goroutine
(pprof) traces # 完整调用栈
(pprof) web # 可视化
# 也可以直接在浏览器查看(全量 goroutine 栈)
# http://localhost:6060/debug/pprof/goroutine?debug=2
4. 单元测试检测(goleak)
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
func TestNoLeak(t *testing.T) {
defer goleak.VerifyNone(t) // 测试结束后检查是否有泄漏的 goroutine
// ...测试代码
}
常见泄漏场景与修复
场景 1:向无人接收的 Channel 发送
// ❌ 泄漏:超时后 goroutine 永远阻塞在 ch<-
func request(ctx context.Context) (string, error) {
ch := make(chan string)
go func() {
result := slowOperation()
ch <- result // 如果外部超时返回了,没人读 ch → 永远阻塞
}()
select {
case r := <-ch:
return r, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
// ✅ 修复:用带缓冲的 channel
func request(ctx context.Context) (string, error) {
ch := make(chan string, 1) // 写入不阻塞
go func() {
result := slowOperation()
ch <- result
}()
select {
case r := <-ch:
return r, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
场景 2:从无人发送的 Channel 接收
// ❌ 泄漏:生产者提前退出,消费者永远等待
func consume(ch <-chan int) {
for v := range ch {
process(v)
} // 如果 ch 永远不 close,这里永远不退出
}
// ✅ 修复:配合 context
func consume(ctx context.Context, ch <-chan int) {
for {
select {
case v, ok := <-ch:
if !ok {
return
}
process(v)
case <-ctx.Done():
return
}
}
}
场景 3:nil Channel
// ❌ 泄漏:对 nil channel 发送或接收永远阻塞
var ch chan int // nil
go func() {
ch <- 1 // 永远阻塞!
}()
// ✅ 确保 channel 已初始化
ch := make(chan int, 1)
场景 4:HTTP 请求无超时
// ❌ 泄漏:如果服务端不响应,goroutine 永远等待
go func() {
resp, err := http.Get("https://slow-server.com/api")
// ...
}()
// ✅ 修复:设置超时
client := &http.Client{
Timeout: 10 * time.Second,
}
go func() {
resp, err := client.Get("https://slow-server.com/api")
if err != nil {
return
}
defer resp.Body.Close()
// ...
}()
场景 5:WaitGroup 计数错误
// ❌ 泄漏:wg.Add 和 wg.Done 不匹配
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
if err := doWork(); err != nil {
return // 忘了 wg.Done()!
}
wg.Done()
}()
}
wg.Wait() // 永远等下去
// ✅ 修复:defer wg.Done()
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if err := doWork(); err != nil {
return // defer 保证一定执行
}
}()
}
场景 6:无退出条件的死循环
// ❌ 泄漏:即使不需要了,goroutine 仍在运行
go func() {
for {
doPeriodicWork()
time.Sleep(time.Second)
}
}()
// ✅ 修复:监听 context
go func(ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-ticker.C:
doPeriodicWork()
case <-ctx.Done():
return
}
}
}(ctx)
防泄漏最佳实践
| 原则 | 做法 |
|---|---|
| 谁创建谁负责 | 启动 goroutine 的函数负责确保能关闭 |
| 总有退出路径 | 每个 goroutine 都必须有 context 取消或 channel 关闭的退出条件 |
| defer 关闭 | defer wg.Done()、defer ticker.Stop()、defer resp.Body.Close() |
| 带缓冲 Channel | 异步发送结果时用 make(chan T, 1) |
| 网络超时 | HTTP Client、gRPC dial 必须设 timeout |
| goleak 测试 | CI 中用 goleak 自动检测 |
常见面试问题
Q1: Goroutine 泄漏和内存泄漏是什么关系?
答案:
- Goroutine 泄漏是 Go 中最常见的内存泄漏原因
- 每个 goroutine 至少占 2KB 栈空间,且其引用的所有对象都无法被 GC 回收
- 10 万个泄漏的 goroutine 至少浪费 200MB+
Q2: 如何在线上安全检测 Goroutine 泄漏?
答案:
- Prometheus 监控
go_goroutines指标,设置告警 - pprof
/debug/pprof/goroutine?debug=2查看全量栈 - 对比两个时间点的 goroutine profile,找到增量部分
- 看增量 goroutine 的 stack trace,定位阻塞点
Q3: context.Context 如何防止 Goroutine 泄漏?
答案:
- 给每个 goroutine 传入
context.Context - goroutine 内部
select监听ctx.Done() - 父函数退出或超时时调用
cancel(),子 goroutine 收到信号退出 - 这是 Go 中级联取消的标准模式
Q4: goleak 库的原理是什么?
答案:
- 测试前记录当前所有 goroutine 的快照
- 测试后再次获取 goroutine 列表
- 对比差异,过滤掉已知的系统 goroutine(runtime、testing 等)
- 如果有新增且未退出的 goroutine,测试失败