分享
  1. 首页
  2. 文章

协程栈扩容

丁凯 · · 3844 次点击 · · 开始浏览
这是一个创建于 的文章,其中的信息可能已经有所发展或是发生改变。

协程栈详细布局

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

这里不仅弄出了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
 }
}

参考资料

https://docs.google.com/document/d/1wAaf1rYoM4S4gtnPh0zOlGzWtrZFQ5suE8qr2sD8uWQ/pub

有疑问加站长微信联系(非本文作者)

本文来自:知乎专栏

感谢作者:丁凯

查看原文:协程栈扩容

入群交流(和以上内容无关):加入Go大咖交流群,或添加微信:liuxiaoyan-s 备注:入群;或加QQ群:692541889

关注微信
3844 次点击
被以下专栏收入,发现更多相似内容
添加一条新回复 (您需要 后才能回复 没有账号 ?)
  • 请尽量让自己的回复能够对别人有帮助
  • 支持 Markdown 格式, **粗体**、~~删除线~~、`单行代码`
  • 支持 @ 本站用户;支持表情(输入 : 提示),见 Emoji cheat sheet
  • 图片支持拖拽、截图粘贴等方式上传

用户登录

没有账号?注册
(追記) (追記ここまで)

今日阅读排行

    加载中
(追記) (追記ここまで)

一周阅读排行

    加载中

关注我

  • 扫码关注领全套学习资料 关注微信公众号
  • 加入 QQ 群:
    • 192706294(已满)
    • 731990104(已满)
    • 798786647(已满)
    • 729884609(已满)
    • 977810755(已满)
    • 815126783(已满)
    • 812540095(已满)
    • 1006366459(已满)
    • 692541889

  • 关注微信公众号
  • 加入微信群:liuxiaoyan-s,备注入群
  • 也欢迎加入知识星球 Go粉丝们(免费)

给该专栏投稿 写篇新文章

每篇文章有总共有 5 次投稿机会

收入到我管理的专栏 新建专栏