症状
泄漏以运行时剖析里单调递增的计数器自我宣告:goroutine_count 随请求量线性上涨,负载结束后也不回落。真正的诊断在按等待状态分组之后:pprof 的 debug=1 输出显示 6800 个 goroutine 停在同一个 chan receive 上长达 9 分钟,全部位于同一个函数——该函数每请求泄漏一个 goroutine,因为它等待的 channel 永远没人发送也永远没人关闭。每个停泊的 goroutine 都钉住自己的栈与捕获的所有变量,RSS 因此同步攀升。
诊断思路
先画 goroutine 计数随时间的曲线,区分“随请求线性增长且负载后不回落”(泄漏)与“到平台期后回到基线”(稳态并发);再按栈分组统计,数千个共享同一阻塞栈就是泄漏签名,栈会点名等待原语与拥有它的代码;最后用 goleak 把泄漏变成确定性测试失败。常见成因:goroutine 等的 channel 无人发送也无人关闭、select 里有 ctx.Done() 但所有者从不 cancel、生产者停止投喂却不 close 队列、HTTP 响应体不关闭让读循环 goroutine 苟活。
根因
永久阻塞在 receive 或 select 上的 goroutine 永远不会被垃圾回收——阻塞中的 goroutine 从调度器可达。每个都钉住自己的栈加捕获的全部对象,于是错过停机信号的每请求 goroutine 无限累积内存。
修复与验证
保证退出信号必达:context 的所有者必须 defer cancel();select 循环必须包含 ctx.Done() 分支;生产者职责是 defer close(out) 让消费者的 range 循环自然退出;每个循环都有一条可达的终止路径。用 goleak.VerifyTestMain 挂在包级测试上守住不变量。验证时驱动固定请求数,断言负载结束后数秒内 goroutine 计数回到加载前基线。取消要协作式进行——让 goroutine 完成当前工作单元再返回,强制中途退出会丢在途工作。
代码示例
$ curl -s http://localhost:6060/debug/pprof/
goroutine_count: 8421
$ curl -s "http://localhost:6060/debug/pprof/goroutine?debug=1" | head -40
# ... 6800 @ 0x45b7c1 0x44d5c2
# chan receive, 9 minutes
# main.(*Worker).listen(...)
# /app/src/worker.go:87
func (w *Worker) listen(ctx context.Context) {
defer close(w.done)
for {
select {
case <-ctx.Done():
return // reachable exit: owner cancels
case job := <-w.queue:
w.process(job)
}
}
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // the owner responsibility — missing defer is the leak
func produce(out chan<- Job) {
defer close(out) // consumers exit their range loop
for _, j := range jobs {
out <- j
}
}
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}