(1/26)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 种情况?

# 场景 谁阻塞 是否泄露
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 退出