15.2 goroutine 与栈管理

一句话

goroutine 的关键不是 “轻量线程” 这个比喻,而是用户态栈可以按需增长:初始只有 2KB,不够就把整个栈复制到更大的位置并修正指针——代价是复制,收益是百万级并发。

栈的一生

阶段 大小 动作
创建 2KB 偷懒到极致,只够跑一个简单调用
增长 翻倍(4KB → 8KB …) 分配新栈、复制旧内容、调整所有指向栈的指针、释放旧栈
上限 1GB(64 位,默认) 超过即 stack overflow 崩溃
收缩 GC 时 栈过大且利用率低会缩回

栈复制的成本与栈大小成正比,所以 “在热路径里创建超大局部变量” 会让每次增长都变贵。栈太大还会直接逃逸到堆(编译器判断)。

与线程栈的对比

维度 goroutine OS 线程
初始栈 2KB 1–8MB(预留)
增长方式 复制(连续栈) 固定或 guard page
切换成本 用户态 ~百 ns 内核态 1–3μs + cache/TLB
数量级 十万–百万 千–万

观测

想看什么 手段
goroutine 数量 runtime.NumGoroutine()、/sched/goroutines:goroutines
谁在泄漏 pprof goroutine profile,按栈聚合(见 15.5 pprof (CPU / 内存 / 阻塞 / 互斥))
栈大小分布 runtime/metrics 相关指标

落到数字:栈的成长与成本

项 量级 说明
初始栈 2KB 约 100 万 goroutine ≈ 2GB
增长方式 翻倍(4KB、8KB…) 每次增长复制全部栈帧
栈上限 1GB(64 位默认) 递归过深即崩溃
单次栈复制 微秒级(栈越大越慢) 热路径大局部变量会放大它
go tool pprof -top -sample_index=goroutine http://localhost:6060/debug/pprof/goroutine
go tool pprof -list 'pkg.Func' ./app cpu.prof     # 看某函数是否触发了栈增长/逃逸

反模式

反模式 后果
每次请求 go func() 无退出条件 goroutine 泄漏,内存线性上涨
深递归 反复栈复制,最坏 stack overflow
在 goroutine 里 panic 不 recover 整个进程崩溃(不是 “只挂一个协程”)
用无缓冲 channel 做 “同步点” 却忘了对方可能不读 永久阻塞

三条铁律

🔑 goroutine 不是免费的:每个至少 2KB + 调度开销,泄漏比线程泄漏更隐蔽。

🔑 栈复制是 “指针调整”,所以栈上的指针不能被外部长期持有(逃逸分析会处理)。

🔑 panic 会杀进程:goroutine 里的 panic 必须 recover,或确保不会发生。

自检

Q1 为什么 goroutine 的初始栈只有 2KB 却不影响功能?

▸ 答案:因为栈是可增长的连续栈:函数调用需要的空间超过当前栈时,运行时会分配一块更大的栈、复制内容并修正指针。初始小只是为了让 “创建 10 万个 goroutine” 的内存成本可接受(2KB × 10 万 = 200MB,而不是 8MB × 10 万)。

Q2 栈增长为什么需要 “调整指针”,而不是简单扩容?

▸ 答案:因为栈上可能存有指向栈内其他位置的指针。栈地址变化后,这些指针必须同步改写,否则会指向旧地址。Go 的连续栈方案在复制时遍历栈帧修正指针,这是 “栈复制比堆分配贵” 的原因。

Q3 一个函数里创建 1MB 的局部数组,会发生什么?

▸ 答案:通常直接分配到堆(编译器判断栈放不下,触发逃逸),而不是先栈后复制。结果是每次调用都产生一次大分配,GC 压力上升。正确做法是用 sync.Pool 复用、或改成流式处理(见 15.3 内存分配与逃逸分析)。

Q4 如何确认 goroutine 泄漏,而不是 “正常波动”?

▸ 答案:看趋势:在稳定负载下,goroutine 数应随 QPS 波动并回落;若长时间单调上升、且压测停止后不下降,就是泄漏。再抓两份 goroutine profile 做 diff,能直接看到 “哪一行创建后一直阻塞”。

Q5 goroutine 里的 panic 会怎样?怎么防?

▸ 答案:会终止整个进程(除非被 recover)。所以:① 每个由外部输入驱动的 goroutine(请求、消息消费)都要 defer recover;② 用 errgroup/中间件统一兜底;③ 单测里加 -race 与 panic 注入,验证兜底生效。

Q6 为什么 “把大数组声明在函数里” 会让延迟抖动,而不只是变慢?

▸ 答案:因为大局部变量会触发栈增长(复制)或直接逃逸到堆:前者每次调用都要复制栈,后者增加分配与 GC。两者都把 “稳定的微秒级调用” 变成 “偶发的毫秒级抖动”,在 P99 上尤其明显。

关联