This repository has been archived on 2026-05-19. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
obsidian/DEV/GO/Goroutine泄漏排查.md
T

356 lines
8.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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垃圾回收]]