356 lines
8.3 KiB
Markdown
356 lines
8.3 KiB
Markdown
---
|
||
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垃圾回收]]
|