Golang系列 - gorountine栈管理

在学习go的过程中发现 gorountine的栈管理方式 和 传统的c/c++语言有所不同。所以本文通过整理网上资料进行学习总结。

 

栈的作用

 

首先,栈 (stack) 是一种串列形式的 数据结构。这种数据结构的特点是 后入先出 (LIFO, Last In First Out),数据只能在串列的一端 (称为:栈顶 top) 进行 推入 (push) 和 弹出 (pop) 操作。

 

:本文中所说的栈 不是专指数据结构中的栈,而是在不同场景下利用栈这种数据结构所实现的应用。例如:进程栈,线程栈,协程栈,硬件栈,内核栈,中断栈等等。栈的大小不是实时固定的,是根据程序的运行动态变化的,但都有一个最大值,本文讨论的栈大小都是指栈的最大值。

 

栈作用可以从两个方面体现:函数调用 和 多任务支持 。

 

函数调用:

不同和语言会有不同的函数调用约定,但无论是那个一种的传参格式和栈帧清理约定,都用到栈这种数据结构。因为在函数调用过程中肯定是 调用者先开辟栈帧空间被调用者后开辟栈帧空间,被调用者肯定先于调用者执行完,所以是被调用者先回退栈帧孔空间,再回退调用者的栈帧空间。这个过程刚好是个一个 后入先出 的过程,而栈刚好满足这个特性。

Golang系列 - gorountine栈管理_第1张图片

函数栈帧:函数调用经常是嵌套的,在同一时刻,栈中会有多个函数的信息。每个未完成运行的函数占用一个独立的连续区域,称作栈帧(Stack Frame)。栈帧存放着函数参数,局部变量及恢复前一栈帧所需要的数据等。

 

多任务支持:

这个很好理解,栈中可以保存一个执行单元的上下文环境。多任务切换的时候只要把当前执行单元的环境保存在自己的栈中,当再次执行的时候从任务单元的栈中恢复这个环境就可以了。这个特性是栈可以支持的,但不一定是非栈不可,而函数调用是非栈不可的。

Golang系列 - gorountine栈管理_第2张图片

在了解gorountine栈管理之前,我们先看下传统32位下Linux进程内存布局:

Golang系列 - gorountine栈管理_第3张图片

用户栈的大小是固定的,Linux中默认为8192KB,运行时内存占用超过上限,程序会崩溃掉并报告segment错误。

为了修复这个问题,我们可以通过ulimit命令调大内核参数中的stack size, 但是全线提高栈大小意味着每个进程都会提高栈的内存使用量。这样一来,你将用光所有内存,即便你的程序还尚未使用栈上的内存。

另外一种可选的解决方法则是为每个线程单独确定栈大小。这样一来你就不得不完成这样的任务:根据每个线程的需要,估算它们的栈内存的大小。这将是 创建线程的难度超出我们的期望。

这两种方案都有自己的优缺点, 前者比较简单但会影响到系统内所有的thread,后者需要开发者精确计算每个thread的大小, 负担比较高。

有没有办法既不影响所有thread又不会给开发者增加太多的负担呢? 答案当然是有的,比如: 我们可以在函数调用处插桩, 每次调用的时候检查当前栈的空间是否能够满足新函数的执行,满足的话直接执行,否则创建新的栈空间并将老的栈拷贝到新的栈然后再执行。 这个想法听起来很完美, 但当前的Linux thread模型却不能满足,实现的话只能够在用户空间实现,并且有不小的难度。

go作为一门21世纪的现代语言,定位于简单高效,充分利用多核优势,解放工程师,自然不能够少了这个特性。它使用内置的用户态运行时runtime优雅地解决了这个问题, 每个goroutine(g0除外)在初始化时stack大小都为2KB, 运行过程中会根据不同的场景做动态的调整。

 

goruntine栈管理

是今天要说的重点,当然他和进程栈、线程栈一样都是在进程的虚拟内存空间上划分的一块内存区域。只不过他的管理方式和其他的栈不同。

在大学学习C语言的时候我曾一直以为所有的栈大小都是一个默认的固定大小,我们可以通过参数来调节大小,但进程启动后就不能在更改了。所以有时当一个递归写错的时候,经常会发生栈溢出。

后来学习了go之后才发现goruntine 的栈大小是按需增长的。当有一个go函数在无限递归的时候你会发现你的内存在无限增大,而且swap分区也在增大。

 

go的发展历程中有过两个不同的对栈的管理方式:

 

分段栈:在函数运行过程中需要更大栈空间时,会自动扩大,当函数不再需要,则会释放掉。一种很极端的情况就是,某函数的执行的栈大小一直在初始栈大小边界绯徊。那么就会不断地触发分段栈的分配回收操作,可以想象这是多么蛋疼的事情。

 

连续栈:则可以避免这种问题。新的实现中,将给每个goroutine一个初始栈,当发生栈越界之后,就会重将分配一块更大的内存空间作为新的栈,将旧栈拷贝到新栈中。

 

Go1.3版本之后则使用的是continuous stack,下面将具体分析一下这种技术

 

如何捕获到函数的栈空间不足

Go语言和C不同,不是使用栈指针寄存器和栈基址寄存器确定函数的栈的。在Go的运行时库中,每个goroutine对应一个结构体G,大致相当于进程控制块的概念。这个结构体中存了stackbase和stackguard,用于确定这个goroutine使用的栈空间信息。每个Go函数调用的前几条指令,先比较栈指针寄存器跟g->stackguard,检测是否发生栈溢出。如果栈指针寄存器值超越了stackguard就需要扩展栈空间。

为了加深理解,下面让我们跟踪一下代码,并看看实际生成的汇编吧。首先写一个test.go文件,内容如下:

package main
func main() {
    main()
}

然后生成汇编文件:

go tool 6g -S test.go | head -8

可以看以输出是:

000000 00000 (test.go:3)    TEXT    "".main+0(SB),$0-0
000000 00000 (test.go:3)    MOVQ    (TLS),CX
0x0009 00009 (test.go:3)    CMPQ    SP,(CX)
0x000c 00012 (test.go:3)    JHI    ,21
0x000e 00014 (test.go:3)    CALL    ,runtime.morestack00_noctxt(SB)
0x0013 00019 (test.go:3)    JMP    ,0
0x0015 00021 (test.go:3)    NOP    ,

让我们好好看一下这些指令。(TLS)取到的是结构体G的第一个域,也就是g->stackguard地址,将它赋值给CX。然后CX地址的值与SP进行比较,如果SP大于g->stackguard了,则会调用runtime.morestack函数。这几条指令的作用就是检测栈是否溢出。

不过并不是所有函数在链接时都会插入这种指令。如果你读源代码,可能会发现#pragma textflag 7,或者在汇编函数中看到TEXT reuntime.exit(SB),7,$0,这种函数就是不会检测栈溢出的。这个是编译标记,控制是否生成栈溢出检测指令。

runtime.morestack是用汇编实现的,做的事情大致是将一些信息存在M结构体中,这些信息包括当前栈桢,参数,当前函数调用,函数返回地址(两个返回地址,一个是runtime.morestack的函数地址,一个是f的返回地址)。通过这些信息可以把新栈和旧栈链起来。

void runtime.morestack() {
    if(g == g0) {
        panic();
    } else {
        m->morebuf.gobuf_pc = getCallerCallerPC();
        void *SP = getCallerSP();
        m->morebuf.gobuf_sp = SP;
        m->moreargp = SP;
        m->morebuf.gobuf_g = g;
        m->morepc = getCallerPC();

        void *g0 = m->g0;
        g = g0;
        setSP(g0->g_sched.gobuf_sp);
        runtime.newstack();
    }
}

需要注意的就是newstack是切换到m->g0的栈中去调用的。m->g0是调度器栈,go的运行时库的调度器使用的都是m->g0。

旧栈数据复制到新栈

runtime.morestack会调用于runtime.newstack,newstack做的事情很好理解:分配一个足够大的新的空间,将旧的栈中的数据复制到新的栈中,进行适当的修饰,伪装成调用过runtime.lessstack的样子(这样当函数返回时就会调用runtime.lessstack再次进入runtime中做一些栈收缩的处理)。

这里有一个技术难点:旧栈数据复制到新栈的过程,要考虑指针失效问题。

比如有某个指针,引用了旧栈中的地址,如果仅仅是将旧栈内容搬到新栈中,那么该指针就失效了,因为旧栈已被释放,应该修改这个指针让它指向新栈的对应地址。考虑如下代码:

func f1() {
    var a A
    f2(&a)
}
func f2(a *A) {
    // modify a
}

如果在f2中发生了栈增长,此时分配更大的空间作为新栈,并将旧栈内容拷贝到新栈中,仅仅这样是不够的,因为f2中的a还是指向旧栈中的f1的,所以必须调整。

Go实现了精确的垃圾回收,运行时知道每一块内存对应的对象的类型信息。在复制之后,会进行指针的调整。具体做法是,对当前栈帧之前的每一个栈帧,对其中的每一个指针,检测指针指向的地址,如果指向地址是落在旧栈范围内的,则将它加上一个偏移使它指向新栈的相应地址。这个偏移值等于新栈基地址减旧栈基地址。

runtime.lessstack比较简单,它其实就是切换到m->g0栈之后调用runtime.oldstack函数。这时之前保存的那个Stktop结构体是时候发挥作用了,从上面可以找到旧栈空间的SP和PC等信息,通过runtime.gogo跳转过去,整个过程就完成了。

gp = m->curg; //当前g
top = (Stktop*)gp->stackbase; //取得Stktop结构体
label = top->gobuf; //从结构体中取出Gobuf
runtime·gogo(&label, cret); //通过Gobuf恢复上下文

 

你可能感兴趣的:(go)