goroutine 栈是如何“自动扩容”的?
前言
goroutine 初始栈很小(≈2KB),但可以自动变大。
那它是怎么做到的?
一、先说结论
goroutine 的栈扩容是通过:
在函数调用前做“栈空间检查”,如果不够,就调用 runtime 进行扩容。
关键机制是:
stack guard + morestack
二、goroutine 栈和线程栈的根本不同
线程栈
- 创建时一次性分配(例如 1MB)
- 固定大小
- 不会自动扩容
goroutine 栈
- 初始 2KB
- 连续内存
- 可以复制到更大的内存块
- 原栈释放
本质是:
不够就“整体搬家”
三、栈扩容是如何触发的?
Go 在每个函数调用前都会插入一段检查代码。
类似(伪代码):
CMP SP, stackGuard
JLS morestack
意思是:
当前栈指针 SP 是否快碰到 guard 线?
如果栈空间不够:
跳转到 morestack
这就是触发扩容的入口。
四、stack guard 是什么?
每个 goroutine 结构体里有:
g.stack.lo // 栈底
g.stack.hi // 栈顶
g.stackguard
stackguard 是一个阈值。
当:
SP <= stackguard
说明栈快用完了。
就必须扩容。
五、morestack 做了什么?
这是扩容的核心函数。
大致流程:
① 计算需要多大栈
通常策略是:
新栈大小 = 旧栈 × 2
例如:
2KB → 4KB → 8KB → 16KB → …
指数增长。
② 分配一块更大的连续内存
在堆上分配新的栈空间。
③ 把旧栈内容复制到新栈
这是关键步骤。
因为 goroutine 栈是“连续栈”,所以:
整块内存直接 memcpy 过去。
④ 修正指针
复制后要修正:
- 栈内的指针
- frame pointer
- defer 链
- panic 结构
- GC 元数据
因为栈地址变了。
⑤ 更新 g.stack
把 goroutine 的栈地址更新为新栈。
⑥ 继续执行原函数
扩容完成后,函数继续执行。
对程序来说是“透明的”。
六、为什么 Go 现在用“连续栈”?
早期 Go 用的是:
分段栈(segmented stack)
每次不够就链接一个新栈段。
像这样:
[segment1] -> [segment2] -> [segment3]
问题:
- 每次函数调用都要检查段边界
- 性能开销大
- 复杂
后来改成:
连续栈 + 复制扩容
好处:
- 调用更快
- 栈布局简单
- GC 更容易扫描
这是现代 Go 的设计。
七、栈什么时候会缩小?
扩容是自动的。
缩小也会发生,但不是立刻。
当 goroutine 进入安全点(比如 GC)时:
- 如果发现栈用量远小于当前大小
- runtime 可能会缩小栈
但缩小不是频繁操作。
八、一个形象类比
想象:
你租了一个 2 平米的小房间。
东西放不下了怎么办?
不是再租一个小房间接起来。
而是:
直接搬去 4 平米的房子。
再不够:
搬去 8 平米。
每次整体搬家。
九、为什么复制栈不会出问题?
因为 Go 有两个保证:
- 栈内指针可追踪(编译器知道哪些是指针)
- GC 是精确 GC(精确知道指针位置)
所以可以安全更新。
十、栈扩容和抢占的关系
有个很有意思的点:
抢占机制也复用了 stack guard。
当:
g.preempt = true
runtime 会故意把 stackguard 改成特殊值。
让下次函数调用时:
直接进入 morestack
然后在 morestack 里判断:
哦,这是抢占,不是扩容。
于是进入调度器。
这是一种“借道实现”。
十一、栈扩容的成本高吗?
扩容是:
- 罕见操作
- 指数增长
- 很少发生很多次
例如:
一个 goroutine 可能一生只扩容 2~3 次。
所以总体成本可接受。
十二、终极总结
goroutine 自动扩容的机制是:
函数调用前做栈检查
→ 不够则调用 morestack
→ 分配更大连续栈
→ 复制旧栈
→ 修正指针
→ 继续执行
核心技术点:
- stack guard
- morestack
- 连续栈复制
- 指针修正

浙公网安备 33010602011771号