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 上尤其明显。
关联
- 前置:15.1 GMP 调度模型
- 并列:15.3 内存分配与逃逸分析、15.6 并发原语 (channel / context / sync)
- 下游:15.5 pprof (CPU / 内存 / 阻塞 / 互斥)、11.1 进程、线程与调度