栈扩容

协程堆栈扩容

协程栈详细布局

我们前面说到,在创建一个协程时就为其创建了一个初始的栈,用来执行函数调用。协程栈的大概布局情况如下:

这里不仅弄出了stackGuard,还弄了一个stackLimit,至于它们有什么用途,我们会在下面仔细描述。

扩容

我们前面了解到,链接器在每个函数调用的开始部分会增加一段代码,用于检测是否需要进行栈扩容。为此,我们有以下几个问题需要解决:

  • 当前函数需要占用的栈空间
  • 栈空间不足的判断条件
  • 如何进行扩容

我们接下来逐个击破。

计算函数栈空间

一个函数占用的栈空间主要由以下几个部分组成:

  • 本地变量
  • 函数调用其他函数的参数
  • 函数调用其他参数的返回值

这里注意的是函数调用时,参数和返回值使用的空间也计算在调用者的使用空间上。

写了一个简单测试程序,反汇编该程序可以清晰看出每个函数需要的栈大小:

package main

func f(a, b int) (int, int) {
    sum := 0
    elements := make([]int, 100)
    for _, i := range elements {
        sum += i
    }
    sum += a
    sum += b
    return sum, a + b
}

func main() {
    f(1, 2)
}
go tool compile -S test.go

"".f t=1 size=176 value=0 args=0x20 locals=0x320
    0x0000 00000 (test.go:9)    TEXT    "".f(SB), $800-32
    0x0000 00000 (test.go:9)    MOVQ    (TLS), CX
    0x0009 00009 (test.go:9)    LEAQ    -672(SP), AX
    0x0011 00017 (test.go:9)    CMPQ    AX, 16(CX)

"".main t=1 size=64 value=0 args=0x0 locals=0x20
    0x0000 00000 (test.go:24)   TEXT    "".main(SB), $32-0
    0x0000 00000 (test.go:24)   MOVQ    (TLS), CX
    0x0009 00009 (test.go:24)   CMPQ    SP, 16(CX)

可以看到,f函数的栈大小是800字节,因为在f内部申请了大小为100的数组。而main函数的栈大小是32字节,因为它只需要向f传递两个int,以及接收两个int的返回值。

判断栈空间不足

上一节我们阐述了函数的栈空间计算方法。接下来我们就要看看golang如何判断栈空间不足了,这也是比较精彩的部分。

golang检测栈扩容的主要条件是SP是否达到了栈保护的边界,也就是我们前面途中的StackGuard。基本思想是如果SP小于StackGuard,那么需要进行栈扩容,否则无需栈扩容。

但是这里有个问题是:因为每个函数调用都会作这样的检查,对于函数调用的开销会增加,而且这种增加是无条件的。

为了避免该问题,Golang作了优化:对于极小的,明显不用扩容就不做检查了。我们前面看到的stackLimit就开始发挥作用了。

函数栈极小,无需扩容

这种情况下,该函数需要的栈空间极小,这时候压根不需要作检查,如下图:

通过上图看到,如果函数f需要的栈大小小于stackSmall=128B, 且此时sp还是小于stackguard,那么这时候认为它还是安全的,无需进行栈扩容。
这点也很好理解:如果当前sp还位于安全区域,而且此时调用的函数需要的栈很小,不会触及stack.lo的话,确实没有必要再去给它分配新的栈。

为此,写了个简单的测试程序并对其进行反汇编:

package main

func f(a, b int) (int, int) {
    sum := 0
    elements := make([]int, 10)
    for _, i := range elements {
        sum += i
    }
    sum += a
    sum += b
    return sum, a + b
}

func main() {
    f(1, 2)
}

对函数f的反汇编结果如下:

"".f t=1 size=128 value=0 args=0x20 locals=0x50
    0x0000 00000 (test.go:9)    TEXT    "".f(SB), $80-32
    0x0000 00000 (test.go:9)    SUBQ    $80, SP
    0x0004 00004 (test.go:9)    MOVQ    "".a+88(FP), R9
    0x0009 00009 (test.go:9)    MOVQ    "".b+96(FP), R8
    0x000e 00014 (test.go:9)    FUNCDATA    $0, gclocals·a8eabfc4a4514ed6b3b0c61e9680e440(SB)
    0x000e 00014 (test.go:9)    FUNCDATA    $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
    0x000e 00014 (test.go:12)   MOVQ    $0, DX
    0x0010 00016 (test.go:13)   LEAQ    "".autotmp_0004(SP), DI
   ......

可以看到,由于f使用的的堆栈很小(80B),在程序的开始部分并没有出现栈扩容的判断。

函数栈适中,需要判断

这种情况就真的需要插入额外的判断指令。

这时候判断需要栈扩容的条件是函数延伸的栈不应该超过stackLimit的限制,转化为数学表达:

$$sp-frameSize < stackLimit$$
=>

$$sp-frameSize < stackGuard-stackSmall$$
=>

$$sp-frameSize + stackSmall < stackGuard$$
=>

$$sp-frameSize + 128 < stackGuard$$

类似上面,写了另外一个测试程序并进行反汇编:

package main

func f(a, b int) (int, int) {
    sum := 0
    elements := make([]int, 100)
    for _, i := range elements {
        sum += i
    }
    sum += a
    sum += b
    return sum, a + b
}

func main() {
    f(1, 2)
}
"".f t=1 size=176 value=0 args=0x20 locals=0x320
    0x0000 00000 (test.go:9)    TEXT    "".f(SB), $800-32
    0x0000 00000 (test.go:9)    MOVQ    (TLS), CX
    // 就是sp-800+128与stackGuard对比
    0x0009 00009 (test.go:9)    LEAQ    -672(SP), AX
    0x0011 00017 (test.go:9)    CMPQ    AX, 16(CX)
    0x0015 00021 (test.go:9)    JLS 158

可以看到,这里面的判断逻辑与我们前面提到的是相符合的。

这里其实遗留了两个疑问:

  1. %fs:0xfffffffffffffff8,%rcx这里存储的到底是什么?猜测应该是线程相关变量,可能指的是正运行m的g;而0x10(%rcx)代表的是g的stackguard0,这样就能讲通这个比较的意义。关于%fs寄存器的相关说明可参考 http://www.airs.com/blog/archives/44,写的很不错;
  2. 这个栈容量检测的汇编代码是谁插入的?根据一些介绍说是linker,有待仔细思考。

栈空间扩容

对协程的栈进行扩容必然是原有堆栈空间不足,因此,我们首先需要切换到该线程的堆栈上来调用扩容函数。否则就变成了鸡生蛋和蛋生鸡的问题了:要调用新函数来进行栈扩容,而调用新函数又需要新的栈。

其次,在新的栈空间申请成功后,我们还需要将现有栈的内容全部拷贝过去。拷贝完成后还得继续现有的函数流程走下去(我们需要能够从线程堆栈切换回协程堆栈)。因此,在调用扩容函数时需要将一些当前的运行环境保存下来。

让我们接下来看看具体实现:

TEXT runtime·morestack(SB),NOSPLIT,$0-0
    // Cannot grow scheduler stack (m->g0).
    get_tls(CX)
    MOVQ    g(CX), BX
    MOVQ    g_m(BX), BX
    MOVQ    m_g0(BX), SI
    CMPQ    g(CX), SI
    JNE     2(PC)
    INT     $3

    // Cannot grow signal stack (m->gsignal).
    MOVQ    m_gsignal(BX), SI
    CMPQ    g(CX), SI
    JNE     2(PC)
    INT     $3

    // Called from f.
    // Set m->morebuf to f's caller.
    // ??? what does this do?
    MOVQ    8(SP), AX       // f's caller's PC
    MOVQ    AX, (m_morebuf+gobuf_pc)(BX)
    LEAQ    16(SP), AX      // f's caller's SP
    MOVQ    AX, (m_morebuf+gobuf_sp)(BX)
    get_tls(CX)
    // SI is current go-routine
    // 将当前申请扩容stack的协程记录在m_morebuf中
    // 这样后面切换到m.g0协程分配堆栈成功后知道返回到
    // 哪个协程继续执行。
    MOVQ    g(CX), SI
    MOVQ    SI, (m_morebuf+gobuf_g)(BX)

    // 将申请分配新堆栈的协程执行环境记录下来
    // 这样下次返回该协程时知道从哪继续执行
    // why 0(SP) is f's PC?
    MOVQ    0(SP), AX // f's PC
    MOVQ    AX, (g_sched+gobuf_pc)(SI)
    MOVQ    SI, (g_sched+gobuf_g)(SI)
    LEAQ    8(SP), AX // f's SP
    MOVQ    AX, (g_sched+gobuf_sp)(SI)
    // what does DX store?
    MOVQ    DX, (g_sched+gobuf_ctxt)(SI)
    MOVQ    BP, (g_sched+gobuf_bp)(SI)

    // Call newstack on m->g0's stack.
    // 切换到线程的调度协程和线程堆栈
    MOVQ    m_g0(BX), BX
    // switch to m.g0
    MOVQ    BX, g(CX)
    // what does this mean?
    // 切换到线程堆栈
    MOVQ    (g_sched+gobuf_sp)(BX), SP
    // runtime.newstack() never return
    CALL    runtime·newstack(SB)
    MOVQ    $0, 0x1003  // crash if newstack returns
    RET

最终调用了newstack来进行实际的栈扩容,让我们继续深入看看栈扩容到底如何实现:

func newstack() {
    // gp是申请堆栈扩容的协程
    gp := thisg.m.curg

    ......

    // Allocate a bigger segment and move the stack.
    oldsize := int(gp.stackAlloc)
    // 新的栈大小是原来的两倍
    newsize := oldsize * 2
    if uintptr(newsize) > maxstacksize {
        print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")
        throw("stack overflow")
    }

    casgstatus(gp, _Gwaiting, _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, uintptr(newsize))
    if stackDebug >= 1 {
        print("stack grow done\n")
    }
    casgstatus(gp, _Gcopystack, _Grunning)
    gogo(&gp.sched)
}

// 申请新的栈空间并将原有栈数据拷贝至这里
func copystack(gp *g, newsize uintptr) {
    if gp.syscallsp != 0 {
        throw("stack growth not allowed in system call")
    }
    old := gp.stack
    if old.lo == 0 {
        throw("nil stackbase")
    }
    // 原有堆栈使用的空间
    used := old.hi - gp.sched.sp

    // allocate new stack
    // newstkbar是什么?
    // 0xfc是什么?
    new, newstkbar := stackalloc(uint32(newsize))
    if stackPoisonCopy != 0 {
        fillstack(new, 0xfd)
    }

    ......

    // adjust pointers in the to-be-copied frames
    // 这里主要调整g的一些调度相关参数
    // 如果它们存储在老的栈上面,需要将它们拷贝到新栈上
    var adjinfo adjustinfo
    adjinfo.old = old
    adjinfo.delta = new.hi - old.hi
    gentraceback(^uintptr(0), ^uintptr(0), 0, gp, 0, nil, 0x7fffffff, adjustframe, noescape(unsafe.Pointer(&adjinfo)), 0)

    // adjust other miscellaneous things that have pointers into stacks.
    adjustctxt(gp, &adjinfo)
    adjustdefers(gp, &adjinfo)
    adjustpanics(gp, &adjinfo)
    adjustsudogs(gp, &adjinfo)
    adjuststkbar(gp, &adjinfo)

    // copy the stack to the new location
    // 0xfb又是什么?
    if stackPoisonCopy != 0 {
        fillstack(new, 0xfb)
    }
    // 数据拷贝,老的堆栈数据拷贝到新堆栈
    memmove(unsafe.Pointer(new.hi-used), unsafe.Pointer(old.hi-used), used)

    // copy old stack barriers to new stack barrier array
    newstkbar = newstkbar[:len(gp.stkbar)]
    copy(newstkbar, gp.stkbar)

    // Swap out old stack for new one
    // 切换到新堆栈上工作
    gp.stack = new
    gp.stackguard0 = new.lo + _StackGuard 
    gp.sched.sp = new.hi - used
    oldsize := gp.stackAlloc
    gp.stackAlloc = newsize
    gp.stkbar = newstkbar

    // 释放老的堆栈
    if stackPoisonCopy != 0 {
        fillstack(old, 0xfc)
    }
    stackfree(old, oldsize)
}

func fillstack(stk stack, b byte) {
    for p := stk.lo; p < stk.hi; p++ {
        *(*byte)(unsafe.Pointer(p)) = b
    }
}

参考资料

results matching ""

    No results matching ""