Go语言进阶(十)内存分配原理

内存分配原理

堆栈 & 逃逸分析

堆和栈的定义

Go 有两个地方可以分配内存:

  • 一个全局堆空间用来动态分配内存,
  • 另一个是每个 goroutine 都有的自身栈空间。

image-20211215135736993

栈区的内存一般由编译器自动进行分配和释放,其中存储着函数的入参以及局部变量,这些参数会随着函数的创建而创建,函数的返回而销毁。(通过 CPU push & release)。

image-20211215144230638

堆区的内存一般由编译器和工程师自己(make 或者 new)共同进行管理分配,交给 Runtime GC 来释放。堆上分配必须找到一块足够大的内存来存放新的变量数据。后续释放时,垃圾回收器扫描堆空间寻找不再被使用的对象。

image-20211215144217307

栈分配廉价,堆分配昂贵。

变量是在堆还是栈上?

Go 声明语法并没有提到栈和堆,而是交给 Go 编译器决定在哪分配内存,保证程序的正确性,在 Go FAQ 里面提到这么一段解释:

从正确的角度来看,你不需要知道。Go 中的每个变量只要有引用就会一直存在。变量的存储位置(堆还是栈)和语言的语义无关

存储位置对于写出高性能的程序确实有影响。如果可能,Go 编译器将为该函数的堆栈侦(stack frame)中的函数分配本地变量。但是如果编译器在函数返回后无法证明变量未被引用,则编译器必须在会被垃圾回收的堆上分配变量以避免悬空指针错误。此外,如果局部变量非常大,将它存储在堆而不是栈上可能更有意义。

在当前编译器中,如果变量存在取址,则该变量是堆上分配的候选变量。但是基础的逃逸分析可以将那些生存不超过函数返回值的变量识别出来,并且因此可以分配在栈上。

逃逸分析

通过检查变量的作用域是否超出了它所在的栈来决定是否将它分配在堆上”的技术,其中“变量的作用域超出了它所在的栈”这种行为即被称为逃逸逃逸分析在大多数语言里属于静态分析:在编译期由静态代码分析来决定一个值是否能被分配在栈帧上,还是需要“逃逸”到堆上。

为什么要做这件事情(逃逸分析):

  • 减少 GC 压力,栈上的变量,随着函数退出后系统直接回收,不需要标记后再清除
  • 减少内存碎片的产生
  • 减轻分配堆内存的开销,提高程序的运行速度

超过栈帧(stack frame)

当一个函数被调用时,会在两个相关的帧边界间进行上下文切换。从调用函数切换到被调用函数,如果函数调用时需要传递参数,那么这些参数值也要传递到被调用函数的帧边界中。Go 语言中帧边界间的数据传递是按值传递的。任何在函数 getRandom 中的变量在函数返回时,都将不能访问。Go 查找所有变量超过当前函数栈侦的,把它们分配到堆上,避免 outlive 变量。

image-20211215151006329image-20211215151015135

上述情况中,num 变量不能指向之前的栈。
Go 查找所有变量超过当前函数栈侦的,把它们分配到堆上,避免 outlive 变量。
变量 tmp 在栈上分配,但是它包含了指向堆内存的地址,所以可以安全的从一个函数的栈侦复制到另外一个函数的栈帧。

image-20211215151315512

连续栈

Go 应用程序运行时,每个 goroutine 都维护着一个自己的栈区这个栈区只能自己使用不能被其他 goroutine 使用。栈区的初始大小是2KB(比 x86_64 架构下线程的默认栈2M 要小很多),在 goroutine 运行的时候栈区会按照需要增长和收缩,占用的内存最大限制的默认值在64位系统上是1GB。

历史变更:

  • v1.0 ~ v1.1 — 最小栈内存空间为 4KB
  • v1.2 — 将最小栈内存提升到了 8KB
  • v1.3 — 使用连续栈替换之前版本的分段栈
  • v1.4 — 将最小栈内存降低到了 2KB

分段栈:当原始栈不够用了,那么再申请一块新的栈空间,原始栈指向新栈。为什么Go1.3之后会取消分段栈,因为分段栈会有hot split的问题。

image-20211215153948603

Hot split 问题

分段栈的实现方式存在 “hot split” 问题:

  • 如果栈快满了,那么下一次的函数调用会强制触发栈扩容。
  • 当函数返回时,新分配的 “stack chunk” 会被清理掉。
  • 如果这个函数调用产生的范围是在一个循环中,会导致严重的性能问题,频繁的 alloc/free

image-20211215154533342

Go 不得不在1.2版本把栈默认大小改为8KB,降低触发热分裂的问题,但是没有解决,Go1.3后引入连续栈,才解决了这个问题,并且再Go1.4之后将默认栈大小改为2KB。

连续栈

采用复制栈的实现方式,在热分裂场景中不会频发释放内存,即不像分配一个新的内存块并链接到老的栈内存块,而是会分配一个两倍大的内存块把老的内存块内容复制到新的内存块里,当栈缩减回之前大小时,我们不需要做任何事情。

image-20211215154753522

操作流程:

  • runtime.newstack 分配更大的栈内存空间
  • runtime.copystack 将旧栈中的内容复制到新栈中
  • 将指向旧栈对应变量的指针重新指向新栈
  • runtime.stackfree 销毁并回收旧栈的内存空间
  • 如果栈区的空间使用率不超过1/4,那么在垃圾回收的时候使用 runtime.shrinkstack 进行栈缩容,同样使用 copystack

栈扩容

Go 运行时如何判断栈空间是否足够?call function 中会插入 runtime.morestack,但每个函数调用都判定的话,成本比较高。在编译期间通过计算 spfunc stack framesize 确定需要哪个函数调用中插入 runtime.morestack

image-20211215155139501

  • 当函数是叶子节点(A->B->C, C不调用其他函数的时候,C是叶子节点),且栈帧小于等于 112 ,不插入指令
  • 当叶子函数栈帧大小为 120 -128 或者 非叶子函数栈帧大小为 0 -128SP < stackguard0,不插入指令
  • 当函数栈帧大小为 128 – 4096 SP - framesize < stackguard0 - StackSmall,不插入指令
  • 当大于 StackBigSP-stackguard+StackGuard <= framesize + (StackGuard-StackSmall),不插入指令

stackguard0:表示有风险,需要扩栈了,但是不影响继续执行代码。

SP:栈指针stack pointer

内存优化

内存管理

TCMallocThread Cache Malloc 的简称,是Go 内存管理的起源,Go的内存管理是借鉴了TCMalloc

TCMalloc 有全局大锁,影响性能。所以对TCMalloc 进行魔改。TCMalloc 也是 谷歌的。

内存碎片

随着内存不断的申请和释放,内存上会存在大量的碎片,降低内存的使用率。为了解决内存碎片,可以将2个连续的未使用的内存块合并,减少碎片。

image-20211215160239230

大锁

同一进程下的所有线程共享相同的内存空间,它们申请内存时需要加锁,如果不加锁就存在同一块内存被2个线程同时访问的问题。

内存布局

我们需要先知道几个重要的概念:

page:内存页,一块 8K 大小的内存空间。Go 与操作系统之间的内存申请和释放,都是以 page 为单位的。

span:内存块,一个或多个连续的 page 组成一个 span。

sizeclass: 空间规格,每个 span 都带有一个 sizeclass,标记着该 span 中的 page 应该如何使用。

object: 对象,用来存储一个变量数据内存空间,一个 span 在初始化时,会被切割成一堆等大的 object。假设 object 的大小是 16B,span 大小是 8K,那么就会把 span 中的 page 就会被初始化 8K / 16B = 512 个 object。

image-20211215161102509

image-20211215161655838

小于 32kb 内存分配

当程序里发生了 32kb 以下的小块内存申请时,Go 会从一个叫做的 mcache 的本地缓存给程序分配内存。这样的一个内存块里叫做 mspan,它是要给程序分配内存时的分配单元。

image-20211215162304616

在 Go 的调度器模型里,每个线程 M 会绑定给一个处理器 P,在单一粒度的时间里只能做多处理运行一个 goroutine,每个 P 都会绑定一个上面说的本地缓存 mcache。当需要进行内存分配时,当前运行的 goroutine 会从 mcache 中查找可用的 mspan。从本地 mcache 里分配内存时不需要加锁,这种分配策略效率更高。

申请内存时都分给他们一个 mspan 这样的单元会不会产生浪费。其实 mcache 持有的这一系列的mspan 并不都是统一大小的,而是按照大小,从8b 到 32kb 分了大概 67*2 类的 mspan。

image-20211215162522044

每个内存页分为多级固定大小的“空闲列表”,这有助于减少碎片。类似的思路在 Linux Kernel、Memcache 都可以见到 Slab-Allactor。

如果分配内存时 mcachce 里没有空闲的对口 sizeclass 的 mspan 了,Go 里还为每种类别的 mspan 维护着一个 mcentral。

image-20211215162637365

mcentral 的作用是为所有 mcache 提供切分好的 mspan 资源。每个 central 会持有一种特定大小的全局 mspan 列表,包括已分配出去的和未分配出去的。 每个 mcentral 对应一种 mspan,当工作线程的 mcache 中没有合适(也就是特定大小的)的mspan 时就会从 mcentral 去获取。

mcentral 被所有的工作线程共同享有,存在多个 goroutine 竞争的情况,因此从 mcentral 获取资源时需要加锁。mcentral 里维护着两个双向链表,nonempty 表示链表里还有空闲的 mspan 待分配。empty 表示这条链表里的 mspan 都被分配了object 或缓存 mcache 中。

程序申请内存的时候,mcache 里已经没有合适的空闲 mspan了,那么工作线程就会像下图这样去 mcentral 里去申请。mcache 从 mcentral 获取和归还 mspan 的流程:

image-20211215163010423

  • 获取 加锁;从 nonempty 链表找到一个可用的mspan;并将其从 nonempty 链表删除;将取出的 mspan 加入到 empty 链表;将 mspan 返回给工作线程;解锁。
  • 归还 加锁;将 mspan 从 empty 链表删除;将mspan 加入到 nonempty 链表;解锁。

mcentral 是 sizeclass 相同的 span 会以链表的形式组织在一起, 就是指该 span 用来存储哪种大小的对象。

当 mcentral 没有空闲的 mspan 时,会向 mheap 申请。而 mheap 没有资源时,会向操作系统申请新内存。mheap 主要用于大对象的内存分配,以及管理未切割的 mspan,用于给 mcentral 切割成小对象。

image-20211215162831307

mheap 中含有所有规格的 mcentral,所以当一个 mcache 从 mcentral 申请 mspan 时,只需要在独立的 mcentral 中使用锁,并不会影响申请其他规格的 mspan。

所有 mcentral 的集合则是存放于 mheap 中的。 mheap 里的 arena 区域是真正的堆区,运行时会将 8KB 看做一页,这些内存页中存储了所有在堆上初始化的对象。运行时使用二维的 runtime.heapArena 数组管理所有的内存,每个 runtime.heapArena 都会管理 64MB 的内存。

如果 arena 区域没有足够的空间,会调用 runtime.mheap.sysAlloc 从操作系统中申请更多的内存。(如下图:Go 1.11 前的内存布局)

image-20211215163417917

G1.11之前是连续的512G内存布局,之后割切成64M的arena区别, 下图为Go1.11后的内存布局,

image-20211215163426761

image-20211215163512445

小于 16b 内存分配

对于小于16字节的对象(且无指针),Go 语言将其划分为了tiny 对象。划分 tiny 对象的主要目的是为了处理极小的字符串和独立的转义变量。对 json 的基准测试表明,使用 tiny 对象减少了12%的分配次数和20%的堆大小。tiny 对象会被放入class 为2的 span 中。

  • 首先查看之前分配的元素中是否有空余的空间
  • 如果当前要分配的大小不够,例如要分配16字节的大小,这时就需要找到下一个空闲的元素

tiny 分配的第一步是尝试利用分配过的前一个元素的空间,达到节约内存的目的。

image-20211215164111660

大于 32kb 内存分配

Go 没法使用工作线程的本地缓存 mcache 和全局中心缓存 mcentral 上管理超过32KB的内存分配,所以对于那些超过32KB的内存申请,会直接从堆上(mheap)上分配对应的数量的内存页(每页大小是8KB)给程序。

  • freelist
  • treap
  • radix tree + pagecache

image-20211215164505913

内存分配

image-20211215164626107

一般小对象通过 mspan 分配内存;大对象则直接由 mheap 分配内存。

  • Go 在程序启动时,会向操作系统申请一大块内存,由 mheap 结构全局管理(现在 Go 版本不需要连续地址了,z所以不会申请一大堆地址)
  • Go 内存管理的基本单元是 mspan,每种 mspan 可以分配特定大小的 object
  • mcache, mcentral, mheap 是 Go 内存管理的三大组件,mcache 管理线程在本地缓存的 mspan;mcentral 管理全局的 mspan 供所有线程

reference

  • https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-stack-management
  • https://golang.design/under-the-hood/zh-cn/part2runtime/ch06sched/stack
  • https://draveness.me/golang/docs/part3-runtime/ch07-memory/golang-memory-allocator