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 有两个保证:

  1. 栈内指针可追踪(编译器知道哪些是指针)
  2. GC 是精确 GC(精确知道指针位置)

所以可以安全更新。


十、栈扩容和抢占的关系

有个很有意思的点:

抢占机制也复用了 stack guard。

当:

g.preempt = true

runtime 会故意把 stackguard 改成特殊值。

让下次函数调用时:

直接进入 morestack

然后在 morestack 里判断:

哦,这是抢占,不是扩容。

于是进入调度器。

这是一种“借道实现”。


十一、栈扩容的成本高吗?

扩容是:

  • 罕见操作
  • 指数增长
  • 很少发生很多次

例如:

一个 goroutine 可能一生只扩容 2~3 次。

所以总体成本可接受。


十二、终极总结

goroutine 自动扩容的机制是:

函数调用前做栈检查
→ 不够则调用 morestack
→ 分配更大连续栈
→ 复制旧栈
→ 修正指针
→ 继续执行

核心技术点:

  • stack guard
  • morestack
  • 连续栈复制
  • 指针修正
posted @ 2026-02-07 22:11  牛奔  阅读(19)  评论(0)    收藏  举报