协程栈详细布局
我们前面说到,在创建一个协程时就为其创建了一个初始的栈,用来执行函数调用。协程栈的大概布局情况如下:
这里不仅弄出了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
可以看到,这里面的判断逻辑与我们前面提到的是相符合的。
这里其实遗留了两个疑问:
- %fs:0xfffffffffffffff8,%rcx这里存储的到底是什么?猜测应该是线程相关变量,可能指的是正运行m的g;而0x10(%rcx)代表的是g的stackguard0,这样就能讲通这个比较的意义。关于%fs寄存器的相关说明可参考 http://www.airs.com/blog/archives/44,写的很不错;
- 这个栈容量检测的汇编代码是谁插入的?根据一些介绍说是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有疑问加站长微信联系(非本文作者)