14.4 栈的拷贝与指针调整
连续栈的代价集中在一处:当一个 goroutine 的栈溢出,运行时分配一段两倍大的新栈,把旧栈整个
搬过去,再释放旧栈。搬家这一步看似就是一次 memmove,把 [old.lo, old.hi) 的字节复制到
[new.lo, new.hi)。倘若真的只是复制字节,问题就来了:栈上的变量里可能存着指向这个栈自身的
指针,比如某个局部变量取了另一个局部变量的地址。字节被原样搬到新地址后,这些指针的值没有变,
仍然指着旧栈的位置,而旧栈马上就要被回收。复制完成的那一刻,它们就成了悬垂指针。
所以栈的拷贝不是 memmove,而是 memmove 加上一遍指针调整:搬完字节之后,运行时必须
遍历新栈,把每一个原本指向旧栈区间的指针,统统加上一个固定的位移 $\delta = \mathrm{new.hi} -
\mathrm{old.hi}$,让它改指向新栈的对应位置。难点不在「怎么加」,而在「怎么知道哪些字是指针」。
栈上的字没有类型标签,一个 8 字节的字到底是指针还是恰好长得像地址的整数,运行时无从分辨。
答案来自编译器:它为每个函数在每个安全点(13.7)生成了栈映射
(stack map),用位图精确记录该函数栈帧里哪些槽位是指针。GC 扫描栈靠它,栈拷贝调整指针也靠它。
这一节就讲这件事:copystack 如何在搬家之后,借栈映射走遍新栈,把所有指向旧栈的指针、加上
那些不在栈帧里、却同样指向栈的运行时结构(gobuf、sudog、defer、panic)一并调整正确。
读完会看到,正是「栈可以移动」这条性质,反过来约束了语言:哪些指针允许指向栈、逃逸分析
(15.5)为何必须把外部持有的地址搬到堆上。
14.4.1 为什么不能只 memmove
先把问题摆清楚。考虑一段普通的 Go 代码:
| |
p 的值是 x 在栈上的地址,落在 [old.lo, old.hi) 区间内。当 g 或其更深的调用触发栈增长,
整个栈被搬到新地址,x 随之搬走,但 p 这个字里存的还是旧地址。若不修正,p 指向的已是
一段即将释放、或将被别的 goroutine 栈复用的内存。
修正的算法本身朴素:对栈上每个指针槽取出其值 $v$,若 $\mathrm{old.lo} \le v <
\mathrm{old.hi}$,就改写为 $v + \delta$。adjustpointer 就是这一句判断的实现:
| |
adjustinfo 把这趟调整需要的三样东西收在一起:旧栈区间(用来判断「是否指向旧栈」)、位移
delta、以及一个稍后会解释的 sghi:
| |
真正难的是「找到所有指针槽」。整个栈被切成一帧帧函数调用,每帧的指针布局由编译器在编译期 就已算定,运行时只需按图索骥。这张图,就是栈映射。
14.4.2 借栈映射逐帧调整
copystack 在 memmove 之后,从栈顶开始用 unwinder 一帧帧回溯,对每一帧调用
adjustframe(完整骨架见 14.4.4)。这里有一处版本变迁值得点出:早期运行时是把调整逻辑作为
回调函数传给 gentraceback 来驱动遍历的,go1.26 改用了独立的 unwinder 迭代器,遍历与调整
解耦,但「逐帧回调」的骨架未变。
adjustframe 处理单帧。它向编译器要来这一帧的三类栈映射,局部变量(locals)、参数与返回值
(args)、以及栈对象(stack objects),逐类按位图调整:
| |
adjustpointers 是真正与位图打交道的地方。它收到一段内存的起址 scanp 与一个位图 bv,
位图的第 $i$ 位为 1 表示 scanp 偏移 $i$ 个字处是指针。它只在置位的槽上动手,不去碰整数槽:
| |
到此,栈帧内的指针都已正确。但栈上的指针不止藏在栈帧里。
14.4.3 栈帧之外:那些也指向栈的结构
有几类运行时结构,本身不在栈上,却存着指向栈的指针。memmove 不会碰它们,逐帧遍历也覆盖
不到它们,必须在 copystack 里逐一显式调整。
第一类是 goroutine 的执行现场 gobuf(即 g.sched,见本章 14.1)。它保存着
sp、bp、以及 ctxt,其中 ctxt 与帧指针可能指向栈:
| |
第二类是 defer 与 panic 记录。每个 defer 结构里存着待执行函数 fn、登记时的栈指针 sp、
以及链向下一个 defer 的 link,这些都可能落在栈上:
| |
第三类最微妙,是 goroutine 阻塞在通道上时挂着的 sudog(10.3)。
当一个 goroutine 在 ch <- v 或 <-ch 上阻塞,运行时用一个 sudog 把它挂到通道的等待队列,
sudog.elem 指向收发数据所在的内存,而这块内存常常就在阻塞者的栈上。栈一搬,sudog.elem
也要跟着调整:
| |
14.4.4 并发的边界:_Gcopystack 与 sudog 的同步
拷贝期间,这个 goroutine 的栈正处于「搬到一半」的中间态,指针有的已调整、有的还没。若此时 并发 GC 来扫描这个栈,或别的 goroutine 通过通道往它的栈上写数据,就会读到或写坏不一致的状态。 运行时用两道闸把这段临界区围起来。
对 GC,办法是状态机。newstack 在调用 copystack 前,把 goroutine 从 _Grunning 切到
_Gcopystack,拷贝完再切回去:
| |
并发 GC 扫描栈前会检查 goroutine 状态,见到 _Gcopystack 便知道这个栈正在搬家,不去碰它。
对并发收发,办法是锁与 CAS。当 goroutine 已经把自己挂上通道等待队列、并释放了通道锁
(activeStackChans 为真),别的 goroutine 随时可能往它栈上的收发槽里写值。copystack 此时
不能莽撞地搬:它先用 findsghi 找出栈上最高的 sudog.elem 地址,记入 adjinfo.sghi,再用
syncadjustsudogs 锁住相关通道、同步地调整并搬运那一段,期间对可能被并发写的槽位改用 CAS
来平移指针(这正是 14.4.2 里 useCAS 的由来)。这一层小心,只为收发槽这一小块栈,代价不大,
却堵住了「调整指针」与「并发写槽」之间的竞争。
把以上串起来,copystack 的骨架是这样:
| |
顺序是有讲究的。adjustctxt、adjustdefers、adjustpanics 必须排在逐帧遍历之前,因为
unwinder 回溯新栈时会用到 g.sched 与 defer 链里的指针,它们得先正确才能驱动遍历。
14.4.5 拷贝的约束反过来塑造了语言
走到这里,可以回答一个更深的问题:为什么 Go 里「只有栈上分配的指针,才允许指向栈」?
因为栈会移动,而移动时能被找到并调整的指针,只有栈映射覆盖得到的那些,也就是栈帧内的
指针、加上运行时显式登记的那几类结构(gobuf、sudog、defer、panic)。一个指向某 goroutine
栈的指针,如果存在堆上的某个对象里,或被另一个 goroutine 的栈持有,copystack 在搬家时根本
无从知道它的存在,自然无法调整它。栈一搬,它就悬垂。
这正是逃逸分析(15.5)必须做出某些判断的根因。 当编译器发现一个局部变量的地址会被外部持有,典型如:
| |
x 的地址要返回给上层,将被一个本帧之外的持有者保存。若把 x 放在栈上,一旦本栈日后增长
搬家,那个外部持有者手里的指针就会失效,而运行时没有任何办法替它修正。于是逃逸分析的结论
只能是:把 x 分配到堆上。堆对象不随栈移动,地址终生不变,外部持有者尽可放心。
换句话说,连续栈「搬家必须能调整全部指向它的指针」这条工程约束,向上传导成了一条语言层面的 分配规则:凡是地址会被栈外持有的值,必须逃逸到堆。栈的可移动性买来了「按需伸缩、无需 预留巨栈」的便宜(本章 14.1),代价则记在逃逸分析与堆分配这一侧。性能的便宜 从不白来,它总伴着复杂度在别处的重新安置。
放进谱系看,这套「搬栈加调指针」并非 Go 独有。带移动式 GC 的运行时,如 JVM 的复制式收集、 .NET 的压缩式 GC,也都要在对象移动后修正所有指向它的引用,手法同样是「靠精确的类型信息找出 指针,再统一平移」。Go 的特别之处在于它把这套机制用在了栈上,且与逃逸分析这一编译期决策 紧紧咬合,让「栈能动」与「指针总有效」这两件看似矛盾的事得以并存。
延伸阅读的文献
- The Go Authors. runtime/stack.go:copystack、adjustframe、adjustpointers、adjustsudogs、 adjustdefers、adjustctxt. https://github.com/golang/go/blob/master/src/runtime/stack.go (本节所据的一手实现,go1.26)
- Keith Randall. Contiguous stacks. Go design document, 2013. https://go.dev/s/contigstacks (连续栈取代分段栈的设计与拷贝时的指针调整动机)
- The Go Authors. runtime/runtime2.go(_Gcopystack 状态)、runtime/mgcmark.go(栈扫描与 shrinkstack). https://github.com/golang/go/tree/master/src/runtime
- The Go Authors. cmd/compile:栈映射(stack maps / liveness)的生成. https://github.com/golang/go/tree/master/src/cmd/compile/internal/liveness
- 本书 13.7 安全点分析:栈映射与安全点,指针调整与 GC 扫描共用的基础。
- 本书 15.5 逃逸分析:栈可移动性如何约束分配决策。
- 本书 10.3 收发与直接传递:sudog 与通道收发槽,
解释
adjustsudogs为何存在。
许可
© 2018-2026 The golang.design Initiative Authors. Licensed under CC-BY-NC-ND 4.0.