0 前言
未来两周,想和大家探讨的主题是 Golang 内存管理机制.
本系列会分为两篇,第一篇谈及 Golang 内存模型以及内存分配机制,第二篇会和大家讨论 Golang 的垃圾回收机制. 本文是其中第一篇.
我个人比较推崇”基于源码支撑原理“的信念,所以本文在阐述原理的基础上,会伴有大量源码走读的过程,作为理论的支撑论证. 走读的 Go 源码版本为 1.19.
内存管理与垃圾回收都属 Go 语言最复杂的模块,受限于笔者个人水平,文章内容可能有不足或纰漏之处,很欢迎大家进行批评指正.
1 内存模型
1.1 操作系统存储模型
本文既然要聊到 Golang 的内存模型设计,就让我们首先回顾操作系统中经典的多级存储模型设计.
观察上图,我们可以从中捕捉到的关键词是:
- 多级模型
- 动态切换
1.2 虚拟内存与物理内存
操作系统内存管理中,另一个重要概念是虚拟内存,其作用如下:
- 在用户与硬件间添加中间代理层(没有什么是加一个中间层解决不了的)
- 优化用户体验(进程感知到获得的内存空间是“连续”的)
- “放大”可用内存(虚拟内存可以由物理内存+磁盘补足,并根据冷热动态置换,用户无感知)
1.3 分页管理
操作系统中通常会将虚拟内存和物理内存切割成固定的尺寸,于虚拟内存而言叫作“页”,于物理内存而言叫作“帧”,原因及要点如下:
- 提高内存空间利用(以页为粒度后,消灭了不稳定的外部碎片,取而代之的是相对可控的内部碎片)
- 提高内外存交换效率(更细的粒度带来了更高的灵活度)
- 与虚拟内存机制呼应,便于建立虚拟地址->物理地址的映射关系(聚合映射关系的数据结构,称为页表)
- linux 页/帧的大小固定,为 4KB(这实际是由实践推动的经验值,太粗会增加碎片率,太细会增加分配频率影响效率)
1.4 Golang 内存模型
前几小节的铺垫,旨在从“内存模型设计”这件事情中收获一些触类旁通的设计理念.
下面步入正题,聊聊 Golang 的内存模型设计的几个核心要点:
- 以空间换时间,一次缓存,多次复用
由于每次向操作系统申请内存的操作很重,那么不妨一次多申请一些,以备后用.
Golang 中的堆 mheap 正是基于该思想,产生的数据结构. 我们可以从两个视角来解决 Golang 运行时的堆:
I 对操作系统而言,这是用户进程中缓存的内存
II 对于 Go 进程内部,堆是所有对象的内存起源
- 多级缓存,实现无/细锁化
堆是 Go 运行时中最大的临界共享资源,这意味着每次存取都要加锁,在性能层面是一件很可怕的事情.
在解决这个问题,Golang 在堆 mheap 之上,依次细化粒度,建立了 mcentral、mcache 的模型,下面对三者作个梳理:
- mheap:全局的内存起源,访问要加全局锁
- mcentral:每种对象大小规格(全局共划分为 68 种)对应的缓存,锁的粒度也仅限于同一种规格以内
- mcache:每个 P(正是 GMP 中的 P)持有一份的内存缓存,访问时无锁
这些概念,我们在第 2 节中都会再作详细展开,此处可以先不深究,注重于宏观架构即可.
- 多级规格,提高利用率
首先理下 page 和 mspan 两个概念:
(1)page:最小的存储单元.
Golang 借鉴操作系统分页管理的思想,每个最小的存储单元也称之为页 page,但大小为 8 KB
(2)mspan:最小的管理单元.
mspan 大小为 page 的整数倍,且从 8B 到 80 KB 被划分为 67 种不同的规格,分配对象时,会根据大小映射到不同规格的 mspan,从中获取空间.
于是,我们回头小节多规格 mspan 下产生的特点:
I 根据规格大小,产生了等级的制度
II 消除了外部碎片,但不可避免会有内部碎片
III 宏观上能提高整体空间利用率
IV 正是因为有了规格等级的概念,才支持 mcentral 实现细锁化
- 全局总览,留个印象
上图是 Thread-Caching Malloc 的整体架构图,Golang 正是借鉴了该内存模型. 我们先看眼架构,有个整体概念,后续小节中,我们会不断对细节进行补充.
2 核心概念梳理
2.1 内存单元 mspan
分点阐述 mspan 的特质:
- mspan 是 Golang 内存管理的最小单元
- mspan 大小是 page 的整数倍(Go 中的 page 大小为 8KB),且内部的页是连续的(至少在虚拟内存的视角中是这样)
- 每个 mspan 根据空间大小以及面向分配对象的大小,会被划分为不同的等级(2.2小节展开)
- 同等级的 mspan 会从属同一个 mcentral,最终会被组织成链表,因此带有前后指针(prev、next)
- 由于同等级的 mspan 内聚于同一个 mcentral,所以会基于同一把互斥锁管理
- mspan 会基于 bitMap 辅助快速找到空闲内存块(块大小为对应等级下的 object 大小),此时需要使用到 Ctz64 算法.
mspan 类的源码位于 runtime/mheap.go 文件中:
2.2 内存单元等级 spanClass
mspan 根据空间大小和面向分配对象的大小,被划分为 67 种等级(1-67,实际上还有一种隐藏的 0 级,用于处理更大的对象,上不封顶)
下表展示了部分的 mspan 等级列表,数据取自 runtime/sizeclasses.go 文件中:
| class | bytes/obj | bytes/span | objects | tail waste | max waste |
| 1 | 8 | 8192 | 1024 | 0 | 87.50% |
| 2 | 16 | 8192 | 512 | 0 | 43.75% |
| 3 | 24 | 8192 | 341 | 8 | 29.24% |
| 4 | 32 | 8192 | 256 | 0 | 21.88% |
| ... | |||||
| 66 | 28672 | 57344 | 2 | 0 | 4.91% |
| 67 | 32768 | 32768 | 1 | 0 | 12.50% |
对上表各列进行解释:
(1)class:mspan 等级标识,1-67
(2)bytes/obj:该大小规格的对象会从这一 mspan 中获取空间. 创建对象过程中,大小会向上取整为 8B 的整数倍,因此该表可以直接实现 object 到 mspan 等级 的映射
(3)bytes/span:该等级的 mspan 的总空间大小
(4)object:该等级的 mspan 最多可以 new 多少个对象,结果等于 (3)/(2)
(5)tail waste:(3)/(2)可能除不尽,于是该项值为(3)%(2)
(6)max waste:通过下面示例解释:
以 class 3 的 mspan 为例,class 分配的 object 大小统一为 24B,由于 object 大小 <= 16B 的会被分配到 class 2 及之前的 class 中,因此只有 17B-24B 大小的 object 会被分配到 class 3.
最不利的情况是,当 object 大小为 17B,会产生浪费空间比例如下:
除了上面谈及的根据大小确定的 mspan 等级外,每个 object 还有一个重要的属性叫做 nocan,标识了 object 是否包含指针,在 gc 时是否需要展开标记.
在 Golang 中,会将 span class + nocan 两部分信息组装成一个 uint8,形成完整的 spanClass 标识. 8 个 bit 中,高 7 位表示了上表的 span 等级(总共 67 + 1 个等级,8 个 bit 足够用了),最低位表示 nocan 信息.
代码位于 runtime/mheap.go
2.3 线程缓存 mcache
要点:
(1)mcache 是每个 P 独有的缓存,因此交互无锁
(2)mcache 将每种 spanClass 等级的 mspan 各缓存了一个,总数为 2(nocan 维度) * 68(大小维度)= 136
(3)mcache 中还有一个为对象分配器 tiny allocator,用于处理小于 16B 对象的内存分配,在 3.3 小节中详细展开.
代码位于 runtime/mcache.go:
2.4 中心缓存 mcentral
要点:
(1)每个 mcentral 对应一种 spanClass
(2)每个 mcentral 下聚合了该 spanClass 下的 mspan
(3)mcentral 下的 mspan 分为两个链表,分别为有空间 mspan 链表 partial 和满空间 mspan 链表 full
(4)每个 mcentral 一把锁
代码位于 runtime/mcentral.go
2.5 全局堆缓存 mheap
要点:
- 对于 Golang 上层应用而言,堆是操作系统虚拟内存的抽象
- 以页(8KB)为单位,作为最小内存存储单元
- 负责将连续页组装成 mspan
- 全局内存基于 bitMap 标识其使用情况,每个 bit 对应一页,为 0 则自由,为 1 则已被 mspan 组装
- 通过 heapArena 聚合页,记录了页到 mspan 的映射信息(2.7小节展开)
- 建立空闲页基数树索引 radix tree index,辅助快速寻找空闲页(2.6小节展开)
- 是 mcentral 的持有者,持有所有 spanClass 下的 mcentral,作为自身的缓存
- 内存不够时,向操作系统申请,申请单位为 heapArena(64M)
代码位于 runtime/mheap.go
2.6 空闲页索引 pageAlloc
与 mheap 中,与空闲页寻址分配的基数树索引有关的内容较为晦涩难懂. 网上能把这个问题真正讲清楚的文章几乎没有.
所幸我最后找到这个数据结构的作者发布的笔记,终于对方案的原貌有了大概的了解,这里粘贴链接,供大家自取:https://go.googlesource.com/proposal/+/master/design/35112-scaling-the-page-allocator.md
要理清这棵技术树,首先需要明白以下几点:
(1)数据结构背后的含义:
I 2.5 小节有提及,mheap 会基于 bitMap 标识内存中各页的使用情况,bit 位为 0 代表该页是空闲的,为 1 代表该页已被 mspan 占用.
II 每棵基数树聚合了 16 GB 内存空间中各页使用情况的索引信息,用于帮助 mheap 快速找到指定长度的连续空闲页的所在位置
III mheap 持有 2^14 棵基数树,因此索引全面覆盖到 2^14 * 16 GB = 256 T 的内存空间.
(2)基数树节点设定
基数树中,每个节点称之为 PallocSum,是一个 uint64 类型,体现了索引的聚合信息,包含以下四部分:
- start:最右侧 21 个 bit,标识了当前节点映射的 bitMap 范围中首端有多少个连续的 0 bit(空闲页),称之为 start;
- max:中间 21 个 bit,标识了当前节点映射的 bitMap 范围中最多有多少个连续的 0 bit(空闲页),称之为 max;
- end:左侧 21 个 bit,标识了当前节点映射的 bitMap 范围中最末端有多少个连续的 0 bit(空闲页),称之为 end.
- 最左侧一个 bit,弃置不用
(3)父子关系
- 每个父 pallocSum 有 8 个子 pallocSum
- 根 pallocSum 总览全局,映射的 bitMap 范围为全局的 16 GB 空间(其 max 最大值为 2^21,因此总空间大小为 2^21*8KB=16GB);
- 从首层向下是一个依次八等分的过程,每一个 pallocSum 映射其父节点 bitMap 范围的八分之一,因此第二层 pallocSum 的 bitMap 范围为 16GB/8 = 2GB,以此类推,第五层节点的范围为 16GB / (8^4) = 4 MB,已经很小
- 聚合信息时,自底向上. 每个父 pallocSum 聚合 8 个子 pallocSum 的 start、max、end 信息,形成自己的信息,直到根 pallocSum,坐拥全局 16 GB 的 start、max、end 信息
- mheap 寻页时,自顶向下. 对于遍历到的每个 pallocSum,先看起 start 是否符合,是则寻页成功;再看 max 是否符合,是则进入其下层孩子 pallocSum 中进一步寻访;最后看 end 和下一个同辈 pallocSum 的 start 聚合后是否满足,是则寻页成功.
代码位于 runtime/mpagealloc.go
基数树节点
2.7 heapArena
- 每个 heapArena 包含 8192 个页,大小为 8192 * 8KB = 64 MB
- heapArena 记录了页到 mspan 的映射. 因为 GC 时,通过地址偏移找到页很方便,但找到其所属的 mspan 不容易. 因此需要通过这个映射信息进行辅助.
- heapArena 是 mheap 向操作系统申请内存的单位(64MB)
代码位于 runtime/mheap.go
3 对象分配流程
下面来串联 Golang 中分配对象的流程,不论是以下哪种方式,最终都会殊途同归步入 mallocgc 方法中,并且根据 3.1 小节中的策略执行分配流程:
- new(T)
- &T{}
- make(xxxx)
3.1 分配流程总览
Golang 中,依据 object 的大小,会将其分为下述三类:
不同类型的对象,会有着不同的分配策略,这些内容在 mallocgc 方法中都有体现.
核心流程类似于读多级缓存的过程,由上而下,每一步只要成功则直接返回. 若失败,则由下层方法兜底.
对于微对象的分配流程:
(1)从 P 专属 mcache 的 tiny 分配器取内存(无锁)
(2)根据所属的 spanClass,从 P 专属 mcache 缓存的 mspan 中取内存(无锁)
(3)根据所属的 spanClass 从对应的 mcentral 中取 mspan 填充到 mcache,然后从 mspan 中取内存(spanClass 粒度锁)
(4)根据所属的 spanClass,从 mheap 的页分配器 pageAlloc 取得足够数量空闲页组装成 mspan 填充到 mcache,然后从 mspan 中取内存(全局锁)
(5)mheap 向操作系统申请内存,更新页分配器的索引信息,然后重复(4).
对于小对象的分配流程是跳过(1)步,执行上述流程的(2)-(5)步;
对于大对象的分配流程是跳过(1)-(3)步,执行上述流程的(4)-(5)步.
3.2 主干方法 mallocgc
先上道硬菜,malloc 方法主干全流程展示.
如果觉得理解曲线太陡峭,可以先跳到后续小节,把拆解的各部分模块都熟悉后,再回过头来总览一遍.
代码位于 runtime/malloc.go 文件中:
3.3 步骤(1):tiny 分配
每个 P 独有的 mache 会有个微对象分配器,基于 offset 线性移动的方式对微对象进行分配,每 16B 成块,对象依据其大小,会向上取整为 2 的整数次幂进行空间补齐,然后进入分配流程.
3.4 步骤(2):mcache 分配
在 mspan 中,基于 Ctz64 算法,根据 mspan.allocCache 的 bitMap 信息快速检索到空闲的 object 块,进行返回.
代码位于 runtime/malloc.go 文件中:
3.5 步骤(3):mcentral 分配
当 mspan 无可用的 object 内存块时,会步入 mcache.nextFree 方法进行兜底.
代码位于 runtime/mcache.go 文件中:
倘若 mcache 中,对应的 mspan 空间不足,则会在 mcache.refill 方法中,向更上层的 mcentral 乃至 mheap 获取 mspan,填充到 mache 中:
代码位于 runtime/mcache.go 文件中:
mcentral.cacheSpan 方法中,会加锁(spanClass 级别的 sweepLocker),分别从 partial 和 full 中尝试获取有空间的 mspan:
代码位于 runtime/mcentral.go 文件中:
3.6 步骤(4):mheap 分配
在 mcentral.cacheSpan 方法中,倘若从 partial 和 full 中都找不到合适的 mspan 了,则会调用 mcentral 的 grow 方法,将事态继续升级:
经由 mcentral.grow 方法和 mheap.alloc 方法的周转,最终会步入 mheap.allocSpan 方法中:
代码位于 runtime/mheap.go
代码位于 runtime/mheap.go
倘若对 mheap 空闲页分配器基数树 pageAlloc 分配空闲页的源码感兴趣,莫慌,3.8 小节见.
3.7 步骤(5):向操作系统申请
倘若 mheap 中没有足够多的空闲页了,会发起 mmap 系统调用,向操作系统申请额外的内存空间.
代码位于 runtime/mheap.go 文件的 mheap.grow 方法中:
3.8 步骤(4)拓展:基数树寻页
核心源码位于 runtime/pagealloc.go 的 pageAlloc 方法中,要点都以在代码中给出注释:
4 展望
祝贺,到此为止,整个 Golang 内存分配流程已经梳理完毕.
两周内,我会带来新作——Golang 垃圾回收机制.
Go 友们不见不散~
文末小广告:
欢迎老板们关注我的个人公众号:小徐先生的编程世界
我会不定期更新个人纯原创的编程技术博客,技术栈以 go 语言为主,让我们一起点亮更多的编程技能树吧!