Go 栈管理

admin
admin 2020年04月26日
  • 在其它设备中阅读本文章

应用程序的内存一般会分成堆区和栈区两个部分。栈区的内存一般由编译器自动进行分配和释放,其中存储着函数的入参以及局部变量,这些参数会随着函数的创建而创建,函数的返回而消亡,一般不会在程序中长期存在,这种线性的内存分配策略有着极高地效率,但是工程师也往往不能控制栈内存的分配,这部分工作基本都是由编译器自动完成的。

一、线程栈

寄存器是中央处理器(CPU)中的稀缺资源,它的存储能力非常有限,但是能提供最快的读写速度,充分利用寄存器的效率可以构建高性能的应用程序。寄存器在物理机上非常有限,然而栈区的操作就会使用到两个以上的寄存器,这足以说明栈内存在应用程序的重要性。
栈寄存器在是 CPU 寄存器中的一种,它的主要作用是跟踪函数的调用栈,Go 语言的汇编代码中包含 BP 和 SP 两个栈寄存器,它们分别存储了栈的基址指针和栈顶的地址,栈内存与函数调用的关系非常紧密。
由于历史的设计问题,目前的栈区内存都是从高地址向低地址扩展的,当应用程序申请或者释放栈内存时只需要修改 SP 寄存器的值,这种线性的内存分配方式与堆内存相比更加快速,占用极少的额外开销。
如果我们在 Linux 操作系统中执行pthread_create系统调用,进程会启动一个新的线程,如果用户没有通过软资源限制RLIMIT_STACK指定线程栈的大小,那么操作系统会根据架构选择不同的默认栈大小。
多数架构上默认栈大小都在 2 ~ 4MB 左右,极少数架构会使用 32MB 作为默认大小,用户程序可以在分配的栈上存储函数参数和局部变量。然而这个固定的栈大小在某些场景下可能不是一个合适的值,如果一个程序需要同时运行几百个甚至上千个线程,那么这些线程中的绝大部分都只会用到很少的栈空间,而如果函数的调用栈非常深,固定的栈大小也无法满足用户程序的需求。
线程和进程都是代码执行的上下文(Context of Execution),但是如果一个应用程序中包含成百上千个执行上下文并且每个上下文都是线程,就会占用大量的内存空间并带来其他的额外开销,Go 语言在设计时认为执行上下文应该是轻量级的,所以在它实现了用户级的 Goroutine 作为执行上下文。

二、逃逸分析

在 C 语言和 C++ 这类需要手动管理内存的编程语言中,将对象或者结构体分配到栈上或者堆上是由工程师自主决定的,这也为工程师的工作带来的挑战,如果工程师能够精准地为每一个变量分配最合理的空间,那么整个程序的运行效率和内存使用效率一定是最高的,但是手动分配内存会导致如下的两个问题:

  • 不需要分配到堆上的对象分配到了堆上,浪费内存空间;
  • 需要分配到堆上的对象分配到了栈上,悬挂指针、影响内存安全;

与悬挂指针相比,浪费的内存空间反而是小问题。如果一个该分配到堆上的变量分配到栈上,函数调用返回时这个局部变量会被回收,调用方获取的是危险的悬挂指针,我们不确定当前指针指向的值是否合法,这种问题在大型项目中是比较难以发现和定位的。

int *dangling_pointer() {
    int i = 2;
    return &i;
}

在编译器优化中,逃逸分析 (Escape analysis)是用来决定指针动态作用域的方法。Go 语言的编译器使用逃逸分析决定哪些变量应该在栈上分配,哪些变量应该在堆上分配,其中包括使用 new、make 和字面量等方法隐式分配的内存,Go 语言的逃逸分析遵循以下两个不变性:

  • 指向栈对象的指针不能存在于堆中;
  • 指向栈对象的指针不能在栈对象回收后存活;

逃逸分析是静态分析的一种,在编译器解析了 Go 语言源文件后,它可以获得整个程序的 抽象语法树 (Abstract syntax tree,AST),编译器可以根据抽象语法树分析静态的数据流,通过以下几个步骤实现静态分析的全过程(src/cmd/compile/internal/gc/escape.go):

  • 构建带权重的有向图,其中顶点表示被分配的变量,边表示变量之间的分配关系,权重表示寻址和取地址的次数;
  • 遍历对象分配图并查找违反两条不变性的变量分配关系,如果堆上的变量指向了栈上的变量,那么栈上的变量就需要分配在堆上;
  • 记录从函数的调用参数到堆以及返回值的数据流,增强函数参数的逃逸分析;
    决定变量是在栈上还是堆上虽然重要,但是这是一个定义相对清晰的问题,我们可以通过编译器在统一作出决策。为了保证内存的绝对安全,编译器可能会将一些变量错误地分配到堆上,但是因为这些对也会被垃圾收集器处理,所以不会造成内存泄露以及悬挂指针等安全问题,解放了工程师的生产力。

三、栈内存空间

Go 语言使用用户态线程 Goroutine 作为执行上下文,它的额外开销和默认栈大小都比线程小很多,然而 Goroutine 的栈内存空间和栈结构也在早期几个版本中发生过一些变化:

  • v1.0 ~ v1.1:最小栈内存空间为 4KB;
  • v1.2:将最小栈内存提升到了 8KB;
  • v1.3:使用连续栈替换之前版本的分段栈;
  • v1.4:将最小栈内存降低到了 2KB;
    Goroutine 的初始栈内存在最初的几个版本中多次修改,从 4KB 提升到 8KB 是临时的解决方案,其目的是为了减轻分段栈的栈分裂问题对程序造成的性能影响;在 v1.3 版本引入连续栈之后,Goroutine 的初始栈大小降低到了 2KB,进一步减少了 Goroutine 占用的内存空间。

1、分段栈

分段栈是 Go 语言在 v1.3 版本之前的实现,所有 Goroutine 在初始化时都会调用 runtime.stackalloc 分配一块固定大小的内存空间,这块内存的大小由 StackMin 表示,在 v1.2 版本中为 8KB。
当 Goroutine 调用的函数层级或者局部变量需要的越来越多时,运行时会调用 runtime.morestack 和 runtime.newstack 创建一个新的栈空间,这些栈空间虽然不连续,但是当前 Goroutine 的多个栈空间会以链表的形式串联起来,运行时会通过指针找到连续的栈片段。
分段栈机制虽然能够按需为当前 Goroutine 分配内存并且及时减少内存的占用,但是它也存在两个比较大的问题:

  • 如果当前 Goroutine 的栈几乎充满,那么任意的函数调用都会触发栈的扩容,当函数返回后又会触发栈的收缩,如果在一个循环中调用函数,栈的分配和释放就会造成巨大的额外开销,这被称为热分裂问题(Hot split);
  • 一旦 Goroutine 使用的内存越过了分段栈的扩缩容阈值,运行时就会触发栈的扩容和缩容,带来额外的工作量;

2、连续栈

连续栈可以解决分段栈中存在的两个问题,其核心原理就是每当程序的栈空间不足时,初始化一片更大的栈空间并将原栈中的所有值都迁移到新的栈中,新的局部变量或者函数调用就有了充足的内存空间。使用连续栈机制时,栈空间不足导致的扩容会经历以下几个步骤:

  • 在内存空间中分配更大的栈内存空间;
  • 将旧栈中的所有内容复制到新的栈中;
  • 将指向旧栈对应变量的指针重新指向新栈;
  • 销毁并回收旧栈的内存空间;
    在扩容的过程中,最重要的是调整指针的第三步,这一步能够保证指向栈的指针的正确性,因为栈中的所有变量内存都会发生变化,所以原本指向栈中变量的指针也需要调整。经过逃逸分析的 Go 语言程序的遵循以下不变性:指向栈对象的指针不能存在于堆中,所以指向栈中变量的指针只能在栈上,只需要调整栈中的所有变量就可以保证内存的安全了。
    因为需要拷贝变量和调整指针,连续栈增加了栈扩容时的额外开销,但是通过合理栈缩容机制就能避免热分裂带来的性能问题,在 GC 期间如果 Goroutine 使用了栈内存的四分之一,那就将其内存减少一半,这样在栈内存几乎充满时也只会扩容一次,不会因为函数调用频繁扩缩容。

四、栈操作

Go 语言中的执行栈由runtime.stack结构体表示,该结构体中只包含两个字段,分别表示栈的顶部和栈的底部,每个栈结构体都表示范围 [lo, hi) 的内存空间:

type stack struct {
    lo uintptr
    hi uintptr
}

栈管理分为编译期间和运行时两个阶段:

  • 编译器会在编译阶段会通过 cmd/internal/obj/x86.stacksplit 在调用函数前插入 runtime.morestack 或者 runtime.morestack_noctxt 函数;
  • 运行时在创建新的 Goroutine 时会在 runtime.malg 函数中调用 runtime.stackalloc 申请新的栈内存,并在编译器插入的 runtime.morestack 中检查栈空间是否充足;

Go 语言的编译器不会为所有的函数插入 runtime.morestack,它只会在必要时插入指令以减少运行时的额外开销,编译指令 nosplit 可以跳过栈溢出的检查,虽然这能降低一些开销,不过固定大小的栈也存在溢出的风险。

1、栈初始化

栈空间在运行时中包含两个重要的全局变量,分别是 runtime.stackpool 和 runtime.stackLarge,这两个变量分别表示全局的栈缓存和大栈缓存,前者可以分配小于 32KB 的内存,后者用来分配大于 32KB 的栈空间。

var stackpool [_NumStackOrders]struct {
    item stackpoolItem
    _    [cpu.CacheLinePadSize - unsafe.Sizeof(stackpoolItem{})%cpu.CacheLinePadSize]byte
}

type stackpoolItem struct {
    mu   mutex
    span mSpanList
}

var stackLarge struct {
    lock mutex
    free [heapAddrBits - pageShift]mSpanList
}

这两个用于分配空间的全局变量都与内存单元 runtime.mspan 有关,我们可以认为 Go 语言的栈内存都是分配在堆上的,运行时初始化时调用的 runtime.stackinit 函数会在初始化这些全局变量。
如果运行时只使用全局变量来分配栈内存的话,势必会造成线程之间的锁竞争进而影响程序的执行效率,栈内存由于与线程关系比较密切,所以在每一个线程缓存 runtime.mcache 中都加入了栈缓存减少锁竞争影响。运行时使用全局的 runtime.stackpool 和线程缓存中的空闲链表分配 32KB 以下的栈内存,使用全局的 runtime.stackLarge 和堆内存分配 32KB 以上的栈内存,提高本地分配栈内存的性能。

2、栈分配

运行时会在 Goroutine 的初始化函数 runtime.malg 中调用 runtime.stackalloc 分配一个大小足够栈内存空间,根据线程缓存和申请栈的大小,该函数会通过三种不同的方法分配栈空间。

  • 如果栈空间较小,使用全局栈缓存或者线程缓存上固定大小的空闲链表分配内存;
  • 如果栈空间较大,从全局的大栈缓存 runtime.stackLarge 中获取内存空间;
  • 如果栈空间较大并且 runtime.stackLarge 空间不足,在堆上申请一片大小足够内存空间;
    runtime.stackpoolalloc函数会在全局的栈缓存池 runtime.stackpool 中获取新的内存,如果栈缓存池中不包含剩余的内存,运行时会从堆上申请一片内存空间;如果线程缓存中包含足够的空间,我们可以从线程本地的缓存中获取内存,一旦发现空间不足就会调用runtime.stackcacherefill从堆上获取新的内存。

3、栈扩容

编译器会在 cmd/internal/obj/x86.stacksplit 函数中为函数调用插入runtime.morestack运行时检查,它会在几乎所有的函数调用之前检查当前 Goroutine 的栈内存是否充足,如果当前栈需要扩容,会保存一些栈的相关信息并调用 runtime.newstack 创建新的栈。
runtime.newstack 会先做一些准备工作并检查当前 Goroutine 是否发出了抢占请求。
如果发出了抢占请求:

  • 当前线程可以被抢占时,直接调用 runtime.gogo 触发调度器的调度;
  • 如果当前 Goroutine 在垃圾回收被 runtime.scanstack 函数标记成了需要收缩栈,调用 runtime.shrinkstack;
  • 如果当前 Goroutine 被 runtime.suspendG 函数挂起,调用 runtime.preemptPark 被动让出当前处理器的控制权并将 Goroutine 的状态修改至_Gpreempted;
  • 调用 runtime.gopreempt_m 主动让出当前处理器的控制权;
    如果当前 Goroutine 不需要被抢占:
    也就意味着我们需要新的栈空间来支持函数调用和本地变量的初始化,运行时会先检查目标大小的栈是否会溢出。如果目标栈的大小没有超出程序的限制,我们会将 Goroutine 切换至_Gcopystack 状态并调用 runtime.copystack 开始栈的拷贝,在拷贝栈的内存之前,运行时会通过 runtime.stackalloc 函数分配新的栈空间。新栈的初始化和数据的复制是一个比较简单的过程,复杂的是需要将指向源栈中内存指向新的栈。所有的指针都被调整后,我们就可以更新 Goroutine 的几个变量并通过 runtime.stackfree 释放原始栈的内存空间了。

4、栈缩容

runtime.shrinkstack是用于栈缩容的函数,该函数的实现原理非常简单,其中大部分都是检查是否满足缩容前置条件的代码。如果要触发栈的缩容,新栈的大小会使原始栈的一半,不过如果新栈的大小低于程序的最地限制 2KB,那么缩容的过程就会停止。运行时只会在栈内存使用不足 1 / 4 时进行缩容,缩容也会调用扩容时使用的runtime.copystack函数开辟新的栈空间。

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)
}