栈空间管理的基本逻辑

go 语言通过 goroutine 提供了并发编程支持,goroutine 是 go 运行库的功能,而不是操作系统线程实现的,goroutine 可以被理解成一个用户态的线程。

既然 goroutine 是由 go 运行库管理的,那么 go 运行库也需要为每个 goroutine 创建并管理相应的栈空间,为每个 goroutine 分配的栈空间不能太大,否则 goroutine 开的太多会浪费大量空间,也不能太小,会导致栈溢出。go 语言选择的栈空间管理方式是,一开始给一个比较小的空间,随着需要自动增长。当 goroutine 不需要那么大的空间时,占空间也要自动缩小。

分段栈 segment stacks

在 go1.4 之前,go 使用分段栈。

分段栈实现了一种不连续但是可以持续增长的栈,开始时,栈只要一个段,当需要更多的栈空间时,会分配一个新的段,和上一个栈双向连接。这样,一个栈就是多个双向连接的段所组成的。当新分配的段使用完毕后,新段会被释放掉。

分段栈实现了栈的按需收缩,在增加新分段时也不需要对原有分段中的数据进行拷贝,使得 goroutine 的使用代价非常低廉。

分段栈的好处是可以按需增长,空间利用率比较高,然而分段栈在某些情况下也存在一定的瑕疵。当一个段即将用尽,这是使用 for 循环执行一个比较耗空间的函数,会导致函数执行时 goroutine 进行段的分配,而执行完成返回时,进行段的销毁,这样就会导致在循环中出现多次栈的扩容和收缩,造成很大的性能损失,这种情况被称作栈分裂(stack split)。

连续栈 contiguous stacks

go1.4 推出了连续栈,连续栈使用了另一种策略,不再把栈分成一段一段的。当栈空间不够时,直接 new 一个 2 倍大的栈空间,并将原先空间中的数据拷贝到新的栈空间中,而后销毁旧栈。这样当出现栈空间触及边界时,不会产生栈分裂的情况。

继续假设当前栈空间即将用尽,并且需要在 for 循环中执行一个比较消耗空间的函数。当该函数执行时。栈空间发生了扩容,变成原先的 2 倍,函数执行完成一次后,栈空间的使用量缩小回执行前的大小,但是栈空间的使用量并没有小于栈大小的 1/4,不会触发栈收缩,所以在整个 for 循环执行过程中,不会反复触发栈空间的收缩扩容。

相比于分段栈,连续栈避免了某些场景下栈空间的频繁伸缩。有一点需要注意的是,连续栈的收缩也是需要重新申请一段空间(原先的 1/2 大小),并进行栈拷贝的操作的。

参考

Go 的栈空间管理