01 栈:从分段栈到连续栈——热分裂是怎么被消灭的
开篇提问
你写 Go 五年,go func() 写了没有一万也有八千次。但我想先问你一个你大概率从没认真想过的问题:
一个 goroutine 的栈,初始大小是几 KB,那么当一个 goroutine 递归下去、或者调用了一个栈帧特别大的函数,栈不够用了,运行时该怎么办?
先别往下翻。给你三个选项,你自己选一个,并且说清楚理由:
- A. 像 C 一样,栈溢出,程序崩掉。
- B. 把这块栈变大,原地扩。
- C. 另外再要一块内存,拼到旧栈后面,当成同一根”逻辑栈”用。
选完再往下看。这一章要讲的东西,全藏在这个选择里。
子主题一:goroutine 的栈,为什么不能是”固定大小”
先解决 A 为什么不行。
你肯定知道线程的栈是固定大小的——Linux 下 pthread 默认 8MB,JVM 线程默认 1MB。因为栈是固定大小的,所以递归太深或者局部变量太大,就会栈溢出(stack overflow),这是每个 C 程序员、Java 程序员的噩梦。
那 Go 能不能照搬这套,给每个 goroutine 也开一块固定 8MB 的栈?
答案是不能。原因只有一个:Go 的核心卖点就是 goroutine 便宜,而固定大栈会毁掉这个卖点。
算一笔账你就明白了。Go 的设计目标之一是”轻松跑百万 goroutine”。假设每个 goroutine 固定 8MB 栈:
- 100 万个 goroutine × 8MB = 8TB 内存。
就算操作系统对栈做按需分页(只有真正用到的页才分配物理内存),这 8TB 的虚拟地址空间也要先预留出来,而且每个 goroutine 哪怕只用一个字节,也得先占一个 8MB 的虚拟地址空间。在 64 位系统上,一个进程的虚拟地址空间是 128TB 量级,8TB 看似塞得下,但这还没算堆、mmap、共享库,而且预留大块连续地址空间的代价、页表的开销,都会随 goroutine 数量线性爆炸。
反过来,如果把初始栈做小——比如 4KB、8KB——那 100 万 goroutine 只需要几个 GB 的量级,这才养得起”海量并发”这张名片。
所以问题就来了:初始栈必须小,但程序的需求可能大。这中间的矛盾怎么调和?
这就是 Go 运行时最核心的设计问题之一,也是这一章的主角。
子主题二:Go 1.3 之前的答案——分段栈
回到开篇那个三选一。B 和 C 是两条能走通的路,而 Go 在 1.3 之前,选的是 C——分段栈(segmented stack)。
分段栈的原理不复杂:
- 每个 goroutine 的栈,不是一块连续内存,而是一串不连续的栈段(stack segment),段与段之间用指针链起来,构成一个单向链表。
- 编译器在每个函数的序言(prologue)里插一段检查:拿当前栈指针 SP 和本段栈底的警戒线比一比,看”我这个函数需要的栈帧,装不装得下”。
- 装得下,正常执行;装不下,触发
morestack——向内存分配器要一块新的栈段,把新段的地址链到旧段后面,然后在新段上继续执行。 - 当这个函数返回、新段上的帧都弹空了,新段被释放,还给分配器。
这个设计看起来很优雅:按需增长、按需释放。函数栈帧小的时候不浪费,大的时候才多要一块,用完就还。教科书级别的”惰性分配”。
而且它还有个听起来很美的性质:栈可以收缩。一个 goroutine 深递归之后又退回来,栈段用不上了就释放,内存不会一直占着。
那么问题来了:这么优雅的设计,为什么 Go 1.3 把它整个推翻了?它到底踩了什么坑?
子主题三:热分裂——分段栈的致命伤
那个坑,就是热分裂(hot split)。
先看这段代码,它朴素得不能再朴素:
func f() {
for {
g()
}
}
func g() {
// 一个栈帧不算小的函数
}f 是个死循环,每轮调用一次 g。假设现在栈的水位线恰好停在某个尴尬的位置:调用 g 的那一刻,当前栈段剩下的空间,刚好装不下 g 的栈帧。
那么每一轮循环会发生什么?
g被调用,序言检查发现”段满了”。- 触发
morestack,malloc 一个新栈段。 g在新段上执行。g返回,新段弹空,free 掉这个栈段。- 下一轮循环,又调用
g,又是段满,又 malloc……
看到了吗?循环体本身的时间复杂度是 O(1),但每一轮都背上了一次 malloc + 一次 free。这两个操作要进内存分配器、要锁、可能还要触发系统调用,慢得离谱。你的程序什么都没干,时间全花在”分配栈段又释放栈段”上了。
这就是热分裂。它可怕在哪?我们拆开看:
第一,它不是 bug,是设计缺陷。 程序没错,代码没错,甚至算法都对——就是运行时一个决定,把你的循环性能从 O(1) 打成了 O(malloc+free)。
第二,它没法靠”均摊分析”来救。 你学算法的时候,摊还分析能救很多”偶尔贵一次”的操作——比如动态数组扩容,虽然某一次 push 是 O(n),但摊还下来是 O(1)。为什么摊还分析救不了热分裂?因为摊还的前提是”贵的操作偶尔发生、被大量便宜操作稀释”。但热分裂里,malloc/free 是确定性地、每一轮都发生的——成本不是被稀释,而是被精确地、反复地命中。这不是”偶尔贵”,是”每轮都贵”。
第三,它取决于一个你完全控制不了的东西——栈的水位线。 同一个函数,换一天、换个 Go 版本、换个编译优化,栈帧大小变一点点,热分裂可能就消失了,也可能突然出现。这种”薛定谔的性能问题”,是工程师最怕的:没有确定性,就无法复现、无法排查、无法保证。
所以热分裂的本质,一句话:分段栈把”增长的代价”放在了循环里的每一轮,而不是让它被生命周期稀释掉。
子主题四:Go 1.2 的权宜之计——把栈改大
面对热分裂,Go 团队一开始并没有直接推翻分段栈。Go 1.2 做了一个看起来偷懒、实则很清醒的决定:把 goroutine 的初始栈从 4KB 提到 8KB。
这个决定背后的逻辑是:热分裂要发生,前提是”调用恰好发生在段边界附近”。栈越大,边界越少,恰好卡在边界的概率越低——不是治本,是把病灶出现的概率压下去。
这是工程上很经典的一课:当你还没有能力根治一个问题时,先想办法让它”不那么容易发生”,给自己争取时间。
但你也能看出来,8KB 只是把概率降了一半,没消灭问题。一个深递归 + 大栈帧的程序,照样能在某个水位线上反复触发热分裂。Go 团队心知肚明,这只是缓兵之计。
真正的问题还在那里等着:能不能有一种栈管理方式,既保留”按需增长”的好处,又彻底消灭”反复 malloc/free”的热分裂?
子主题五:Go 1.3 的答案——连续栈
Go 1.3 给出了答案:连续栈(contiguous stack)。也就是开篇三选一里的 B——把栈变大,原地扩。
但”原地扩”这三个字,在栈这种结构上有个巨大的技术障碍。你想想:
- 栈是一块连续内存,SP 指针在里面上下移动。
- 栈要”原地扩”,就得在它后面紧挨着再要一块内存——但后面那块可能已经被别的对象占了,物理上根本扩不了。
- 于是”扩”的本质变成了:另外找一块更大的内存,把旧栈的整个内容复制过去,然后所有指向旧栈的指针,全部改成指向新栈。
你没看错,Go 1.3 的连续栈,干的就是这么一件听起来很暴力的事:
morestack触发时,分配一块两倍大小的新栈(通常翻倍,这样摊还成本是 O(1))。- 把旧栈的全部内容原样复制到新栈。
- 找到所有指向旧栈的指针,把它们改成指向新栈对应位置。
- 调整 SP、栈底、各寄存器,然后继续执行,就像什么都没发生过一样。
问题就出在第 3 步:你怎么知道哪些指针指向了栈?怎么保证能全部找到?
这就是这一章真正的高潮。我们先按下不表,先回答一个更朴素的问题:这种”整体搬栈”看起来很贵,为什么反而比分段栈强?
两个原因:
第一,成本被摊还了。 栈按 2 倍增长,意味着一个 goroutine 从 8KB 涨到 64KB,只需要搬 3 次家(8→16→32→64)。搬家的总成本是 8+16+32=56KB 的复制,换来 64KB 的可用空间,摊还下来每次增长 O(1)。而最关键的是——搬完这一次家,之后这个 goroutine 在 64KB 里随便跑,很长一段时间都不用再搬。 热分裂那种”每一轮都搬”的噩梦,被彻底消灭了,因为栈是连续的,根本不存在”段边界”可以卡。
第二,病灶被物理消除了。 分段栈的热分裂,根子在于”栈被切成了一段段,边界就藏在某个水位线上”。连续栈把栈变成一整块,没有段、没有边界、没有”恰好卡在边界”这回事。不是把概率降下去,是把产生问题的那个东西直接删掉了。
这也是工程上一课:治本常常不是”优化”旧方案,而是换一个”压根不产生这个问题”的新方案。
子主题六:搬栈的前提——逃逸分析
现在回到那个致命的问题:连续栈要复制栈、要更新所有指向栈的指针,凭什么能做到?
在 C 语言里,这是绝对做不到的。你想想,C 里随便写一个 &local,这个指针可能已经:
- 存进了全局变量;
- 传给了另一个线程;
- 被
memcpy到了堆上某个结构体里; - 藏在某个
void*后面,谁也不知道它指向哪。
C 的编译器无法在编译期确定”哪些指针指向栈、这些指针都藏在哪”,所以 C 永远不可能做”搬栈”——一旦搬,那些散落在各处的悬垂指针全废了,这是内存安全灾难。
那 Go 凭什么敢?
答案就是逃逸分析(escape analysis),也是下一章的主角。这里先给结论:
Go 编译器在编译期做静态分析,保证了一条铁律:指向栈上变量的指针,绝对不会”逃逸”到堆、全局变量、或者别的 goroutine 手里。 因此,指向栈的指针,只可能存在于栈自身内部。
这条铁律一成立,搬栈就变得可行了:运行时只需要扫描栈自身的所有指针,把它们从”旧地址”改成”新地址”。因为指针都在栈里,扫栈就够了,不用满世界找。
反过来看,这也解释了为什么逃逸分析和连续栈是一对搭档,缺一不可:
- 没有逃逸分析,连续栈不敢搬栈(指针会散落各处,搬了就崩);
- 没有连续栈,逃逸分析只是”少分配几个堆对象”的小优化,价值有限。
Go 1.3 敢上连续栈,正是因为 1.3 之前逃逸分析已经成熟。两个机制一前一后咬合,才有了今天我们看到的、既便宜又安全的 goroutine 栈。
子主题七:连续栈也带来了新问题
看到这里,别以为连续栈就完美了。它只是消灭了热分裂,同时引入了几个新的、值得你知道的代价:
第一,搬栈的成本是真实的。 一个深递归、或持有超大局部数组的 goroutine,栈可能涨到几百 MB,此时再触发一次翻倍,就要复制几百 MB,还要扫栈改指针,停顿是实打实的。所以 Go 里”栈很大”的 goroutine,性能风险要单独评估。
第二,栈收缩更复杂。 分段栈释放段很容易,连续栈要收缩,得等栈回落到足够空、再搬一次小栈。Go 的规则是:栈用不到 1/4 时才考虑收缩(且收缩后不会太小)。搬来搬去都有成本。
第三,栈拷贝的历史包袱。 早期连续栈的实现里,搬栈这件事对”指针是否真的在栈上”的假设非常敏感,也催生了一系列围绕 uintptr、unsafe、reflect 的严格约束——这些约束至今还在坑人(比如 uintptr 持有栈地址后再搬栈导致的悬垂问题,官方文档专门警告过)。
这三点,留到后面章节(02 逃逸分析、19 反射与 unsafe)再展开。现在你只需要记住一句:连续栈不是免费的午餐,它是用”搬栈的成本”和”逃逸分析的约束”换来的”没有热分裂”。
子主题八:栈在内存里长什么样——它为什么”向下”长
先问一个看起来跟 Go 一点关系都没有的问题:你写 C 的时候,知道栈是往哪个方向长的吗?
大部分人答不上来,因为大部分人从没需要知道。但一旦你要理解 morestack,这个方向就是全部前提。
在 amd64、arm64 这些主流架构上,栈从高地址向低地址增长。PUSH 一次,SP 减小;函数序言里的那条减 SP 的指令,是往低地址挪。所以一个 goroutine 的栈,用 Go 运行时自己的说法,是一个半开区间 [stack.lo, stack.hi)——lo 在下面(低地址),hi 在上面(高地址),SP 从 hi 这一头开始,一路往下啃。
那为什么人类当初要选这么别扭的方向?
答案藏在一个今天已经不太重要的历史约束里。早年的机器上,一个进程的地址空间就那么点,程序得同时养活堆和栈这两块会长大东西。怎么分?总不能预先规定”堆 3MB、栈 5MB”——那必然是一边撑死、一边饿死。于是有人想到了一个漂亮得不像话的办法:把它们放在地址空间的两端,让它俩相向生长。堆从低地址往上长,栈从高地址往下长,中间那片巨大的空洞,谁先需要谁先用。两边撞上了,才算真的没内存了。
你看出这里的巧思了吗?它把”如何划分内存”这个静态决策,变成了一个动态的、自适应的过程,而且不需要任何运行时统计。 堆多的程序自动多占堆,栈深的程序自动多占栈。这是纯靠布局就白拿到的自适应。
今天有了 64 位虚拟地址空间和地址随机化,中间那块空洞大到用不完,相向生长的自适应价值基本归零了。但 ABI 一旦定下来就是化石级的兼容性,没人会为了”更顺眼”去改它——于是这个从早期 Unix 传下来的约定,一路传到了你的 goroutine 里。
那么,向下增长这件事,对 Go 的连续栈意味着什么?意味着一件非常具体、而且很优雅的事。
搬栈的时候,旧栈的内容要”顶部对齐”地贴到新栈的高地址端。
为什么?因为栈帧之间的所有关系,都是用”相对于 SP(或相对于帧指针)的偏移”来表达的。如果我把旧栈整块搬过去,还让它贴着新的 hi 那一头,那么每个栈帧内部的所有偏移、所有”从这个帧找回上一个帧”的推导,全都一字不改地成立。剩下的只有一件事:所有绝对地址都要平移一个固定的量。
这个平移量就是 new.hi - old.hi——新旧两块栈高地址端的差值。搬栈时,运行时把整块内容 memmove 过去,然后把这个 delta 加到每一个栈上指针上。
你看,向下增长这个”历史包袱”,反过来让搬栈的实现变简单了:复制是整块的,改指针是统一加一个常数。 如果你要重新设计一个向上增长的栈,搬栈时就得多操心帧的排列,麻烦得多。
这里还有个反直觉的细节,值得你想一想:既然栈是从 hi 往 lo 长,那”栈用完了”是 SP 掉到 lo 下面,还是升到 hi 上面?
答案是掉到 lo 下面。所以警戒线画在 lo 这一端——栈的增长边界在低地址,不在高地址。记住这一点,下一节看 morestack 的时候就不会晕。
子主题九:morestack 的序言里,编译器到底插了什么
现在我们可以真正钻进函数序言了。
你写的每一行 Go 代码,编译出来的函数最开头,都被编译器塞进了一小段你没写过的指令。这段代码只干一件事:在给自己分配栈帧之前,先问一句——我这一帧,还装得下吗?
具体怎么问?我按”从直觉到实现”的顺序讲。
第一步:拿到当前 goroutine 的警戒线
每个 g(goroutine 的运行时结构体)里有一对”哨兵”字段,源码里的名字大致是 stackguard0 和 stackguard1。它们的取值,概念上是:
警戒线 = 栈底(lo 端)+ 一小段预留的缓冲区
那这段预留是干嘛的?是给运行时自己留的活路。 你想,如果 SP 真的踩过了警戒线,接下来要执行的可是 morestack——这段代码本身也要用栈啊。如果一点余量都没有,morestack 自己就先溢出了。所以警戒线不是画在栈的物理边界上,而是往里缩了一段,这段空间专门留给”处理栈不够”这件事本身。
那为什么有两个?因为它们服务的场景不同:一个给普通的函数调用用,另一个给栈帧特别大的函数用(后者要预留得更多)。而且 GC 会临时把其中一个抬高,当作协作式抢占的信号——这是个很妙的复用,记住 stackguard 不只服务于栈增长,它还是抢占的哨兵,后面讲调度时会再摸到它。
第二步:比较。小帧和大帧,检查方式不一样
这里有个非常值得品味的工程细节。
对于栈帧很小的函数(小到什么程度?源码里有个阈值常量,量级在”几十到一百多字节”。我建议你去源码里查准确值,而不是背我这里的模糊说法),编译器用的是最直接的办法:
拿 SP 和 g.stackguard0 比一下
比警戒线还低 → 跳到 morestack
两条指令,CMP + JLS(无符号/有符号小于则跳转)。这就是全部开销。
但对于栈帧很大的函数,这样比就不够了。为什么?你想——这个函数的帧可能好几 KB,光比较”当前 SP”没意义,你得知道”我把这个大帧分配下去之后,SP 会掉到哪”。所以大帧的检查是:
先把"分配完这一帧以后的 SP"算出来(一条 LEA 就够,不用真的改 SP)
再拿它和 g.stackguard1 比
不够 → 跳到 morestack
看出这里面最漂亮的一处了吗? 那条 LEA(取地址指令)同时干了两件事:它既算出了”分配后的 SP”,又把这个值留在寄存器里,紧接着赋值给 SP——也就是说,“算新 SP”这个动作既服务于检查,又是分配本身,检查和分配共用了一条指令。这不是炫技,这是在每一次函数调用都要执行的热路径上,把能省的一条指令都省掉。
第三步:morestack 干了什么
一旦跳到 morestack,事情就严肃了。它是一段汇编写的 trampoline,大致流程是:
- 保存现场:把当前的 PC、SP 等寄存器状态存进 g 的调度信息结构(
g.sched)里。因为马上就要天翻地覆了,不存好就回不来了。 - 切到系统栈:切换到当前 M 的
g0栈上运行。这一步是必须的,理由很硬——你不能在”已经被判定不够用的栈”上,执行”换一块更大的栈”这段逻辑。就像你不能站在即将被拆的梯子上换梯子,得先下来站在地上。g0是每线程独立的、固定大小的系统栈,专门干运行时自己的脏活。 - 调用
newstack:真正的逻辑在这里——算新栈大小、分配、整块复制、按 stack map 改指针、更新 defer/panic 等结构里的指针。 - 恢复现场继续执行:新栈就绪,从
g.sched恢复,接着跑,仿佛什么都没发生。
顺便解答一个容易让人困惑的点:都连续栈时代了,为什么函数还叫 morestack?
因为它是从分段栈时代继承下来的名字,当时它确实是”再要一段栈(more stack)“。语义早变了,名字留了下来。这种”名字比语义活得久”的现象在 runtime 里到处都是,读源码时别被名字骗了——要看它现在在做什么,而不是它叫什么。
第四步:谁可以不检查?
每次函数调用多两条指令,这笔开销值得较真吗?值得。所以 Go 给了几条豁免:
- 编译器证明基本不用栈的叶子函数,可以直接免掉检查;
- 打了
//go:nosplit标记的函数不插入检查。但这是把安全责任转嫁给了你:这类函数里一旦栈真的不够,没有任何优雅的增长机制来救你,直接就是致命错误。所以 nosplit 是给运行时自己、给极短的汇编片段用的,业务代码里乱用是在给自己埋雷。
回到开头:分段栈和连续栈,在序言检查上差多少?
答案是:几乎没有差别。 检查的指令、警戒线、跳到 morestack 的路径,全都一样。真正变的是 morestack 后面接的那件事——从”链一段新栈”变成了”换一块更大的栈,整个搬过去”。
这是个极其重要的观察:Go 那次栈的大改造,对绝大多数函数的前两条指令没有任何影响。 它把复杂度收敛到了 newstack 一个地方。好的设计就是这样——把痛点关进一个可控的笼子里,而不是让它散落在每一处调用点。
子主题十:栈收缩——为什么”变小”比”变大”保守得多
增长讲完了,来看它的镜像问题:一个 goroutine 深递归到 512KB,然后退回来只剩 2KB,那 510KB 就一直占着吗?
先问你一个更根本的问题:你觉得收缩该在什么时候做? 如果你是设计者,第一反应大概是”函数返回、栈帧弹出的时候,看看是不是空了很多,空了就缩”。
这个直觉很自然,但它是错的。为什么?
因为”函数返回时检查”这个动作本身,要加在每个函数的热路径上。 为了一个”可能永远用不上”的收缩,给所有函数返回处加指令,这笔买卖不划算。更糟的是:如果你允许每次返回都触发收缩,那段”循环调用一个刚好卡在阈值附近的函数”的噩梦就回来了——只不过这回不是 malloc/free 的循环,是搬栈的循环。热分裂会以另一种形式复活。
那 Go 怎么做?搭 GC 的便车。
GC 在标记阶段本来就要挨个扫描所有 goroutine 的栈(栈是 GC 根,下一节详讲)。既然都要停下来看一眼,那就顺手判断一下:这个栈是不是太空了?是的话顺便缩一下。
这是一次非常划算的复用:收缩的触发点不需要任何新的插桩,复用一个已经存在、而且是低频的动作就够了。
接下来是具体规则。我讲三个要点,你感受一下它有多保守。
第一,门槛:使用率不到 1/4。
不是”空了一半就缩”,而是用量跌到栈总大小的四分之一以下才考虑动手。为什么定这么低?这 3/4 的余量就是迟滞(hysteresis)——它保证了”刚涨完的栈不会因为一次小小回落就立刻被缩”,也保证了”刚缩完的栈还有充足的上涨空间,短时间内不会再触发增长”。
第二,幅度:只缩一半,不一次缩到位。
注意这一点,很多人会猜错。条件满足了,它不是缩到”刚好装得下当前用量”,而是缩到原大小的一半。所以一个 512KB 但只用了 2KB 的栈,一次收缩变成 256KB,而不是 2KB。
为什么要这么磨叽?还是防抖。一次缩到 2KB,紧接着业务又来一波,立刻就得涨回去——一次收缩 + 一次增长 = 两次搬栈,而这两次之间可能只隔了几毫秒。缩一半,就给了它一整倍的缓冲。
第三,本质:收缩也是一次搬栈。
这是最容易被忽略的一点。收缩不是”free 掉高地址那一半”——栈是一整块连续内存,中间不能凭空剪掉一截。它和增长走的是同一套代码路径:分配一块新栈 → 整块复制 → 用 stack map 找出所有指针、统一加上 delta → 更新 defer/panic/sudog 等结构里的指针 → 切换过去。
换句话说,Go 里根本不存在”免费释放栈内存”这回事,只有”搬到一块更小的栈上去”。 分段栈当年那个”段空了就 free,轻飘飘”的优势,在连续栈这里变成了必须精心控制节奏的昂贵操作。
三条合起来,你现在能回答”为什么收缩比增长保守”了吗?
因为增长是”不得不做”——不做程序就崩;而收缩是”锦上添花”——不做程序照样跑,只是多占点内存。 一个操作如果做错的代价是”白白浪费两次搬栈的停顿”,而收益只是”早一点还回一点内存”,那它理应被设计得非常迟钝。
一句话记住:Go 宁可让一个闲着的 goroutine 多占一会儿内存,也不愿意为了省内存而在热路径上引入新的抖动。
最后留一个思考:既然收缩要等 GC,那一个几乎不触发 GC 的进程里,goroutine 的栈会不会一直不收缩? 严格说,GC 总会发生(哪怕堆分配很少,也有强制周期),所以不至于永远。但这确实意味着一件事:栈内存的回收节奏,是被堆的 GC 节奏牵着走的。 一个”栈涨得很凶但堆很干净”的服务,它的栈内存回收会比你想的慢。你遇到过这种情况吗?
子主题十一:goroutine 栈 vs 操作系统线程栈——一次完整对照
这一节我们把两套栈放在一起彻底比一比。先看结论,再逐条拆。
| 维度 | OS 线程栈(pthread) | goroutine 栈 |
|---|---|---|
| 初始大小 | MB 级,固定 | KB 级,可变 |
| 大小上限 | 创建时定死 | 有上限,但可在运行时增长 |
| 能否搬动 | 不能 | 能,而且是真搬 |
| 由谁管 | 内核 + ABI | Go 运行时自己管 |
| 不够用的后果 | 段错误,进程死 | 触发增长;超过上限才是致命错误 |
| 创建成本 | 系统调用 + 内核对象 | 用户态分配 |
第一,内存占用:差三个数量级。
线程栈 MB 级起跳,goroutine 栈 KB 级起跳。这不是”省了一点”,这是”能不能把并发从几千做到几百万”的分水岭。你在开篇那一节已经算过这笔账了:100 万 × MB 级 = TB 级,根本不成立;100 万 × KB 级 = GB 级,勉强养得起。
而且别只算虚拟内存。虽然操作系统对线程栈也是按需调页的,但每个线程光是内核里的任务结构体、页表项、以及为栈预留的地址区间,就是实打实的固定开销。goroutine 的栈则完全在 Go 自己的内存管理之下,就是堆里的一段 span。
第二,可移动性:这是最深的一条差别。
线程栈能搬吗?绝对不能。 理由不是”技术上做不到”,而是”做不到能接受的程度”——内核不知道你在栈上放了什么,C 程序的指针可以藏在 void* 里、藏在 long 里、甚至被运算处理过。你一动,全世界都悬垂。
goroutine 能搬,靠的是我们在子主题六讲过的那条铁律:逃逸分析保证指向栈的指针只存在于栈内部,再加上编译器为每一个 PC 位置生成的栈帧指针位图(stack map),让运行时知道”这个函数的这一帧里,哪几个字长是指针”。
所以,“可移动”不是运气,是一整套编译期保证 + 元数据的产物。
第三,成本:创建一条。
创建 OS 线程要走系统调用、要在内核里建对象、要被调度器登记;创建 goroutine 主要是 newproc 里的一段用户态逻辑——找一个空闲的 g、给它一段栈、挂到 P 的本地队列,全程用户态,没有系统调用。这就是”goroutine 便宜”的全部秘密:它没有把自己交给内核。
第四,谁负责不溢出。
线程栈溢出 = 内核发信号 = 默认进程死。goroutine 栈要不够时,运行时先尝试增长;只有涨到上限(默认值不小,在 GB 量级,可以用 runtime/debug 包里设置栈上限的那个函数调整)才抛出致命错误。
所以我给你一个判断标准:“stack overflow” 在 Go 里几乎总是一个症状,而不是一个原因。 一个正常的 Go 程序不会因为”递归深了一点”就栈溢出——真正会撞上上限的,通常是死递归、或者你把某个无界增长的东西塞在了栈上。看到”goroutine 的栈超过了上限”这类致命错误,第一反应应该是”这里有死循环或者无限递归”,而不是”栈太小了”。
第五,一个容易被忽略的代价:goroutine 的栈不是免费的。
线程的固定栈是”占了就占了”,goroutine 的可增长栈多了一层成本:每次变化都要复制 + 扫描。而且,一个 goroutine 的栈内存是独占的——它的 span 不能分给别的对象用。所以大量 goroutine 各自占着一块”涨上去又没缩下来”的栈,是真实存在的内存压力来源。
这也是为什么”goroutine 池 / worker 复用”在某些高频场景里仍然有意义——复用的不只是 g 对象,更是那块已经涨到合适大小的栈。
子主题十二:栈是 GC 的根——搬栈和扫描,共用同一份元数据
这一节是本章最容易被跳过、但我认为最值钱的一节。
先问一个你可能没认真想过的问题:GC 是怎么知道堆上哪些对象还活着的?
答案的起点,就是栈。
GC 的标记过程不是”扫描整个堆找谁被引用”,而是反过来:从一组”根(roots)“出发,顺着指针往下走,能走到的就是活的,走不到的就是垃圾。 而根的主要来源有两类:全局变量,和每一个 goroutine 的栈。
这意味着一件大事:栈上那些局部变量里存的指针,决定了堆上一大批对象的生死。 一个 p := &T{} 如果还在某个 goroutine 的栈帧里活着,那这整个 T 以及它引用的所有东西,都不能被回收。
现在有意思的地方来了:GC 要扫栈,就得知道栈上哪几个字节是指针。
栈是一坨没有类型信息的裸内存。第 8 个字节到底是个 int64、是个指针、还是个 float64 的一半?从内存里看不出来。
Go 的解法,是让编译器为每个函数的每个”安全点”生成一张指针位图(stack map):这张图精确记录”在这个函数的这一帧里,偏移 x、y、z 处是指针,其他都不是”。运行时扫描时,先根据 PC 找到对应的函数、再找到对应的 stack map,然后按图索骥。
这种扫法叫精确式扫描(precise scanning)。它的反面是”保守式扫描”——把栈上每一个字长的东西都当成可能的指针去试(Boehm GC 那一类就是这么干的)。保守式的代价有两个:可能把整数误认成指针,导致该回收的没回收(内存泄漏);而且你根本不敢搬动任何东西——万一那个”看起来像指针的整数”其实真是指针呢?
所以,精确式和可移动性,是同一枚硬币的两面。 只有当你确确实实知道”这两个字节是指针”时,你才敢搬;也只有精确知道,你才敢只扫这些位置。
这就是为什么我在子主题六说”逃逸分析和连续栈是搭档”。现在可以补上第三位:stack map。三者合起来,才让”既能搬、又能精确 GC”成为现实。
关键洞察来了——搬栈用的元数据,和 GC 扫栈用的元数据,是同一份。
搬栈(copystack)要改所有栈上指针,靠什么找指针?靠 stack map。GC 扫栈要读所有栈上指针,靠什么?还是 stack map。同一份编译器生成的元数据,被两个完全不同的机制复用了。 这是 Go runtime 里我很喜欢的一处设计:一次投入,两处收益,而且两处都因此变简单。
再往前一步:搬栈能发生在什么时候?
搬栈期间,栈正在被复制、栈上的指针正在被改写,这个过程中栈处于不一致的中间状态。如果此时 GC 恰好来扫这个栈,或者别的 goroutine 恰好通过 channel 往它栈上写数据……会怎样?
所以搬栈必须挑一个”绝对安全”的时刻。具体来说,运行时只会在这几种情况下搬栈:
- 这个 goroutine 被抢占并停在了安全点(此时它的栈处于一个 stack map 能精确描述的状态);
- 它阻塞在系统调用或 channel 上,自己不跑了;
- 它正在被 GC 扫描(反正已经停下来了,顺手把收缩也做了);
- 或者就是它自己触发了 morestack(它自己知道要搬家,此刻正站在 g0 上)。
而且搬栈全程跑在 g0 系统栈上,不在被搬的那个栈上——这一点在 morestack 那节已经见过一次了。
这就是为什么”goroutine 可以搬栈”这件事,需要一个支持精准抢占的运行时才玩得转。 早年的 Go 只能在函数调用处抢占,那搬栈的安全窗口就窄得多。这里有一条很深的因果链:连续栈需要搬栈 → 搬栈需要安全点 → 安全点需要协作式乃至异步抢占 → 抢占又是 GC 能把停顿压到极短的前提。Go 的这些子系统是一张大网,牵一根动全身。 你在后面讲调度和 GC 的章节里,会反复摸到这张网。
子主题十三:业界对照(进阶)——同一个坑,三种解法
前面我们已经粗看过一遍业界的做法。这一节把其中最有价值的三条线索讲透,因为它们不是”别人怎么做的”这种八卦,而是同一道题目的三种解法,看完你会更清楚 Go 这步棋的成色。
Erlang:从语言层面把”指针”这个词删掉
Erlang 的答案最极端,也最优雅。
Erlang 的进程是语言级的轻量实体,几十万、上百万随便开。它的栈怎么处理?Erlang 干脆不让你操心独立的”栈”——进程自己有一块私有内存区域,栈和进程私有堆放在同一块区域里,随 GC 一起被拷贝和压缩。
于是”搬栈”在 Erlang 里根本不是一个需要专门解决的问题:它每次 GC 都在搬整个进程的内存,栈只是顺带被搬了。 一个需要精心设计阈值的、独立的”栈收缩机制”?不需要,GC 压缩时自然就腾出来了。
那它凭什么敢这么干?凭它的语言里没有”取地址”这回事。
Erlang 的数据不可变、变量单次赋值、进程之间不共享内存、进程间通信靠拷贝消息。这几个特性合起来的后果是:你拿不到指向”别人内存”的指针,也拿不到指向”自己栈顶某个变量”的裸指针。 没有别名、没有指针算术、没有 &x。
所以我给 Erlang 的路线一个说法:在语言设计层面做”硬保证”。 它不需要聪明的编译器去分析”这个指针逃没逃”,因为语言压根不给你这个能力。
对比一下 Go:Go 有 &x、有指针、有切片底层数组共享,所以 Go 必须在编译器层面做”软保证”(逃逸分析)。软保证的好处是表达力强(你该写指针还是能写),代价是分析器的正确性成了内存安全的地基。 哪个更好?取决于你要不要那点表达力。Go 要,Erlang 不要。
Rust:同一个坑,独立地再踩一次
这条线索最有说服力,因为它是一次独立的重复实验。
Rust 早期(1.0 之前那几年)确实实现了分段栈。动机和 Go 一模一样:支持海量并发任务、每个任务的栈要小、要能按需增长。手段也高度相似:编译器在函数序言插入检查,不够就调用运行时去要一段新栈。
然后呢?它撞上了同一个 hot split。 同样是在循环里反复调用一个刚好卡在段边界的函数,每次都分配又释放一段栈,性能塌方。
Rust 后来正式移除了分段栈,回到固定大小的线程栈。除了热分裂,压垮它的还有另外两根稻草,都很值得你知道:
- 和 C 的互操作。 分段栈要求”调用链上所有代码都懂分段栈这套约定”。但真实世界里,你的 Rust 程序要调 C 库、要用内联汇编、要被
libc回调——那些代码不知道你的栈是一段段的,它们假设栈连续。这个假设一旦被打破,问题就不是”慢一点”,而是”行为未定义”。 - 工具链的维护成本。 分段栈需要编译器后端的深度配合,而这条路径用的人少,长期处于”能跑但没人认真维护”的状态。
于是两个语言,在不同的社区、不同的时间点、不同的实现路径上,各自独立地证明了同一件事:分段栈在热分裂面前站不住。
我觉得这件事比任何基准测试都更有说服力。它说明 hot split 不是”Go 实现得不好”,而是这个设计的内在缺陷。一个方案如果换了实现者、换了语言、换了编译器,还是会以同样的方式失败,那失败的原因大概率在方案本身。
Java 虚拟线程:绕了一大圈,走到了隔壁
Java 的答案最晚到,也最曲折。
Java 传统的线程就是 OS 线程,栈固定、MB 级、溢出抛 StackOverflowError。所以当 Java 想要”百万级并发”时,它面对的是和 Go 当年一模一样的困境。
它的解法是虚拟线程:大量虚拟线程跑在少量的载体线程上。虚拟线程阻塞时(比如等 I/O),会被”卸载”下来,让载体线程去干别的。
那栈怎么办?Java 的答案是把虚拟线程的栈以”栈块”对象的形式存在堆上。栈块可以按需分配、按需增长;涨的时候,同样是分配一块更大的、复制过去——你看,和 Go 的连续栈是同一个思路,只是它把栈做成了堆上的普通对象,于是 GC 天然就会管它。
为什么说”绕了一大圈”?因为 Java 早年坚持”一个 Java 线程 = 一个 OS 线程”,这个坚持让它在很长一段时间里把”并发要便宜”这条路堵死了,只能用回调、Future、响应式编程这些别扭的东西绕。等到它终于承认”需要一层用户态调度”,它就得把 Go 十年前做过的事情重做一遍,包括栈。
那么它搬栈时怎么更新指针?靠 JVM 早就有的精确栈映射(oops map)——JVM 的 GC 本来就是精确式的、本来就能移动对象。所以 Java 在这一步反而很轻松:地基早就打好了,只是之前没往这个方向盖房子。
三条路放一起看
| 语言 | 栈在哪 | 能搬吗 | 凭什么敢搬 |
|---|---|---|---|
| Erlang | 进程私有区,与堆同区 | 天然能 | 语言层面没有指针 |
| Go | 堆上,独立的 span | 能,需要搬栈 | 逃逸分析 + stack map |
| Rust | OS 线程栈 | 不能 | ——(栈固定,不需要搬) |
| Java 虚拟线程 | 堆上的栈块对象 | 能 | JVM 精确 GC(oops map) |
看出规律了吗?凡是”能搬栈”的语言,都已经在别的地方为”精确知道指针在哪”付过费了。 Go 付在逃逸分析和 stack map 上,Java 付在 JVM 的 GC 基础设施上,Erlang 付在”干脆不要指针”上。
搬栈从来不是一个独立的技巧,它是”你有没有精确指针信息”的一次兑现。 没付过那个成本,就别想要这个能力。
子主题十四:动手看看——怎么观察栈增长,以及”大栈”的性能账
讲了这么多机制,最后必须落到手上。你怎么知道自己服务里 goroutine 的栈涨成了什么样?
先泼一盆冷水:Go 没有提供一个”列出每个 goroutine 栈大小”的现成开关。 这是个真实的痛点,也是你排查时容易卡住的地方。但有几条路可以逼近真相。
路子一:从全局数字入手——runtime.MemStats
最直接的一手数据来自 runtime 包里读取内存统计的那个函数(debug 包下有等价的入口)。里面有两个字段专门描述栈:
StackInuse:当前真正被 goroutine 栈占用的内存;StackSys:从操作系统申请来的、用于栈的内存总量。
这两个数的差值,就是栈 span 里闲置和碎片化的部分。把它打成一个定时上报的指标,你就能看到一条曲线:栈内存是不是在缓慢爬升?是不是在某次变更后台阶式跳了一下? 这一步极其便宜,我建议所有在意内存的服务都加上。
一个判读经验:如果 StackInuse 涨上去就不下来了,而 goroutine 数量是平稳的,那大概率是”大量 goroutine 曾经深栈过一次,之后一直没被收缩”。 回忆子主题十——收缩要等 GC、还要跌到 1/4 以下,所以它慢是有道理的。
路子二:看 goroutine 的调用栈——runtime/pprof
runtime/pprof 里的 goroutine profile(以及 runtime.Stack 的全量 dump)能给你一份所有 goroutine 的调用栈快照。它不告诉你字节数,但它告诉你谁深。
怎么用?把 dump 拿出来,按调用栈聚合,然后找两类东西:
- 栈特别长的那批:调用栈深到几十上百层的,几乎必然对应大栈。尤其注意递归——一眼就能看出递归深度。
- 栈里带着”大局部数组”的那批:如果你在某个函数里写了
var buf [1 << 16]byte这类东西,它会出现在栈上(除非逃逸了),dump 里看到那个函数名,就是线索。
再进一步:goroutine 的数量 × 平均栈深度,才是你要盯的乘积。一个 goroutine 深栈不可怕,一万个深栈 goroutine 才可怕。
路子三:用 GODEBUG 观察”搬栈的副作用”
GODEBUG 是运行时的调试开关集合,其中和栈最能扯上关系的是 gctrace=1(打印每次 GC 的详细耗时)。它不直接打印栈大小,但能告诉你一件要紧的事:GC 的各阶段耗时,有没有异常地长。
为什么这能反映栈的问题?因为扫描 goroutine 栈是 GC 标记阶段的主要工作量之一。栈越大、goroutine 越多,扫栈越贵。如果你看到 GC 标记阶段的耗时和 goroutine 数量强相关,而堆本身并不大,那栈就是头号嫌疑。
顺便说一句,GODEBUG 的开关和 gctrace 的输出格式会随版本演进,具体字段请以你手上那个 Go 版本的官方文档为准。别背格式,去查。 这个习惯比记住某个字段重要得多。
那么,“栈很大”到底贵在哪
最后把账算清楚。一个大栈 goroutine,代价分布在四个地方:
- 搬栈的停顿。 复制是 O(栈大小),扫栈改指针也是 O(栈大小里的指针数)。栈到几百 MB 时,这是一次肉眼可见的卡顿。而且翻倍增长意味着下一次搬要复制的是”当前整个栈”——栈越大,搬一次越痛,体感是超线性的。
- GC 扫栈的成本。 每个 goroutine 的栈,每轮 GC 都要扫一遍(至少扫到当前用到的部分)。栈大、goroutine 多,这笔开销是乘积级的。
- 内存独占。 栈 span 归这个 goroutine 私有,别的对象用不了。涨上去没缩下来的那些空间,是纯粹的浪费。
- 局部性变差。 栈越大,工作集越散,缓存命中率下降。这条最隐蔽,但真实存在。
给出几条可执行的建议
- 别在栈上放大数组。
var buf [64 * 1024]byte这种写法在大并发的函数里是定时炸弹。要么让它按预期分配在堆上,要么用sync.Pool复用,要么把大缓冲区提升到 goroutine 生命周期之外。 - 深递归优先改迭代。 尤其是”递归深度取决于输入数据”的那种——那等于把栈大小的控制权交给了外部输入。
- 给栈设个天花板。
runtime/debug包里设置栈上限的那个函数可以把默认上限调低,让”失控的栈”早点以明确的错误暴露,而不是先吃光内存再说。这一招在测试和预发环境里特别好用。 - 高频场景考虑复用 goroutine。 理由前面说过:复用的是那块已经涨好的栈。
最后留个问题给你自己想:如果 StackInuse 长期居高不下,你会先怀疑什么? 我的排查顺序是:先看 goroutine 总数是不是真的有那么多;再看是不是有一批 goroutine 卡在深栈的调用路径上(dump 一眼就能看出来);最后才怀疑”该收缩而没收缩”——因为 GC 总会跑,收缩只是迟早,不会永远不来。
业界对照:别人怎么管栈
把 Go 的答案放到更大的坐标系里看,你会更理解它的取舍。
C/C++:固定大栈。 栈大小固定(8MB 默认),程序员自己小心别溢出。没有任何”自动增长”——简单粗暴,零运行时开销,代价是深递归和超大局部变量直接崩。这是”性能优先、安全靠人”的路子。
Java/JVM:固定大栈 + 分代堆。 线程栈固定(1MB 左右),栈溢出抛 StackOverflowError。Java 解决”并发要便宜”的办法不是缩小栈,而是后来的虚拟线程(Virtual Thread,Loom 项目)——本质上是”在操作系统线程上调度大量轻量任务”,这一思路和 goroutine 高度相似,只是 Java 来得晚。有趣的是,Java 虚拟线程早期实现里也面临”栈要不要分段/搬”的讨论,最终选择了和 Go 类似的”可增长的连续栈 + 搬栈”,只是搬栈的指针更新依赖 JVM 的精确 GC 栈映射(oops map)来完成,殊途同归。
Erlang/Elixir:极小的栈 + 可移动。 每个进程的栈可以很小,且进程内存整体可被 GC 移动。Erlang 语言本身没有”取地址”这种逃逸的口子(不可变数据、无指针别名),所以搬起来毫无负担。这是”从语言层面掐断指针逃逸”的最彻底路线——Go 的逃逸分析是在编译器层面”软保证”,Erlang 是在语言设计层面”硬保证”。
Rust:栈就是栈,搬栈依赖借用检查。 Rust 的栈是固定大小(线程栈),但借用检查器在编译期保证了”引用不会逃出所有者的作用域”,理论上也可以做搬栈优化。历史上 Rust 早期讨论过分段栈(segmented stack),后来正是因为热分裂(hot split)问题,在 2014 年前后放弃了分段栈,改回固定栈。Go 踩过的坑,Rust 又踩了一遍,最后殊途同归地否掉了分段栈。这是一个非常值得记住的对照:两个语言,各自独立地验证了”分段栈在热分裂面前不成立”。
版本演进小结
| 版本 | 栈策略 | 关键变化 |
|---|---|---|
| Go 1.0 ~ 1.1 | 分段栈(初始 4KB) | 按需增长,存在热分裂 |
| Go 1.2 | 分段栈(初始 8KB) | 权宜之计:放大初始栈,压低热分裂概率 |
| Go 1.3 | 连续栈 | 彻底废除分段栈,搬栈 + 逃逸分析,消灭热分裂 |
| Go 1.4 | 连续栈(初始 2KB) | 连续栈稳定后,把初始栈缩回 2KB,更省内存 |
注意最后一行这个反直觉的细节:Go 1.3 上连续栈之后,Go 1.4 反而把初始栈从 8KB 缩到了 2KB。为什么?因为连续栈的增长代价被摊还得很好,初始给 2KB 也不会频繁搬;而省下的内存(100 万 goroutine 从 8GB 降到 2GB)是实打实的。技术方案换对了,前面那个”为了规避问题而放大”的权宜参数,就能改回来了。 这是”治本”和”治标”最直观的对比。
本章思考题
【思考题】
-
开篇的三选一,你原本选了什么?如果让你重新设计,在”没有逃逸分析”的前提下,你还能想到 B(连续栈)之外的办法来消灭热分裂吗?
-
连续栈按 2 倍增长,为什么是 2 倍而不是 1.5 倍或 3 倍?这个数字背后有什么权衡?
-
一个 goroutine 的栈曾经涨到过 256KB,后来回落到只用 2KB。Go 会立刻把它收缩回 2KB 吗?为什么?
-
既然栈是向下增长的(SP 从高地址往低地址走),那搬栈时把旧栈内容”贴着新栈的高地址端”放,有什么好处?如果反过来贴低地址端,会发生什么?
-
函数序言里的栈检查,为什么小帧和大帧要用两套不同的比较方式?如果对所有函数统一用”先算出分配后的 SP 再比较”,代价是什么?
-
收缩的规则是”使用率低于 1/4 才动手、且只缩一半”。如果改成”低于 1/2 就缩、并且缩到刚好装得下当前用量”,会发生什么?请具体描述一个会出问题的场景。
-
为什么 Go 能搬栈,而保守式 GC(比如 Boehm 那一类)绝对搬不了?支撑”可搬”的那一套共用基础设施是什么?它同时被哪两个机制使用?
-
你的服务
StackInuse从 200MB 涨到 2GB 就再也不下来了,而 goroutine 数量稳定在 5000 左右。请给出你的排查顺序,以及每一步的依据。 -
分段栈在 Go 和 Rust 两个语言里被独立地放弃过。请你说说:为什么”两个独立社区得出同一结论”这件事,比任何一份基准测试数据都更有说服力?
【参考答案】
-
顶部对齐的好处是:栈帧之间的所有关系都用”相对 SP / 相对帧指针的偏移”表达,只要旧栈内容整体贴着新的 hi 端,所有帧内偏移和回溯链都一字不改地成立,剩下只需把所有绝对地址统一加一个 delta(delta = 新栈 hi − 旧栈 hi)。反过来贴低地址端的话,栈帧的排布会被”上下颠倒”,每个帧的相对位置都变了,你就必须逐帧重算回溯关系和偏移——而”怎么从上一个帧找到下一个帧”本身就依赖栈内布局,推导会自相矛盾。换句话说,向下增长这个历史约定,恰好让”搬栈”退化成了”整块复制 + 统一加常数”。
-
小帧只需要比较当前 SP 与警戒线,两条指令搞定;大帧必须先知道”把这一帧分配下去后 SP 会掉到哪”,所以要额外一条 LEA 先算出分配后的 SP 再比。若对所有函数统一用大帧那套,代价是每次函数调用都多一条指令——而函数调用是最高频的操作之一,多出来的一条会被乘以调用次数。Go 把大帧的检查设计得尤其巧妙:那条 LEA 既服务于检查,又直接产出分配后的 SP,检查和分配共用一条指令,把这一层的额外成本压到了最低。
-
会制造”收缩版热分裂”。设想一个循环:每轮先深调用一次(把栈顶到 100KB),然后返回到只剩 2KB。按”低于 1/2 就缩到刚好装得下”,每轮循环都会触发一次收缩搬栈(缩到 2KB),下一轮又立刻触发一次增长搬栈(涨回去)——每轮两次整栈复制加两次指针扫描,这比 malloc/free 还要贵得多,退化成比分段栈更糟糕的形态。而且”缩到刚好装得下”消除了任何余量,等于主动制造了抖动。所以真实规则的两个”保守”设计各有分工:1/4 的门槛提供迟滞,防止刚涨完又被缩;只缩一半提供缓冲,防止刚缩完又要涨。
-
因为搬栈的前提是”精确认知哪些字节是指针”。保守式 GC 分不清栈上一个字长到底是
int64还是指针,只能一律当成可能的指针去试;在这种认知水平下搬动任何对象,都可能漏改一个真指针(悬垂)或错改一个整数(数据损坏),所以保守式 GC 只能放弃移动。Go 靠的是编译器为每个函数、每个安全点生成的指针位图(stack map)——它精确记录”这一帧里哪几个字长是指针”。这套元数据被两个机制共用:搬栈时用它找指针并加 delta,GC 扫描栈时用它找根。 一次投入,两处收益,这也是为什么 Go 能同时做到”能搬”和”精确 GC”。 -
三步走。第一步,看 goroutine 总数是否真有那么多:如果数量也从几千涨到了几万,那问题在 goroutine 泄漏,不在栈本身。第二步,抓一份 goroutine 的栈 dump,按调用栈聚合,找”栈特别长”或”带着大局部数组”的那批——5000 个 goroutine 吃掉 2GB,平均每个 400KB,这绝不是普通 goroutine 的形态,几乎必然有一批卡在深递归或栈上大数组的函数里。第三步,才考虑”该收缩而没收缩”:收缩要等 GC、且要求用量跌到 1/4 以下,所以回落是迟滞的,但 GC 总会发生,不会永远不缩。补充一个交叉验证:看 GC 的标记阶段耗时是否异常偏长——如果堆不大而扫栈很贵,就是栈大的直接证据。
-
因为基准测试只能证明”某个实现慢”,而两个独立社区得出同一结论,证明的是”这个设计本身有问题”。Go 和 Rust 的实现者不同、语言不同、编译器后端不同、目标场景也有差异,但它们都撞上了同一个 hot split:循环里反复调用一个恰好卡在段边界的函数,导致每轮一次分配加一次释放。当变量(实现者、语言、工具链)全都变了,而失败模式不变,那么失败的原因大概率在自变量——也就是”分段栈”这个方案本身。 这也是工程判断里很硬的一条原则:单个案例可以归因于”没做好”,重复出现的相同失败要归因于”这么做不行”。
-
没有逃逸分析,就不能安全搬栈,B 直接不可行。可选的替代方案:一是”混合方案”——栈小用连续、栈大到一定程度转成分段(但依然有边界问题);二是给栈设定一个足够大的固定上界(回到线程栈的老路,牺牲 goroutine 便宜性);三是像 Erlang 那样从语言层面禁止指针逃逸。实际上 Go 选择的是”先做逃逸分析,再上连续栈”,说明连续栈的可行性是逃逸分析买来的,二者不可分割。
-
摊还分析的核心公式:增长因子为 k 时,总复制成本约为
1/(k-1)倍的新栈大小。k=1.5 时是 2 倍、k=2 时是 1 倍、k=3 时是 0.5 倍——k 越大,摊还越低,但内存浪费也越大(一次扩容后,最多有 (k-1)/k 的空间暂时闲置)。k=2 是”摊还成本 = 恰好等于一次栈大小”的经典平衡点:翻倍增长摊还 O(1),且不会像 k=3 那样造成明显的空间浪费。这是数据结构里”几何增长”的通用结论,vector、ArrayList、slice 的扩容都遵循同一套逻辑。 -
不会立刻收缩。Go 的收缩阈值是”栈使用率低于 1/4 时才考虑收缩”,而且要经过 GC 观察、确认持续低水位才会搬小栈。因为收缩也是一次搬栈(有复制和指针扫描成本),如果刚收缩完又涨回来,就变成”来回搬”的新版热分裂。所以收缩比增长更保守,宁可多占一会内存,也不频繁搬家。
-
摊还分析成立的前提是”贵操作被大量便宜操作稀释”——动态数组扩容是”偶尔 O(n)、平时 O(1)“,摊下来 O(1)。热分裂恰恰相反:malloc/free 不是”偶尔”,是”每一轮循环都必然发生”,成本不存在被稀释的空间,摊还分析无从谈起。一句话:摊还救的是”偶尔贵”,热分裂是”每轮都贵”。