Golang中Stack的管理

栈的演变

在 Go1.13之前的版本,Golang 栈管理是使用的分段栈(Segment Stacks)机制来实现的,由于sgement stack 存在 热分裂(hot split)的问题,后面版本改为采用连续栈(Contiguous stacks机制(说明)。

分段栈(Segment Stack)

分段栈是指开始时只有一个stack,当需要更多的 stack 时,就再去申请一个,然后将多个stack 之间用双向链接连接在一起。当使用完成后,再将无用的 stack 从链接中删除释放内存。

segment stack

可以看到这样确实实现了stack 按需增长和收缩,在增加新stack时不需要拷贝原来的数据,系统使用率挺高的。但在一定特别的情况下会存在 热分裂(hot split) 的问题。

当一个 stack 即将用完的时候,任意一个函数都会导致堆栈的扩容,当函数执行完返回后,又要触发堆栈的收缩。如果这个操作是在一个for语句里执行的话,则过多的malloc 和 free 则会导致系统资源开销非常的大。

如果你对 Stack Frame 不了解的话,可能先阅读一下 Go 语言机制之栈和指针

连续栈(Contiguous stacks)

Contiguous stacks 扩容与收缩

连续栈使用了另一种管理机制,每当一个stack 空间不够的时候,直接再申请一个2倍大小的空间,然后再将stack数据拷贝过去,同时修改指向原来stack 的指针到新stack,最后再将旧stack删除。

这种机制可以在当stack 空间快用尽的时候,避免在for语句里频繁触发扩容的问题。也正是官方采用这种机制的原因。

扩容

函数 newstack()。在编译时调用的是 runtime.morestack() 函数。

当G的堆栈空间不够用时,系统会再申请一块两倍大小(源码)的空间,然后将原堆栈数据拷贝过去,再删除原来的堆栈。

步骤

  1. 申请两倍大小空间
  2. 修改G的状态,将 _Grunning 改为 _Gcopystack,函数 casgstatus()
  3. 拷贝旧数据到新堆栈 copystack(),并调整指针到新堆栈地址 gentraceback(),最后释放旧堆栈 stackfree()
  4. 再恢复G的状态,将 _Gcopystack_Grunning,函数 casgstatus()

收缩

函数 shrinkstack()

当一个 G 占用的stack非常大,后期却使用很少的stack,这时候则会有大量的stack处于空间状态,我们需要对空间进行收缩,以释放资源。

收缩原则

  • 在GC期间,如果一个goroutine未使用stack的大小占用超过X% ,则需要将其复制到一个small stack中。当需要的时候再进行扩容。
  • 在GC期间,如果一个goroutine未使用stack的大小占用超过X%,将其stack guard 降低到一个较小的值。如果它在下一次GC时没有受到保护,则将其复制到一个small stack 中。

目前的状态是在GC期间,如果一个goroutine使用大小小于其stack的1/4,则释放stack 底部的1/2。同样调用的是 copystack() 函数,此函数内会进行新栈的创建。

Stack frame layout

Stack Frame 是指函数运行时占用的内存空间,是栈上的数据集合,它包括:

  • Local variables
  • Saved copies of registers modified by subprograms that could need restoration
  • Argument parameters
  • Return address

FPSPPC ,SB

  • FP: Frame Pointer– Points to the bottom of the argument list
  • SP: Stack Pointer– Points to the top of the space allocated for local variables
  • PC: Program Counter –  jumps and branches
  • SB: Static base pointer – global symbols
ch3-3-arch-amd64-02.ditaa

所有用户空间的数据都可以通过FP/SP(局部数据、输入参数、返回值)和SB(全局数据)访问。 通常情况下,不会对SB/FP寄存器进行运算操作,通常情况以会以SB/FP/SP作为基准地址,进行偏移解引用 等操作。

伪SP是一个比较特殊的寄存器,因为还存在一个同名的SP真寄存器。真SP寄存器对应的是栈的顶部,一般用于定位调用其它函数的参数和返回值。

当需要区分伪寄存器和真寄存器的时候只需要记住一点:伪寄存器一般需要一个标识符和偏移量为前缀,如果没有标识符前缀则是真寄存器。比如(SP)+8(SP)没有标识符前缀为真SP寄存器,而a(SP)b+8(SP)有标识符为前缀表示伪寄存器。

// Stack frame layout
//
// (x86)
// +------------------+
// | args from caller |
// +------------------+ <- frame->argp
// |  return address  |
// +------------------+
// |  caller's BP (*) | (*) if framepointer_enabled && varp < sp
// +------------------+ <- frame->varp
// |     locals       |
// +------------------+
// |  args to callee  |
// +------------------+ <- frame->sp
//
// (arm)
// +------------------+
// | args from caller |
// +------------------+ <- frame->argp
// | caller's retaddr |
// +------------------+ <- frame->varp
// |     locals       |
// +------------------+
// |  args to callee  |
// +------------------+
// |  return address  |
// +------------------+ <- frame->sp

可以看到 x86arm 架构布局是不一样的。目前来说,我们一般只关注 x86 架构布局就可以了,必须arm还不是主流。

调用栈

这里以一个函数调用过程A->B->C为例了来解释调用栈过程

v2-03c1e190354612a14c039764ca9f7c4f_720w

分配从高到低顺序进行。

推荐阅读针对 heap 的GC: Go:内存管理与内存清理,

参考