Go 协程堆栈设计进化之旅

本文详细讲述了 Golang 中 , 堆栈设计理念以及演变过程 。 描述了从 Segment Stack 到 Contiguous Stack 、初始堆栈大小从 8Kb 到 2Kb 的原因 。
Go 协程堆栈设计进化之旅文章插图
Illustration created for “A Journey With Go”, made from the original Go Gopher, created by Renee French.
:information_source: 文章基于 Go 1.12.
Go 提供了一个轻量且智能的协程管理机制 。 轻量是因为协程堆栈初始化只有 2Kb , 智能是因为协程堆栈可以根据我们的需要自动增加 / 减少 。
堆栈的大小定义 , 我们可以在这里找到 runtime/stack.go:
// The minimum size of stack used by Go code_StackMin = 2048我们需要注意的是 , 它曾经在以下版本的时间里进行过优化:

  • Go 1.2: 协程堆栈从 4Kb 增长到 8Kb.
  • Go 1.4: 协程堆栈从 8Kb 减少到 2Kb.
协程堆栈大小的变化主要是因为堆栈分配策略的变化 。 在文章后面我们一会儿将会提到这个问题 。
默认的堆栈大小有的时候并不能满足我们运行的程序 。 这时候 Go 就会自动的调整堆栈大小 。
动态堆栈大小如果 Go 可以自动的增长栈空间大小 , 那么也意味着他可以决定堆栈大小到底有没有必要需要修改 。 让我们看一个例子 , 分析一下它是怎么工作的:
func main() {a := 1b := 2r := max(a, b)println(`max: `+strconv.Itoa(r))}func max(a int, b int) int {if a >= b {return a}return b}这个例子只是计算了两个数字中最大的一个 。 为了了解 Go 是如何管理协程堆栈分配的 , 我们可以看下 Go 的编译流程代码 ,通过命令: go build -gcflags -S main.go . 输出 —— 我只保留了与堆栈有关的一些行 —— 它给我们一些有趣的信息 , 这些内容展示了 Go 都做了什么:
"".main STEXT size=186 args=0x0 locals=0x700x0000 00000 (/go/src/main.go:5)TEXT"".main(SB),ABIInternal, $112-0[...]0x00b0 00176 (/go/src/main.go:5) CALLruntime.morestack_noctxt(SB)[...]0x0000 00000 (/go/src/main.go:13)TEXT"".max(SB), NOSPLIT|ABIInternal, $0-24有两条指令涉及到栈大小的更改:- CALL runtime.morestack_noctxt: 这个方法会在需要的时候增加堆栈大小 。 -NOSPLIT: 这条指令的意味着堆栈不需要溢出检测 , 他与指令//go:nosplit .比较相似 。 我们看到这个方法: runtime.morestack_noctxt, 他会调用 runtime/stack.go 中的 newstack 方法:
func newstack() {[...]// Allocate a bigger segment and move the stack.oldsize := gp.stack.hi - gp.stack.lonewsize := oldsize * 2if newsize > maxstacksize {print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")throw("stack overflow")}// The goroutine must be executing in order to call newstack,// so it must be Grunning (or Gscanrunning).casgstatus(gp, _Grunning, _Gcopystack)// The concurrent GC will not scan the stack while we are doing the copy since// the gp is in a Gcopystack status.copystack(gp, newsize, true)if stackDebug >= 1 {print("stack grow done\n")}casgstatus(gp, _Gcopystack, _Grunning)}首先根据 gp.stack.hi 和 gp.stack.lo 的边界来计算堆栈的大小 , 他们是指向堆栈头部和尾部的指针 。
type stack struct {lo uintptrhi uintptr}然后堆栈大小被乘以 2 倍 , 如果它没有达到最大值的话 —— 最大值与系统架构有关 。
// Max stack size is 1 GB on 64-bit, 250 MB on 32-bit.// Using decimal instead of binary GB and MB because// they look nicer in the stack overflow failure message.if sys.PtrSize == 8 {maxstacksize = 1000000000} else {maxstacksize = 250000000}现在我们已经了解了运行机制 , 我们来写个简单的例子来验证以上的内容 。 为了 debug , 我们需要设置 stackDebug 常量 , 它在上面 newstack 的方法里会打印一些 debug 信息 , 运行:
func main() {var x [10]inta(x)}//go:noinlinefunc a(x [10]int) {println(`func a`)var y [100]intb(y)}//go:noinlinefunc b(x [100]int) {println(`func b`)var y [1000]intc(y)}//go:noinlinefunc c(x [1000]int) {println(`func c`)}【Go 协程堆栈设计进化之旅】//go:noinline 指令是为了避免编译时把所有的方法都放到一行 。 如果都放到一行的话 , 我们将看不到每个方法开始时候的堆栈动态增长 。
下面是一部分的 debug 日志:
runtime: newstack sp=0xc00002e6d8 stack=[0xc00002e000, 0xc00002e800]stack grow donefunc aruntime: newstack sp=0xc000076888 stack=[0xc000076000, 0xc000077000]stack grow doneruntime: newstack sp=0xc00003f888 stack=[0xc00003e000, 0xc000040000]stack grow doneruntime: newstack sp=0xc000081888 stack=[0xc00007e000, 0xc000082000]stack grow donefunc bruntime: newstack sp=0xc0000859f8 stack=[0xc000082000, 0xc00008a000]func c我们可以看到堆栈一共有 4 次增长 。 其实 , 方法开始会将堆栈增长到它需要的大小 。 就像我们在代码中看到的 , 堆栈的边界定义了堆栈的大小 , 所以我们可以计算每一个新的堆栈的大小 —— newstack stack=[...] 指令提供了当前堆栈边界的指针: