协程栈详细布局
我们前面说到,在创建一个协程时就为其创建了一个初始的栈,用来执行函数调用。协程栈的大概布局情况如下:
这里不仅弄出了stackGuard,还弄了一个stackLimit,至于它们有什么用途,我们会在下面仔细描述。
扩容
我们前面了解到,链接器在每个函数调用的开始部分会增加一段代码,用于检测是否需要进行栈扩容。为此,我们有以下几个问题需要解决:
- 当前函数需要占用的栈空间
- 栈空间不足的判断条件
- 如何进行扩容
我们接下来逐个击破。
计算函数栈空间
一个函数占用的栈空间主要由以下几个部分组成:
- 本地变量
- 函数调用其他函数的参数
- 函数调用其他参数的返回值
这里注意的是函数调用时,参数和返回值使用的空间也计算在调用者的使用空间上。
写了一个简单测试程序,反汇编该程序可以清晰看出每个函数需要的栈大小:
可以看到,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的话,确实没有必要再去给它分配新的栈。
为此,写了个简单的测试程序并对其进行反汇编:
对函数f的反汇编结果如下:
可以看到,由于f使用的的堆栈很小(80B),在程序的开始部分并没有出现栈扩容的判断。
函数栈适中,需要判断
这种情况就真的需要插入额外的判断指令。
这时候判断需要栈扩容的条件是函数延伸的栈不应该超过stackLimit的限制,转化为数学表达:
$$sp-frameSize < stackLimit$$
=>
$$sp-frameSize < stackGuard-stackSmall$$
=>
$$sp-frameSize + stackSmall < stackGuard$$
=>
$$sp-frameSize + 128 < stackGuard$$
类似上面,写了另外一个测试程序并进行反汇编:
可以看到,这里面的判断逻辑与我们前面提到的是相符合的。
这里其实遗留了两个疑问:
- %fs:0xfffffffffffffff8,%rcx这里存储的到底是什么?猜测应该是线程相关变量,可能指的是正运行m的g;而0x10(%rcx)代表的是g的stackguard0,这样就能讲通这个比较的意义。关于%fs寄存器的相关说明可参考 http://www.airs.com/blog/archives/44,写的很不错;
- 这个栈容量检测的汇编代码是谁插入的?根据一些介绍说是linker,有待仔细思考。
栈空间扩容
对协程的栈进行扩容必然是原有堆栈空间不足,因此,我们首先需要切换到该线程的堆栈上来调用扩容函数。否则就变成了鸡生蛋和蛋生鸡的问题了:要调用新函数来进行栈扩容,而调用新函数又需要新的栈。
其次,在新的栈空间申请成功后,我们还需要将现有栈的内容全部拷贝过去。拷贝完成后还得继续现有的函数流程走下去(我们需要能够从线程堆栈切换回协程堆栈)。因此,在调用扩容函数时需要将一些当前的运行环境保存下来。
让我们接下来看看具体实现:
最终调用了newstack来进行实际的栈扩容,让我们继续深入看看栈扩容到底如何实现: