3.5 Go 中的分段栈和连续栈的区别?

分段栈

在 Go 1.3 版本之前 ,使用的栈结构是分段栈,随着goroutine 调用的函数层级的深入或者局部变量需要的越来越多时,运行时会调用 runtime.morestackruntime.newstack创建一个新的栈空间,这些栈空间是不连续的,但是当前 goroutine 的多个栈空间会以双向链表的形式串联起来,运行时会通过指针找到连续的栈片段。

分段栈虽然能够按需为当前 goroutine 分配内存并且及时减少内存的占用,但是它也存在一个比较大的问题:

如果当前 goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题(Hot split)。

为了解决这个问题,Go 在 1.2 版本的时候不得不将栈的初始化内存从 4KB 增大到了 8KB。后来把采用连续栈结构后,又把初始栈大小减小到了 2KB。

连续栈

连续栈可以解决分段栈中存在的两个问题,其核心原理就是每当程序的栈空间不足时,初始化一片比旧栈大两倍的新栈并将原栈中的所有值都迁移到新的栈中,新的局部变量或者函数调用就有了充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:

  1. 调用用runtime.newstack在内存空间中分配更大的栈内存空间;

  2. 使用runtime.copystack将旧栈中的所有内容复制到新的栈中;

  3. 将指向旧栈对应变量的指针重新指向新栈;

  4. 调用runtime.stackfree销毁并回收旧栈的内存空间;

copystack会把旧栈里的所有内容拷贝到新栈里然后调整所有指向旧栈的变量的指针指向到新栈, 我们可以用下面这个程序验证下,栈扩容后同一个变量的内存地址会发生变化。

package main

func main() {
    var x [10]int
    println(&x)
    a(x)
    println(&x)
}

//go:noinline
func a(x [10]int) {
    println(`func a`)
    var y [100]int
    b(y)
}

//go:noinline
func b(x [100]int) {
    println(`func b`)
    var y [1000]int
    c(y)
}

//go:noinline
func c(x [1000]int) {
    println(`func c`)
}

程序的输出可以看到在栈扩容前后,变量x的内存地址的变化:

0xc000030738
...
...
0xc000081f38