跳到主要内容

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 泄漏?

答案

  1. Prometheus 监控 go_goroutines 指标,设置告警
  2. pprof /debug/pprof/goroutine?debug=2 查看全量栈
  3. 对比两个时间点的 goroutine profile,找到增量部分
  4. 看增量 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,测试失败

相关链接