前言
我们先看看其他语言是如何处理栈,在Java中,默认的栈大小是1M,可以通过-XX:ThreadStackSize
参数控制,当新建一个线程时,会向内核申请指定的栈大小。但是固定的栈大小如果设置过大,可能会浪费内存,设置过小,可能导致栈溢出。在golang 语言中,goroutine 是轻量的用户级协程,使用者不需要有很大的负担,不用像Java,C++一样设置线程池来处理任务。所以一个应用可以开启上万个goroutine。go 采用的就是按需分配,默认每个栈大小为2k,当空间不够,可以 "栈增长"。
一个协程的栈空间主要对应方法执行过程中的栈帧的压栈出栈。每个方法对应一个栈帧
int a(int m, int n) {
if (m == 0) {
return n + 1;
} else if (m > 0 && n == 0) {
return a(m - 1, 1);
} else {
return a(m - 1, a(m, n - 1));
}
}
如下方法将产生极大的递归调用,从而导致栈空间特别大,如果采用固定栈大小,和容易栈溢出。
golang 在早期采用分段栈的方式来进行栈增长,但是会带来hot split 问题,栈拷贝解决了该问题,目前默认是采用栈拷贝。
分段栈(segment stack)
goroutine 初始化是默认是2k的栈空间,那么是如何检测到栈空间不够?其实编译器会在每个方法的入口处,插入一个方法 morestack
判断是否需要栈增长,当栈帧不断出栈,goroutine 已经不需要使用那么多栈空间,编译器在方法的返回处插入一个方法lessstack
用来判断是否收缩栈大小。
如下图所示,当执行Foobar方法时,进行了栈增长,那么此时会栈分裂(stack split),在新的栈分段的栈底压入一个stack info ,记入上一个栈分段的信息(包括地址等),然后压入lessstack 和 Foobar栈帧,若Foobar 执行完并没有新的栈帧入栈,那么会执行lessstack 进行收缩。
go build -gcflags -S main.go //查看汇编代码,可以看到morestack
+---------------+
| |
| unused |
| stack |
| space |
+---------------+
| Foobar |
| |
+---------------+
| |
| lessstack |
+---------------+
| Stack info |
| |-----+
+---------------+ |
|
|
+---------------+ |
| Foobar | |
| | <---+
+---------------+
| rest of stack |
| |
Hot split?
如上若是,如果Foobar 方法是在一个循环中调用,那么会导致该goroutine 的栈空间频繁的增长和收缩,这个对性能损耗是极大的,这个就是 hot split 问题。我们来看看栈拷贝是如何解决
连续栈(Contiguous stack)
连续栈又叫栈拷贝(stack copying)
栈拷贝栈增长和分段栈是一样的处理,不同的是,它是申请一个栈大小是原来的两倍,然后将原来的栈上的数据拷贝过来。这样就不会有hot split 问题。不过在GC 时,当栈分配空间大于实际使用大小,也会进行栈收缩。
我们知道,指向栈上的数据的指针一定是存在栈上,而不是在堆上,如果有堆上的指针指向栈空间,那么随着栈帧出栈,可能对导致堆上出现悬挂指针(一个指针指向了未定义的地方),这是不允许的。
由于go 的GC 需要知道指针在什么地方,所以我们可以借助GC 的信息,将栈上的指针移动到新的位置(新申请栈空间上对应的位置)以及所有相关的栈上指针都需要处理。
由于部分runtime 上的代码还是使用c 语言编写,所以没有指针相关的信息,所以他们是不可拷贝的,也就不能使用栈拷贝的方式,所以需要用回分段栈。 这也是为什么现在golang 团队在用golang重写runtime 模块。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。