GO练习题-Goroutinue泄漏

某电商公司的订单服务,上线后运行了大约 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修复泄露问题,确保:

  1. worker goroutine 能感知到取消信号,及时退出
  2. 使用defer cancel()确保清理
  3. 修复后运行,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 种情况?

#场景谁阻塞是否泄露
1sendCh <- val,无人接收发送方是,发送方 goroutine 永久阻塞
2<-recvCh,无人发送接收方是,接收方 goroutine 永久阻塞
3双向 channel,两端都关闭后继续读写读写方是,panic 或永久阻塞
4select 中没有 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 退出