踩坑故事
某电商公司的订单服务,上线后运行了大约 2 小时,OOM(Out of Memory)被 K8s 杀死,进程反复重启。
运维同事看到的日志只有一行:fatal error: runtime: out of memory。
开发同事重启了几次,问题依旧。直到有经验的架构师用 pprof 看了一眼 goroutine 分布,才发现:每处理一个请求,就 leak 一个 goroutine,而且这些 goroutine 全部卡在 channel 的 send 操作上,永远无法退出。
1000 个 QPS × 2 小时 = 720 万次泄露的 goroutine,每个 goroutine 默认 2KB 栈空间,再加上分配的堆对象,最终把内存吃光了。
教训:goroutine 泄露是 Go 生产环境中最隐蔽、危害最大的并发问题之一。没有之一。
学习目标
- 理解 goroutine 的生命周期:谁启动、谁负责停止
- 理解 channel 阻塞如何导致 goroutine 泄露
- 掌握
context.WithCancel的正确用法和取消传播链 - 学会用
net/http/pprof检测 goroutine 泄露
问题描述
下面的代码是一个模拟的请求处理函数。每个请求启动一个 worker goroutine 来处理任务,并将结果发送到 channel。
但这段代码有一个 goroutine 泄露------worker goroutine 在完成工作后,试图向 resultCh 发送结果时,如果没有人读取,就会永远阻塞。
请找出泄露点并修复它。
原始代码(有 BUG)
package main
import (
"fmt"
"math/rand"
"time"
)
func simulateRequest() {
resultCh := make(chan string) // 无缓冲!
// 启动 worker goroutine 处理"请求"
go func() {
// 模拟耗时处理(300~800ms)
time.Sleep(time.Duration(rand.Intn(500)+300) * time.Millisecond)
resultCh <- fmt.Sprintf("处理完成,耗时 %dms", 300+rand.Intn(500))
// TODO: 这里会泄露------如果 resultCh 没人读,goroutine 永远卡住
}()
// TODO: 模拟请求取消或超时的情况(比如 500ms 后调用方不再关心结果)
// 如果 worker 还没完成,它就 leak 了
time.Sleep(500 * time.Millisecond)
// 这里不会收到结果,因为 goroutine 已经被阻塞在 send 上了
fmt.Println("请求方放弃等待")
}
func main() {
for i := 0; i < 5; i++ {
simulateRequest()
fmt.Printf("第 %d 个请求处理完毕\n\n", i+1)
time.Sleep(200 * time.Millisecond)
}
fmt.Println("所有请求处理完毕")
// 运行后观察:goroutine 数量只增不减
time.Sleep(2 * time.Second)
}
预期现象 :运行后 goroutine 数量持续增长,因为这些 goroutine 卡在 resultCh <- ... 上无法退出。
修复要求
使用 context.WithCancel 修复泄露问题,确保:
- worker goroutine 能感知到取消信号,及时退出
- 使用
defer cancel()确保清理 - 修复后运行,goroutine 数量保持稳定
面试追问
Q1: 如何用 pprof 检测 goroutine 泄露?
答:
// 在 HTTP 服务中启用 pprof
import _ "net/http/pprof"
然后通过以下命令检查:
# 查看 goroutine 数量
curl http://localhost:6060/debug/pprof/goroutine?debug=1
# 导出 goroutine profile 到文件
curl http://localhost:6060/debug/pprof/goroutine -o goroutine.prof
# 用 pprof 工具分析
go tool pprof goroutine.prof
生产环境中,定期采集 goroutine 数量并设置告警是必要的。
Q2: channel 阻塞的 4 种情况?
| # | 场景 | 谁阻塞 | 是否泄露 |
|---|---|---|---|
| 1 | sendCh <- val,无人接收 |
发送方 | 是,发送方 goroutine 永久阻塞 |
| 2 | <-recvCh,无人发送 |
接收方 | 是,接收方 goroutine 永久阻塞 |
| 3 | 双向 channel,两端都关闭后继续读写 | 读写方 | 是,panic 或永久阻塞 |
| 4 | select 中没有 default 分支,所有 case 都阻塞 | select 所在 goroutine | 是,goroutine 永久阻塞 |
Q3: 如何用 context 预防 goroutine 泄露?
答 :核心原则------所有从请求衍生出的 goroutine,都应该传入同一个 context,并在 context 取消时优雅退出。
func handler(ctx context.Context) {
ctx, cancel := context.WithCancel(ctx)
defer cancel() // 关键:无论什么路径退出,都调用 cancel
go func() {
defer fmt.Println("goroutine 已退出")
select {
case <-ctx.Done():
return // context 取消,优雅退出
case resultCh <- doWork():
// 发送成功,正常退出
return
}
}()
// ... 处理逻辑
}
context 取消传播链:
用户请求取消 / 超时
→ handler 的 context 被 cancel
→ 所有通过 context.WithCancel/WithTimeout 衍生出的子 context 被 cancel
→ 所有 select 中 case <-ctx.Done() 分支触发
→ 所有衍生 goroutine 退出