8.3 KiB
8.3 KiB
tags, create time
| tags | create time | ||||||||
|---|---|---|---|---|---|---|---|---|---|
|
2026-04-24 10:00 |
Goroutine 泄漏排查
概述
Goroutine 泄漏是指 goroutine 被创建后,由于代码逻辑问题,永远无法结束,导致其占用的栈内存和其他资源无法回收。Goroutine 是泄漏检测的"隐形杀手"——它不会像进程那样消耗 PID,也不会像内存泄漏那样有明显的 Out Of Memory 错误,而是表现为 runtime.NumGoroutine() 持续增长。
思考:如果一个 goroutine 泄漏了,它会一直占用内存吗?goroutine 初始栈只有 2KB,泄漏 10 万个 goroutine 会占用多少内存?
泄漏场景与排查
场景 1:向无人读的 channel 写
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。
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
func main() {
ch := make(chan int)
val := <-ch // 永远阻塞,goroutine 泄漏
fmt.Println(val)
}
修复方式:确保有发送方,或者用 select 加超时。
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
func main() {
var ch chan int // nil
// 以下任何一行都会导致永久阻塞
// ch <- 1 // 向 nil channel 发送 → 永远阻塞
// <-ch // 从 nil channel 接收 → 永远阻塞
// close(ch) // 关闭 nil channel → panic
}
修复方式:在使用前确保 channel 已初始化。
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 计数错误
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 有明确的退出条件。
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 拿锁未释放
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()) // 持续增加
}
修复方式:确保在任何路径下都能释放锁。
func safe() {
mu.Lock()
defer mu.Unlock() // 即使 panic,defer 也会执行
panic("boom")
}
场景 6:select 中没有默认分支
func main() {
ch := make(chan int)
// 如果 ch 没有数据,也没有其他 case 能就绪
// goroutine 会永远阻塞在 select 上
select {
case <-ch:
fmt.Println("received")
// 缺少 default 和 time.After → 永远等待
}
}
泄漏排查工具
1. pprof goroutine profile
# 方法一:导入 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()
}
# 分析 goroutine 泄漏
go tool pprof goroutine.prof
# 进入交互界面后执行:
top # 查看最顶层的 goroutine
top -cum # 按累积时间排序
list main.leaky # 查看具体函数的 goroutine 阻塞位置
2. 监控 NumGoroutine
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 常用命令速查
# 启动 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 都能响应取消 |
// ✅ 推荐的 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()
}
排查流程图
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