--- tags: [go, golang, runtime, goroutine, 泄漏, 并发, channel, waitgroup] create time: 2026-04-24 10:00 --- # Goroutine 泄漏排查 ## 概述 Goroutine 泄漏是指 goroutine 被创建后,由于代码逻辑问题,**永远无法结束**,导致其占用的栈内存和其他资源无法回收。Goroutine 是泄漏检测的"隐形杀手"——它不会像进程那样消耗 PID,也不会像内存泄漏那样有明显的 `Out Of Memory` 错误,而是表现为 `runtime.NumGoroutine()` 持续增长。 > **思考**:如果一个 goroutine 泄漏了,它会一直占用内存吗?goroutine 初始栈只有 2KB,泄漏 10 万个 goroutine 会占用多少内存? ## 泄漏场景与排查 ### 场景 1:向无人读的 channel 写 ```go func producer(ch chan<- int) { for i := 0; ; i++ { ch <- i // 如果没有接收方,goroutine 永远阻塞 } } func main() { ch := make(chan int) go producer(ch) // 泄漏!没有人从 ch 读取 time.Sleep(1 * time.Second) fmt.Println("goroutines:", runtime.NumGoroutine()) // 至少 2 个 } ``` **修复方式**:确保有对应的接收方,或者在适当的时候关闭 channel。 ```go func main() { ch := make(chan int) go func() { for i := 0; i < 10; i++ { ch <- i // 发送有限数量的数据 } close(ch) // 发送完毕后关闭 }() // 用 range 消费,channel 关闭后自动退出 for v := range ch { fmt.Println(v) } } ``` ### 场景 2:读无人写的 channel ```go func main() { ch := make(chan int) val := <-ch // 永远阻塞,goroutine 泄漏 fmt.Println(val) } ``` **修复方式**:确保有发送方,或者用 `select` 加超时。 ```go func main() { ch := make(chan int) select { case val := <-ch: fmt.Println(val) case <-time.After(5 * time.Second): fmt.Println("timeout, no data received") } } ``` ### 场景 3:读写 nil channel ```go func main() { var ch chan int // nil // 以下任何一行都会导致永久阻塞 // ch <- 1 // 向 nil channel 发送 → 永远阻塞 // <-ch // 从 nil channel 接收 → 永远阻塞 // close(ch) // 关闭 nil channel → panic } ``` **修复方式**:在使用前确保 channel 已初始化。 ```go func main() { var ch chan int if someCondition { ch = make(chan int) } select { case ch <- 1: fmt.Println("sent") default: fmt.Println("channel not initialized, skipped") } } ``` ### 场景 4:WaitGroup 计数错误 ```go func main() { var wg sync.WaitGroup // ❌ 错误:在 goroutine 外部调用 wg.Add(1) 是正确的 // 但在 goroutine 内部调用 Add(1) 会导致 panic // 或者忘记调用 wg.Done() wg.Add(1) go func() { defer wg.Done() // 如果 goroutine 提前 return 忘记调用 Done → 泄漏 for { // 没有退出条件 → goroutine 永远不会结束 } }() wg.Wait() // 永远等待 } ``` **修复方式**:确保每个 `Add(1)` 都有对应的 `Done()`,且 goroutine 有明确的退出条件。 ```go func main() { var wg sync.WaitGroup ctx, cancel := context.WithCancel(context.Background()) defer cancel() wg.Add(1) go func() { defer wg.Done() for { select { case <-ctx.Done(): return // 有退出条件 default: // 正常处理 } } }() time.Sleep(1 * time.Second) cancel() // 触发退出 wg.Wait() } ``` ### 场景 5:sync.Mutex 拿锁未释放 ```go var mu sync.Mutex func leaky() { mu.Lock() // 如果这里 panic 或者永久阻塞 // mu.Unlock() 永远不会执行 → 所有等待 mu.Lock() 的 goroutine 泄漏 panic("boom") // mu 永远不会被释放 } func main() { go leaky() // 拿锁后 panic,锁不释放 go func() { mu.Lock() // 永远等不到锁 → goroutine 泄漏 mu.Unlock() }() time.Sleep(1 * time.Second) fmt.Println(runtime.NumGoroutine()) // 持续增加 } ``` **修复方式**:确保在任何路径下都能释放锁。 ```go func safe() { mu.Lock() defer mu.Unlock() // 即使 panic,defer 也会执行 panic("boom") } ``` ### 场景 6:select 中没有默认分支 ```go func main() { ch := make(chan int) // 如果 ch 没有数据,也没有其他 case 能就绪 // goroutine 会永远阻塞在 select 上 select { case <-ch: fmt.Println("received") // 缺少 default 和 time.After → 永远等待 } } ``` ## 泄漏排查工具 ### 1. pprof goroutine profile ```bash # 方法一:导入 net/http/pprof import _ "net/http/pprof" # 方法二:使用 runtime/pprof import ( "runtime" "runtime/pprof" "os" ) func dumpGoroutines() { f, _ := os.Create("goroutine.prof") pprof.WriteProfile(f) f.Close() } ``` ```bash # 分析 goroutine 泄漏 go tool pprof goroutine.prof # 进入交互界面后执行: top # 查看最顶层的 goroutine top -cum # 按累积时间排序 list main.leaky # 查看具体函数的 goroutine 阻塞位置 ``` ### 2. 监控 NumGoroutine ```go import ( "runtime" "time" ) func monitorGoroutines() { ticker := time.NewTicker(5 * time.Second) defer ticker.Stop() for range ticker.C { n := runtime.NumGoroutine() if n > 100 { // 设定阈值 fmt.Printf("⚠️ High goroutine count: %d\n", n) // dump stack buf := make([]byte, 1<<20) n := runtime.Stack(buf, true) fmt.Printf("Goroutine stacks:\n%s\n", buf[:n]) } } } ``` ### 3. pprof 常用命令速查 ```bash # 启动 web UI(推荐) go tool pprof -http=:8080 http://localhost:6060/debug/pprof/goroutine?debug=1 # 文本模式 go tool pprof -text goroutine.prof # 查看具体函数阻塞位置 go tool pprof -list=main.worker goroutine.prof ``` ## 预防最佳实践 | 实践 | 说明 | |------|------| | **每个 goroutine 必须有退出条件** | 用 `context.Context` 控制生命周期 | | **Channel 用完必须关闭** | 发送方负责关闭,防止接收方永远等待 | | **WaitGroup 配对使用** | `Add` 和 `Done` 一一对应,用 `defer` 确保 `Done` 执行 | | **Mutex 用 defer 释放** | `defer mu.Unlock()` 防止 panic 导致锁不释放 | | **select 加超时** | 永远不要 `select` 中只有阻塞 channel 而无 `default`/`timeout` | | **启动时加监控** | 定期采样 `runtime.NumGoroutine()`,异常时 dump stack | | **context 传递取消信号** | 让所有 goroutine 都能响应取消 | ```go // ✅ 推荐的 goroutine 模板 func worker(ctx context.Context, wg *sync.WaitGroup) { defer wg.Done() for { select { case <-ctx.Done(): return // 正常退出 default: // 执行任务 } } } func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() var wg sync.WaitGroup for i := 0; i < 10; i++ { wg.Add(1) go worker(ctx, &wg) } wg.Wait() } ``` ## 排查流程图 ```mermaid flowchart TD A["发现 goroutine 持续增长"] --> B{"NumGoroutine 持续增长?"} B -->|"是"| C["go tool pprof goroutine profile"] C --> D["top / top -cum 查看类型分布"] D --> E{"泄漏的 goroutine 类型?"} E -->|"chan send / chan receive"| F["检查 channel 发送方/接收方配对"] F --> G["确保有对应的读取/写入"] G --> H["使用 select + timeout 防止永久等待"] E -->|"sync.Mutex lock"| I["检查 mutex 是否被正确释放"] I --> J["所有路径都使用 defer Unlock"] E -->|"sleep / IO"| K["检查是否有阻塞的 IO 操作"] K --> L["确保有超时和取消机制"] E -->|"goroutine 数量少且稳定"| M["可能是正常的工作 goroutine"] M --> N["确认是否为预期行为"] B -->|"否"| O["没有泄漏,goroutine 数量正常"] classDef leak fill:#e53935,color:#fff classDef fix fill:#43a047,color:#fff classDef normal fill:#1e88e5,color:#fff class C,D,F,I,K,M,O normal class G,H,J,L fix class A leak ``` ## 关联笔记 - [[DEV/GO/GMP调度模型]] - [[DEV/GO/Channel详解]] - [[DEV/GO/GC垃圾回收]]