Go栈扩容是函数调用前触发的整栈复制搬迁,非实时拼接;通过stackguard0预判、runtime.morestack入口、memcpy旧栈并重定位指针实现,代价明确且仅在调用边界发生。

Go 的栈扩容不是“慢慢加内存”,而是函数调用前触发的一次整栈复制搬迁。 它不依赖你写什么代码来“启动”,也不在循环里实时响应;一旦触发,就申请新内存、memcpy 旧内容、重定位指针、释放旧栈——整个过程对用户透明,但代价明确。
runtime.morestack 是栈扩容的唯一入口点
编译器会在每个可能消耗较多栈空间的函数入口自动插入 CALL runtime.morestack 指令(可用 go tool compile -S main.go | grep morestack 查看)。它不是你手动调的函数,也不是 runtime 导出的 API;它是汇编层硬编码的检查跳转点。
- 触发条件是:当前栈指针
SP落入stackguard0区域(约 8KB 安全缓冲区)之下,而非等栈真正“爆掉” - 只在函数调用边界发生,不会在 for 循环中间、大数组分配时、或 defer 执行中动态判断
- 如果函数被标记为
//go:nosplit,则跳过该检查——但一旦它内部调用了任何可能扩容的函数(比如fmt.Sprintf),就会直接 panic
扩容过程本质是栈搬家,不是拼接
Go 1.3 后弃用分段栈,改用连续栈(contiguous stack)。每次扩容不是追加一段内存,而是:
- 申请一块新栈(大小通常是原栈 2 倍,初始 2KB → 4KB → 8KB…上限默认 1GB)
- 把旧栈上所有活跃数据(包括局部变量、defer 链、寄存器值)完整 memcpy 过去
- 修正
g.stack.lo/g.stack.hi、栈指针SP、帧指针BP,以及所有可追踪的栈上指针(如&x) - 释放旧栈内存(注意:不立即归还 OS,受 GC 控制)
这意味着:一次扩容耗时是 O(n),且会引发一次内存拷贝毛刺。频繁出现 runtime.morestack 占 CPU 高,基本等于你在递归太深、或反复声明大数组(如 [4096]byte)。
哪些操作会让栈扩容失效或引发 panic
扩容机制有明确盲区,一旦踩中,不会“多试几次”,而是直接崩溃:
- 闭包捕获大结构体,或函数内声明超大数组(如
var buf [65536]byte),单帧超过当前栈剩余空间 + guard 区,绕过检查直接 overflow - CGO 调用中进入 C 函数:C 使用系统栈,Go runtime 完全不管理,
stackguard0失效,极易因混用栈导致静默损坏或 panic - 用
unsafe.Pointer硬算地址(如unsafe.Add(unsafe.Pointer(&x), 8)),运行时无法识别该指针是否指向栈,扩容后地址悬空 - 把栈变量地址存进全局 map / channel / goroutine 参数,而该 goroutine 已调度并完成栈复制——旧地址失效,读写行为未定义
栈大小和指针生命周期根本无关
调大栈(比如用 debug.SetMaxStack)不会让 *int 更安全,也不会延长局部变量寿命。真正决定变量在哪分配的是逃逸分析:
-
go build -gcflags="-m" main.go看输出:若含escapes to heap,说明变量已堆分配,跟栈大小无关 -
&x是否有效,取决于 x 是否还在作用域内,而不是“栈有没有扩够” - 跨 goroutine 传指针危险,是因为发送方函数已返回、栈帧被回收(或复制后旧地址失效),不是因为“栈太小装不下”
最容易被忽略的一点:栈复制只重映射“编译器能识别的指针”,所有基于 uintptr 或 unsafe 的地址运算,都会在扩容后断连——这不是 bug,是设计使然。











