Golang 内存模型与分配机制(上) | 青训营
2023/8/27 ·雨辰login
这篇介绍Go语言的内存管理机制
本篇内容引用自知乎用户@小徐先生.
这真的是一个宝藏博主,B站也有号:小徐先生1212
所以把他的笔记找来跟大家分享,希望大家都去看看,真的做的非常好。
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 文件中:
type mspan struct {
// 标识前后节点的指针
next *mspan
prev *mspan
// ...
// 起始地址
startAddr uintptr
// 包含几页,页是连续的
npages uintptr
// 标识此前的位置都已被占用
freeindex uintptr
// 最多可以存放多少个 object
nelems uintptr // number of object in the span.
// bitmap 每个 bit 对应一个 object 块,标识该块是否已被占用
allocCache uint64
// ...
// 标识 mspan 等级,包含 class 和 noscan 两部分信息
spanclass spanClass
// ...
}
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 大小