golang 栈扩容和栈转移原理
go在线程的基础上实现了用户态更加轻量级的写成,线程栈为了防止stack overflow,默认大小一般是2MB,而在go中,协程栈在初始化时是2KB
go中的栈是可以扩容的,在64位操作系统上最大为1GB
1. newstack()函数
在函数序言阶段如果判断出需要扩容,则会跳转调用运行时morestack_noctxt函数,函数调用链为:
morestack_noctxt() -> morestack() -> newstack()
核心代码位于 newstack() 函数中,newstack()函数不仅会处理扩容,还会处理协程的抢占
下面看一下newstack()函数的核心实现:
func newstack() {
oldsize := gp.stack.hi - gp.stack.lo
// 两倍于原来大小
newsize := oldsize * 2
// 需要的栈太大,直接溢出
if newsize > maxstacksize {
throw( "stack overflow" )
}
// goroutine必须是正在执行过程中才会调用newstack
// 所以这个状态一定是Grunning或者Gscanrunning
casgstatus(gp, _Grunning, _Gcopystack)
// gp的处于Gcopystack状态,当我们对栈进行复制时并发GC不会扫描此栈
// 栈的复制
copystack(gp, newsize)
casgstatus(gp, _Gcopystack, _Grunning)
// 继续执行
gogo(&gp.sched)
}
什么是gp?
gp就是当前协程的结构体:
type g struct {
stack stack
stackguard0 uintptr
stackguard1 uintptr
...
}
type stack struct {
lo uintptr // 8 bytes
hi uintptr
}
gp.stack.hi - gp.stack.lo就是在计算当前协程栈的大小
newstack()函数首先通过栈底地址与栈顶地址计算出旧栈的大小,并计算新栈的大小,新栈大小为旧栈的两倍大。在64为操作系统中,如果栈大小超过了1GB(maxstacksize)则直接报错stack overflow
2. 栈转移
栈扩容的重要一步就是将旧栈的内容转移到新栈中,栈扩容首先将协程的状态设置为 _Gcopystack,以便在垃圾回收时不会扫描该栈带来错误
栈复制并不是向内存复制一样简单,需要处理很多其他地址的指针转移的问题,同时为了应对频繁的栈调整,linux操作系统下,会对2/4/8/16KB的小栈进行专门的优化
在全局以及每个逻辑处理器中预先分配这些小栈的缓存池,避免频繁申请堆内存
对于大栈,其大小不确定,孙然也有一个全局的缓存池,但不会预先放入多个栈,当栈被销毁时,如果被销毁的栈为大栈则放入全局缓存池中
在分配到栈后,如果有指针指向旧栈,那么需要将其调整到新栈中
在调整时有一个额外的步骤是调整sudog,由于通道在阻塞的情况下存储的元素可能指向了站上的指针,因此需要调整
接着需要将旧栈的大小复制到新栈中,这涉及借助memmove函数进行内存复制
扩容最关键的一步是在新栈中调整指针,因为新栈中的指针可能指向旧栈,旧栈一旦释放后会出现问题。
在栈扩容的时候,copystack函数会遍历新栈上虽有的栈帧信息,并遍历其中所有可能指针的位置,一旦发现指针指向旧栈,就会调整当前的指针使其指向新栈