目录
一. 基础
- go中将内存分为堆区Heap和栈区Stack两区域,
- 由内存分配器进行分配的堆空间,由垃圾收集器负责回收,垃圾回收收集不再使用的span,把span释放交给mheap,mheap对span进行合并,把合并后的span加入scav树中,等待再分配内存时,由mheap进行内存再分配。因此Go堆是Go垃圾收集器管理的主要区域
- 栈区中存储着函数的参数以及局部变量,随着函数的创建而创建,函数的返回而销毁,由编译器自动分配和释放,这种线性的内存分配策略有着极高地效率
栈寄存器
- 当程序执行时需要保存一些数据,比如函数中的变量,这些数据通常都会存储在读写速度很快内存中
- CPU寄存器是一种非常快的内存,用来存储一些临时变量和状态信息,可以提供最快的读写速度,但是存储能力有限
- 栈寄存器在是 CPU 寄存器中的一种,主要作用是跟踪函数的调用栈
- golang的栈中用到了 BP 和 SP 两个栈寄存器,BP 存储当前函数栈帧的基址指针,也就是当前函数的栈帧的起始位置,SP 存储当前函数栈帧的栈顶地址,也就是当前函数对应栈帧的末尾地址,BP 和 SP 之间的内存就是当前函数的调用栈,用于存储该函数的参数,局部变量以及其他相关信息
- 当应用程序申请或者释放栈内存时只需要修改 SP 寄存器的值,这种线性的内存分配方式与堆内存相比更加快速,占用极少的额外开销
- 举例:
- 假设有一个函数被调用,BP 寄存器会被设置为当前栈帧的起始位置,即在该函数中所有局部变量和参数所占用的内存位置处,而 SP 寄存器则指向当前栈帧的栈顶,也就是末尾即下一个可用的空间地址
- 当这个函数调用结束后,栈帧出栈,SP 寄存器的值会被还原,表示该函数所占用的栈空间已被释放,BP 寄存器的值也会被还原,返回到调用该函数的上一个函数的栈帧中
- 所以通过修改 SP 寄存器的值,就能够在栈上动态地分配和释放内存,不需要考虑如何分配和回收内存,而 BP 寄存器则用于跟踪当前栈帧的基址,方便访问局部变量和函数参数等信息
逃逸分析
- 所谓逃逸分析Escape analysis,实际是指由编译器决定将对象或者结构体分配到栈上或者堆上的一种分配策略
- 分配时需要考虑的问题
- 不需要分配到堆上的对象分配到了堆上会造成内存空间的浪费
- 需要分配到堆上的对象分配到了栈上会造成悬挂指针,影响内存安全
- 分配到栈上或堆上的优点:
- 如果分配在栈中,则函数执行结束可自动将内存回收
- 有了逃逸分析,返回函数局部变量将变得可能
- 如果分配在堆中,则函数执行结束可交给GC(垃圾回收)处理
- 分配策略
- 如果函数外部没有引用,则优先放到栈中
- 如果函数外部存在引用,则必定放到堆中
1. 什么是"逃逸",与逃逸策略
- 什么是逃逸: 原本应该分配到栈上的分配到了堆上,称为"逃逸"
- 在分配内存是会按照是否被函数外部引用,将有引用的分配到堆上,没有引用的分配到栈, 但是也有逃逸的可能,出现逃逸的几种情况,我们称为逃逸策略
- 指针逃逸
- 栈空间不足
- 动态类型逃逸
- 闭包引用对象逃逸
2. Go 语言的逃逸分析遵循的两个不变性
- Go语言的逃逸分析遵循以下两个不变性
- 指向栈对象的指针不能存在于堆中;
- 指向栈对象的指针不能在栈对象回收后存活;
- 当违反了第一条不变性,堆上的绿色指针指向了栈中的黄色内存,一旦当前函数返回函数栈被回收,该绿色指针指向的值就不再合法;如果我们违反了第二条不变性,因为寄存器 SP 下面的内存由于函数返回已经被释放掉,所以黄色指针指向的内存已经不再合法
3. 逃逸场景示例
指针逃逸
- 通过一个案例解释
- Go可以返回局部变量指针
- 函数StudentRegister()内部s为局部变量,其值通过函数返回值返回,
- s本身为一指针,其指向的内存地址不会是栈而是堆,这就是典型的逃逸案例
package main
type Student struct {
Name string
Age int
}
func StudentRegister(name string, age int) *Student {
s := new(Student) //局部变量s逃逸到堆
s.Name = name
s.Age = age
return s
}
func main() {
StudentRegister("Jim", 18)
}
栈空间不足
- 如下代码
- Slice()函数中分配了一个1000个长度的切片,是否逃逸取决于栈空间是否足够大
- 上当栈空间不足以存放当前对象时或无法判断当前切片长度时会将对象分配到堆中
package main
func Slice() {
s := make([]int, 1000, 1000)
for index, _ := range s {
s[index] = index
}
}
func main() {
Slice()
}
动态类型逃逸
- 如下代码: 很多函数参数为interface类型,比如fmt.Println(a …interface{}),编译期间很难确定其参数的具体类型,也会产生逃逸
package main
import "fmt"
func main() {
s := "Escape"
fmt.Println(s)
}
闭包引用对象逃逸
- 如下代码:
- Fibonacci()获取一个闭包,每次执行闭包就会打印一个Fibonacci数值
- Fibonacci()函数中原本属于局部变量的a和b由于闭包的引用,不得不将二者放到堆上,以致产生逃逸
package main
import "fmt"
func Fibonacci() func() int {
a, b := 0, 1
return func() int {
a, b = b, a+b
return a
}
}
func main() {
f := Fibonacci()
for i := 0; i < 10; i++ {
fmt.Printf("Fibonacci: %d\n", f())
}
}
栈内存空间
线程栈大小
- 多数架构上默认栈大小都在 2 ~ 4 MB 左右,极少数架构会使用32 MB作为默认大小,用户程序可以在分配的栈上存储函数参数和局部变量
例如 Linux 操作系统中执行 pthread_create 系统调用,进程会启动一个新的线程,如果用户没有通过软资源限制 RLIMIT_STACK 指定线程栈的大小,那么操作系统会根据架构选择不同的默认栈大小3
- 注意: 固定的栈大小在某些场景下可能并不适用,例如线程数量非常多时,实际只会用到很少的栈空间,但是当函数的调用栈非常深,固定的栈大小也就无法满足需求
- 在 Go 中每个协程都有一个独立的栈空间,用于保存该协程运行期间所需要的状态信息。与传统的线程不同的是,每个协程的栈是动态伸缩的,可以根据需要自动扩展或收缩,因此协程的栈大小不是固定的
协程栈
- 先说一下golang中不同版本栈空间大小
- Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多,不同版本的变化
v1.0 ~ v1.1 — 最小栈内存空间为 4KB;
v1.2 — 将最小栈内存提升到了 8KB;
v1.3 — 使用连续栈替换之前版本的分段栈;
v1.4 — 将最小栈内存降低到了 2KB;
1. 设置栈空间大小时考虑的问题
- 通过传统语言,比如C引出设置栈空间大小时出现的问题
- 如果设置了栈的大小,但是某个函数会递归调用,造成当前栈内存耗尽, 解决这个问题可以调整标准库给线程栈分配的内存块的大小。但是全线提高栈大小可能会造成耗尽程序内存空间
- 另外一种解决方式: 根据每个线程的需要,估算栈内存的大小,为每个线程单独确定栈大小,实现复杂
- golang中如何解决这个问题: 通过分段栈与连续栈解决
2. 分段栈
- 分段栈(segmented stacks)是Go语言最初用来处理栈的方案。当创建一个goroutine时,Go运行时会分配一段8K字节的内存用于栈供goroutine运行使用
- 当8K字节的栈空间使用完毕后,每个go函数在函数入口处都会有一小段代码called prologue,通过该代码检查8k是否还存在空闲内存,如果不存在会调用morestack函数
- morestack函数会分配一段新内存用作栈空间,接下来它会将有关栈的各种数据信息写入栈底的一个struct中,包括上一段栈的地址此时拥有了一个新的栈段,这些栈空间虽然不连续,但是当前 Goroutine 的多个栈空间会以链表的形式串联起来,运行时会通过指针找到连续的栈片段然后将重启goroutine,从导致栈空间用光的那个函数开始执行。这就是所谓的**“栈分裂”**
- 一旦 Goroutine 申请的新的栈空间不需要时,运行时会调用 runtime.lessstack和 runtime.oldstack,释放不再使用的内存空间,lessstack中会查找 stack底部的那个struct,并调整栈指针返回到前一段栈空间,之后就可以将这个新栈段(stack segment)释放掉
- 分段栈的弊端:
- 如果当前 Goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题
- 一旦 Goroutine 使用的内存越过了分段栈的扩缩容阈值,运行时就会触发栈的扩容和缩容,带来额外的工作量;
- 进而提出连续栈
3. 连续栈
- 连续栈可以解决分段栈中存在的两个问题,其核心原理就是每当程序的栈空间不足时,创建一个两倍于原stack大小的新stack,并将旧栈拷贝到其中,新的局部变量或者函数调用就有了充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:
- 在内存空间中分配更大的栈内存空间;
- 将旧栈中的所有内容复制到新的栈中;
- 将指向旧栈对应变量的指针重新指向新栈;
- 销毁并回收旧栈的内存空间;
- 栈的收缩是垃圾回收的过程中实现的,当检测到栈只使用了不到1/4时,栈缩小为原来的1/2,也就是说栈缩小是一个无任何代价的操作
如何捕获到函数的栈空间不足
- Go语言和C不同,不是使用栈指针寄存器和栈基址寄存器确定函数的栈的。
- 在Go的运行时库中,每个goroutine对应一个结构体G,这个结构体中存了stackbase和stackguard,用于确定这个goroutine使用的栈空间信息。每个Go函数调用调用前会执行几条指令:
- 先比较栈指针寄存器跟g->stackguard,检测是否发生栈溢出。
- 如果栈指针寄存器值超越了stackguard就需要扩展栈空间
旧栈数据复制到新栈
- 旧栈数据复制到新栈的过程,要考虑指针失效问题。
- Go实现了精确的垃圾回收,运行时知道每一块内存对应的对象的类型信息。在复制之后,会进行指针的调整。具体做法是:
- 对当前栈帧之前的每一个栈帧,对其中的每一个指针,检测指针指向的地址,
- 如果指向地址是落在旧栈范围内的,则将它加上一个偏移使它指向新栈的相应地址。这个偏移值等于新栈基地址减旧栈基地址
二. 栈操作流程
- Go 语言中的执行栈由 runtime.stack 结构体表示,该结构体中包含两个字段,分别表示栈的顶部和栈的底部,每个栈结构体都表示范围 [lo, hi) 的内存空间,注意: 不同的操作系统和 CPU 架构对于栈的管理方式也可能存在一些差异,因此 runtime.stack 结构体的具体实现方式可能会有所不同
type stack struct {
lo uintptr //表示栈的最低地址,也就是栈的起始地址
hi uintptr //标识栈的最高地址,不包含此地址,也就是栈的结束地址
}
- 编译器在编译阶段会通过 cmd/internal/obj/x86.stacksplit 在调用函数前插入 runtime.morestack 或者 runtime.morestack_noctxt 函数;
- 运行时在创建新的 Goroutine 时会在 runtime.malg 函数中调用 runtime.stackalloc 申请新的栈内存,并在编译器插入的 runtime.morestack 中检查栈空间是否充足
- 可以分–> 栈初始化—> 栈分配—>栈扩容—>栈缩容四个阶段去了解栈的执行流程
栈初始化
- 栈空间在运行时包含两个重要的全局变量:
- runtime.stackpool: 全局的栈缓存,可以分配小于 32KB 的内存
- runtime.stackLarge: 大栈缓存,用来分配大于 32KB 的栈空间
var stackpool [_NumStackOrders]struct {
item stackpoolItem
_ [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte
}
var stackLarge struct {
lock mutex
free [heapAddrBits - pageShift]mSpanList
}
- 这两个用于分配空间的全局变量都与内存管理单元 runtime.mspan 有关,可以先认为Go 语言的栈内存都是分配在堆上的,在初始化时会调用的 runtime.stackinit()函数, 初始化这些全局变量
func stackinit() {
for i := range stackpool {
stackpool[i].item.span.init()
}
for i := range stackLarge.free {
stackLarge.free[i].init()
}
}
- 考虑到通过全局变量来分配内存时线程之间的锁竞争造成的性能问题,所以在每一个线程缓存 runtime.mcache 中都加入了栈缓存
type mcache struct {
stackcache [_NumStackOrders]stackfreelist
}
type stackfreelist struct {
list gclinkptr
size uintptr
}
- 运行时使用全局的 runtime.stackpool 和线程缓存中的空闲链表分配 32KB 以下的栈内存,使用全局的 runtime.stackLarge 和堆内存分配 32KB 以上的栈内存,提高本地分配栈内存的性能
栈分配
- 运行时会在 Goroutine 的初始化函数 runtime.malg() 中调用 runtime.stackalloc()分配栈内存空间,根据线程缓存和申请栈的大小,该函数会通过三种不同的方法分配栈空间:
- 如果栈空间较小,使用全局栈缓存或者线程缓存上固定大小的空闲链表分配内存;
- 如果栈空间较大,从全局的大栈缓存 runtime.stackLarge 中获取内存空间;
- 如果栈空间较大并且 runtime.stackLarge 空间不足,在堆上申请一片大小足够内存空间;
- 查看stackalloc()源码,如果申请的栈空间小于 32KB 时,会在全局栈缓存池或者线程的栈缓存中初始化内存
- runtime.stackpoolalloc 函数中会在全局的栈缓存池 runtime.stackpool 中获取新的内存,
- 如果栈缓存池中不包含剩余的内存,运行时会从堆上申请一片内存空间;
- 如果线程缓存中包含足够的空间,可以从线程本地的缓存中获取内存,
- 一旦发现空间不足就会调用 runtime.stackcacherefill 从堆上获取新的内存
func stackalloc(n uint32) stack {
thisg := getg()
var v unsafe.Pointer
if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
order := uint8(0)
n2 := n
for n2 > _FixedStack {
order++
n2 >>= 1
}
var x gclinkptr
c := thisg.m.mcache
if stackNoCache != 0 || c == nil || thisg.m.preemptoff != "" {
x = stackpoolalloc(order)
} else {
x = c.stackcache[order].list
if x.ptr() == nil {
stackcacherefill(c, order)
x = c.stackcache[order].list
}
c.stackcache[order].list = x.ptr().next
c.stackcache[order].size -= uintptr(n)
}
v = unsafe.Pointer(x)
} else {
...
}
...
}
- 如果 Goroutine 申请的内存空间过大,运行时会查看 runtime.stackLarge 中是否有剩余的空间,如果不存在剩余空间,也会从堆上申请新的内存
func stackalloc(n uint32) stack {
...
if n < _FixedStack<<_NumStackOrders && n < _StackCacheSize {
...
} else {
var s *mspan
npage := uintptr(n) >> _PageShift
log2npage := stacklog2(npage)
if !stackLarge.free[log2npage].isEmpty() {
s = stackLarge.free[log2npage].first
stackLarge.free[log2npage].remove(s)
}
if s == nil {
s = mheap_.allocManual(npage, &memstats.stacks_inuse)
osStackAlloc(s)
s.elemsize = uintptr(n)
}
v = unsafe.Pointer(s.base())
}
return stack{uintptr(v), uintptr(v) + uintptr(n)}
}
注意:OpenBSD 6.4+ 对栈内存有特殊的需求,只要我们从堆上申请栈内存,就需要调用 runtime.osStackAlloc 函数做一些额外的处理,其它操作系统没有这种限制
栈扩容
- 编译器会在 cmd/internal/obj/x86.stacksplit 函数中为函数调用插入 runtime.morestack 运行时检查,几乎所有的函数调用之前检查当前 Goroutine 的栈内存是否充足,如果当前栈需要扩容,会保存一些栈的相关信息并调用 runtime.newstack 创建新的栈, 在newstack()函数中,会先做一些准备工作并检查当前 Goroutine 是否发出了抢占请求,如果发出了抢占请求
- 当前线程可以被抢占时,直接调用 runtime.gogo 触发调度器的调度;
- 如果当前 Goroutine 在垃圾回收被 runtime.scanstack 函数标记成了需要收缩栈,调用 runtime.shrinkstack;
- 如果当前 Goroutine 被 runtime.suspendG 函数挂起,调用 runtime.preemptPark 被动让出当前处理器的控制权并将 Goroutine 的状态修改至 _Gpreempted;
- 调用 runtime.gopreempt_m 主动让出当前处理器的控制权;
func newstack() {
thisg := getg()
gp := thisg.m.curg
...
preempt := atomic.Loaduintptr(&gp.stackguard0) == stackPreempt
if preempt {
if !canPreemptM(thisg.m) {
gp.stackguard0 = gp.stack.lo + _StackGuard
gogo(&gp.sched)
}
}
sp := gp.sched.sp
if preempt {
if gp.preemptShrink {
gp.preemptShrink = false
shrinkstack(gp)
}
if gp.preemptStop {
preemptPark(gp)
}
gopreempt_m(gp)
}
...
}
- 如果当前 Goroutine 不需要被抢占,表示当前需要新的栈空间来支持函数调用和本地变量的初始化,会先检查目标大小的栈是否会溢出,如果目标栈的大小没有超出程序的限制,会通过 runtime.stackalloc 函数分配新的栈空间,并会将 Goroutine 切换至 _Gcopystack 状态并调用 runtime.copystack 开始栈的拷贝,
func newstack() {
...
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize * 2
if newsize > maxstacksize {
print("runtime: goroutine stack exceeds ", maxstacksize, "-byte limit\n")
print("runtime: sp=", hex(sp), " stack=[", hex(gp.stack.lo), ", ", hex(gp.stack.hi), "]\n")
throw("stack overflow")
}
casgstatus(gp, _Grunning, _Gcopystack)
copystack(gp, newsize)
casgstatus(gp, _Gcopystack, _Grunning)
gogo(&gp.sched)
}
- 新栈的初始化和数据的复制时,在copystack()函数中,需要调整指针将指向源栈中内存指向新的栈
- 调用 runtime.adjustsudogs 或者 runtime.syncadjustsudogs 调整 runtime.sudog 结构体的指针;
- 调用 runtime.memmove 将源栈中的整片内存拷贝到新的栈中;
- 调用 runtime.adjustctxt、runtime.adjustdefers 和 runtime.adjustpanics 调整剩余 Goroutine 相关数据结构的指针
func copystack(gp *g, newsize uintptr) {
old := gp.stack
used := old.hi - gp.sched.sp
new := stackalloc(uint32(newsize))
...
...
var adjinfo adjustinfo
adjinfo.old = old
adjinfo.delta = new.hi - old.hi // 计算新栈和旧栈之间内存地址差
ncopy := used
if !gp.activeStackChans {
adjustsudogs(gp, &adjinfo)
} else {
adjinfo.sghi = findsghi(gp, old)
ncopy -= syncadjustsudogs(gp, used, &adjinfo)
}
memmove(unsafe.Pointer(new.hi-ncopy), unsafe.Pointer(old.hi-ncopy), ncopy)
adjustctxt(gp, &adjinfo)
adjustdefers(gp, &adjinfo)
adjustpanics(gp, &adjinfo)
gp.stack = new
gp.stackguard0 = new.lo + _StackGuard
gp.sched.sp = new.hi - used
gp.stktopsp += adjinfo.delta
...
stackfree(old)
}
- 所有的指针都被调整后,我们就可以更新 Goroutine 的几个变量并通过 runtime.stackfree 释放原始栈的内存空间
1. 栈扩容条件总结
- 将扩容前要先了解几个属性
- FramSzie:表示函数调用时的帧大小,也就是说,在每次函数调用时,需要用到的栈空间大小等于该函数的帧大小。当栈大小大于 128 字节时,栈扩容的条件会涉及到 FramSzie。
- StackSmall:表示小栈的大小,它通常比 StackGuard 小。当栈大小大于 128 字节时,栈扩容的条件会涉及到 StackSmall
- stackguard0:表示栈警戒区域的起始位置,一个指针,通常指向当前 goroutine 的栈顶即 SP,在 Go 程序执行时,当 SP 的值越过 stackguard0 时,就说明栈即将溢出。stackguard0 的重要作用是保护栈的完整性。
- 扩容条件
- 当栈大小128(含)内时,SP 小于 stackguard0 即栈扩容。
- 当栈大小大于 128 字节时,先判断 SP 是否小于 stackguard0。如果 SP 小于 stackguard0,则直接触发栈扩容。否则,计算出 SP - FrameSize + StackSmall 的值,与 stackguard0 进行比较。如果这个值小于 stackguard0,则触发栈扩容。
- 当栈大小大于 4096 字节时,需要先检查 stackguard0 是否处于 StackPreempt 状态。如果是,则直接触发栈扩容。否则,需要计算出 SP - stackguard0 + StackGuard 和 framesize + (StackGuard - StackSmall) 的值。如果前者小于等于后者,则触发栈扩容
栈缩容
- runtime.shrinkstack 是用于栈缩容的函数, 如果要触发栈的缩容,新栈的大小会使原始栈的一半,不过如果新栈的大小低于程序的最地限制 2KB,那么缩容的过程就会停止
func shrinkstack(gp *g) {
...
oldsize := gp.stack.hi - gp.stack.lo
newsize := oldsize / 2
if newsize < _FixedStack {
return
}
avail := gp.stack.hi - gp.stack.lo
if used := gp.stack.hi - gp.sched.sp + _StackLimit; used >= avail/4 {
return
}
copystack(gp, newsize)
}
- 注意: 运行时只会在栈内存使用不足 1/4 时进行缩容,缩容也会调用扩容时使用的 runtime.copystack 函数开辟新的栈空间
三. 总结
逃逸与逃逸分析
- 分配策略有哪些: 在内存分配时,编译器会根据分配策略将变量分配到堆上或者栈上
- 如果函数外部没有引用,则优先放到栈中
- 如果函数外部存在引用,则必定放到堆中
- 什么是逃逸: 原本应该分配到栈上的分配到了堆上,称为"逃逸"
- 逃逸分析的两个不变性,可以看为满足逃逸的两个条件
- 指向栈对象的指针不能存在于堆中;
- 指向栈对象的指针不能在栈对象回收后存活;
- 逃逸策略,或者说会发生逃逸的场景
- 指针逃逸: 也就是一个函数执行完毕后,反回了一个在该函数内部创建的一个指针变量,这个变量会逃逸
- 栈空间不足: 假设函数内部声明了一个切片,不停的像切片中添加数据,当栈空间存放不下时会将这个切片变量分配到堆上
- 动态类型逃逸: 比如使用interface这种无法在编译器确定数据类型的,也会产生逃逸
- 闭包引用对象逃逸: 闭包引用时
栈空间
- 线程栈与协程栈
- 线程栈大小: 大多数架构上默认栈大小都在 2 ~ 4 MB
- golang中提出了轻量级线程Goroutine,每个协程都有一个独立的栈空间,用于保存该协程运行期间所需要的状态信息。与传统的线程不同的是,每个协程的栈是动态伸缩的,可以根据需要自动扩展或收缩,因此协程的栈大小不是固定的
- 为什么做成动态的:
- 如果设置了栈的大小,但是某个函数会递归调用,造成当前栈内存耗尽, 解决这个问题可以调整标准库给线程栈分配的内存块的大小。但是全线提高栈大小可能会造成耗尽程序内存空间
- 另外一种解决方式: 根据每个线程的需要,估算栈内存的大小,为每个线程单独确定栈大小,实现复杂
- golang中如何解决这个问题: 通过分段栈与连续栈解决
- 什么是分段栈:
- 当创建一个goroutine时,会分配一段8K字节的内存用于栈供goroutine运行使用
- 当8K字节的栈空间使用完毕后,每个go函数在函数入口处都会有一小段代码called prologue,通过该代码检查8k是否还存在空闲内存,如果不存在会调用morestack函数
- morestack函数会分配一段新栈,同一个协程的多个栈空间会以链表的形式串联起来,又称为栈分裂
- 新的栈空间不需要时,会调用 runtime.lessstack和 runtime.oldstack释放
- 分段栈的弊端:
- 如果当前 Goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题
- 一旦 Goroutine 使用的内存越过了分段栈的扩缩容阈值,运行时就会触发栈的扩容和缩容,带来额外的工作量;
- 进而提出连续栈
- 连续栈: (用来解决分段栈中存在的两个问题)
- 当程序的栈空间不足时,创建一个两倍于原stack大小的新stack,并将旧栈拷贝到其中
- 栈的收缩是垃圾回收的过程中实现的,当检测到栈只使用了不到1/4时,栈缩小为原来的1/2,也就是说栈缩小是一个无任何代价的操作
- 如何捕获到函数的栈空间不足
- goroutine对应一个结构体G,这个结构体中存了stackbase和stackguard,用于确定这个goroutine使用的栈空间信息。
- 每个Go函数调用调用前会执行几条指令: 先比较栈指针寄存器跟g->stackguard,检测是否发生栈溢出,如果栈指针寄存器值超越了stackguard就需要扩展栈空间
- 不同版本协程栈大小的变化
v1.0 ~ v1.1 — 最小栈内存空间为 4KB;
v1.2 — 将最小栈内存提升到了 8KB;
v1.3 — 使用连续栈替换之前版本的分段栈;
v1.4 — 将最小栈内存降低到了 2KB;
栈的源码相关总结
- 针对栈提供类 runtime.stack 结构,该结构体内部包含表示栈的最低地址,也就是栈的起始地址的lo属性与表示栈的最高地址也就是结束地址的hi属性
- 在创建新的 Goroutine 时会在 runtime.malg 函数中调用 runtime.stackalloc 申请新的栈内存,并在编译器插入的 runtime.morestack 中检查栈空间是否充足
- 可以分–> 栈初始化—> 栈分配—>栈扩容—>栈缩容四个阶段去了解栈的执行流程
- 栈初始化: 在初始化时会调用一个stackinit()函数,该函数中会初始化:
- 分配小于 32KB 的内存的全局的栈缓存 runtime.stackpool
- 分配大于 32KB 的栈空间 大栈缓存runtime.stackLarge
- 考虑到通过全局变量来分配内存时线程之间的锁竞争造成的性能问题,每一个线程缓存 runtime.mcache 中都加入了栈缓存stackcache
- 栈分配,运行时会在 Goroutine 的初始化函数 runtime.malg() 中调用 runtime.stackalloc()分配栈内存空间,根据线程缓存和申请栈的大小,该函数会通过三种不同的方法分配栈空间
- 如果申请的栈空间小于 32KB 时,会在全局栈缓存池或者线程的栈缓存中初始化内存
- 如果栈空间较大,从全局的大栈缓存 runtime.stackLarge 中获取内存空间
- 如果栈空间较大并且 runtime.stackLarge 空间不足,在堆上申请一片大小足够内存空间
- 如果栈缓存池中内存不足时,运行时会从堆上申请一片内存空间
- 栈扩容: 编译器在 cmd/internal/obj/x86.stacksplit 函数中为函数调用插入 runtime.morestack 运行时检查,几乎所有的函数调用之前检查当前 Goroutine 的栈内存是否充足,如果当前栈需要扩容,会保存一些栈的相关信息并调用 runtime.newstack 创建新的栈
- 栈扩容条件: 大致了解一下
- 栈缩容: runtime.shrinkstack 是用于栈缩容的函数, 如果要触发栈的缩容,新栈的大小会使原始栈的一半,不过如果新栈的大小低于程序的最地限制 2KB,那么缩容的过程就会停止,注意运行时只会在栈内存使用不足 1/4 时进行缩容,缩容也会调用扩容时使用的 runtime.copystack 函数开辟新的栈空间