本文章为译文,原作者 Jesús Espino。在这里查看他关于 Go Runtime 的系列文章:Understanding the Go Runtime

首图

上一篇文章 中,我们探讨了 Go 运行时如何引导自身 —— 从操作系统将控制权交给 Go 二进制文件,到你的 func main() 开始运行。在引导过程中,运行时最先设置的事情之一就是 内存分配器(memory allocator)。这就是我们今天要探索的内容。

可以把内存分配器想象成一个仓库管理员。你的程序不断需要不同大小的箱子 —— 有时很小,有时很大 —— 而且需要 快速 拿到它们。分配器的工作就是尽可能快地分发这些箱子,保持仓库井井有条以避免浪费,并与垃圾收集器协作,回收不再被使用的箱子。

但在我们深入了解仓库本身之前,先来聊聊数据实际在何时被放到那里。

何时发生内存分配?

并非程序中的每个变量都会经过内存分配器。Go 有两个存放数据的地方:栈(stack)堆(heap)

栈比较简单。每个函数调用都在栈上获得自己的一小块临时空间,当函数返回时,这块空间自动消失。它快速且简单 —— 无需记账。

但有时,数据需要在创建它的函数结束后继续存在。也许你返回了一个指向某物的指针,或者存储了一个程序其他部分稍后会使用的值。这些数据不能存活在栈上 —— 当函数返回时它们就会消失。因此它们被放在 堆(heap) 上,这是一个生命周期更长的内存区域。

Go 编译器在这方面实际上非常智能。它在编译时分析你的代码,以决定哪些数据可以留在栈上,哪些需要进入堆 —— 这被称为 逃逸分析(escape analysis)(我们在IR 文章中详细介绍过)。

每次有数据最终进入堆时,就是内存分配器发挥作用的时候了。它是在堆上寻找空闲空间并将其交出去的系统。这就是本文剩余部分要讲的内容。

一个小简化:上面的描述并不完整。在 Go 中,goroutine 的栈实际上是从堆上分配的 —— 所以是内存分配器提供了栈所需的空间。但是一旦栈被分配,其上的变量管理与堆对象的管理方式截然不同:它们只是栈帧中的偏移量,每个变量的分配不涉及分配器。因此,虽然分配器负责栈的 内存,但它不参与将单个变量放置到栈上。在本文中,我们将聚焦于堆这方面。

所以分配器管理着堆内存。但这些内存最初又是从哪里来的呢?

为什么不直接向操作系统请求?

当你的程序需要内存时,总得有人提供它。最终,这个人就是操作系统。操作系统管理着机器上的所有物理 RAM,任何需要内存的进程都必须通过系统调用(如 Linux/macOS 上的 mmap 或 Windows 上的 VirtualAlloc)向操作系统请求。

问题在于系统调用很慢。它们涉及从用户空间切换到内核空间、操作系统进行自己的记账操作,然后再切换回来。如果 Go 每次你写下 make([]byte, 100)&MyStruct{} 时都进行一次系统调用,性能将会非常糟糕 —— 尤其对于设计为高并发的语言来说,可能有数千个 goroutine 同时分配内存。

因此,Go 运行时采取了不同的方法:它 预先向操作系统请求大块内存(稍后我们会看到,在大多数 64 位系统上是 64MB),然后在内部管理分配。当你的代码需要 100 字节时,分配器不会去找操作系统 —— 而是从已经拥有的内存中切出 100 字节。只有当内存耗尽时,它才会再次求助于操作系统。

这就是内存分配器背后的基本思想。它位于你的程序和操作系统之间,充当一个快速的中间层,通过避免在热路径上进行系统调用来使分配变得廉价。但在内部管理所有这些内存并非易事 —— 分配器需要跟踪哪些内存正在使用,哪些是空闲的,并且必须在不成为瓶颈的情况下完成这一切。让我们看看它是如何做到的。

Arena 与页(Page)

我们说过运行时会向操作系统请求大块内存。这些块被称为 arena,在大多数 64 位系统上,每个 arena 的大小是 64MB(在 Windows 和 32 位系统上是 4MB,在 WebAssembly 上是 512KB)。

当你的程序启动并开始分配时,运行时会向操作系统请求它的第一个 arena。随着程序需要更多内存,它会请求额外的 arena。这些 arena 在内存中不必是连续的 —— 运行时通过一个内部映射来跟踪它们。

这是否意味着 Go 会立即占用 64MB 的 RAM? 不是的。当运行时“请求”一个 arena 时,它首先只是 预留(reserve) 了 64MB 的 地址空间 —— 可以想象成在一块土地上标上你的名字,但尚未在上面建造任何东西。此时并未使用任何物理内存。然后,当运行时实际需要使用该 arena 的部分区域时,它会告诉操作系统以大约 4MB 的块为单位使这些区域变为 可用(commit)。即便如此,直到你的程序实际写入这些地址时,操作系统才会分配真正的物理内存 —— 物理内存是按需出现的,一次一个操作系统页,完全透明。因此,真正的成本是渐进的:预留空间(基本上是免费的),根据需要以 4MB 的块为单位提交(每次一个系统调用),然后让操作系统在后台根据需要填充物理内存。这是分配器如此之快的另一个原因 —— 一旦内存被提交,分配器所做的所有事情都不再需要与操作系统通信。

但是一个 64MB 的块太大了,无法直接分发出去。如果你的程序请求 32 字节,你不会想给它一整个 arena。因此,每个 arena 被划分为 页(page),大小为 8KB(8192 字节)。这是 Go 自己的页大小 —— 不同于通常为 4KB 的操作系统页大小。

页是分配器内部工作的基本单位。当需要满足一个分配请求时,它以页为单位来操作 —— 需要抓取多少页,哪些页是空闲的,哪些正在使用。一个 64MB 的 arena 包含 8192 个页(64MB / 8KB),运行时跟踪每个页的状态。

但是对于大多数分配来说,8KB 仍然太大了。一个 32 字节的结构体不需要一整个页。这就是 span 发挥作用的地方。

Span:对象的栖息地

一个 span 是一个或多个连续的页,专门用于存放 单一大小 的对象。这是分配器实际将内存交给你的程序的层级。

让我们具体说明。假设你的程序需要许多 32 字节的对象。分配器会取一个页(8KB),将其转换为一个用于 32 字节对象的 span,并将其划分为 256 个槽位(8192 / 32 = 256)。每个槽位可以存放恰好一个对象。当你分配一个 32 字节对象时,分配器只需在该 span 中找到下一个空闲槽位并返回。当你需要另一个时,它再抓取下一个空闲槽位。快速而简单。

这之所以可行,是因为 一个 span 中的每个槽位大小相同。无需搜索适合的块,span 内部没有碎片,也无需合并相邻的空闲块。只需找到一个空闲槽位并使用它。为了找到那个空闲槽位,每个 span 维护一个 名为 allocBits 的位图(bitmap) —— 每个槽位对应一个比特位,1 表示“使用中”,0 表示“空闲”。寻找下一个空闲槽位就是扫描下一个 0 位。span 还会跟踪它在内存中的起始位置、覆盖了多少个页、有多少个槽位以及当前有多少已被分配。还有一个称为 gcmarkBits 的第二个位图,供垃圾收集器使用 —— 我们稍后会讲到。所有这些元数据都是 span 结构本身的一部分 —— 运行时分分配一个独立对象来管理 span —— 并不存储在你对象所在的页中。因此,一个页(或 span 覆盖的任意多个页)的全部 8KB 空间都可用于对象槽位。

大小类(Size Class)

现在,如果每个 span 只存放一种大小的对象,我们就需要为不同的大小准备不同的 span。但分配器不可能为每个可能的字节数都创建一个 span —— 那将无法管理。相反,Go 定义了 68 个大小类,范围从 8 字节到 32KB。当你分配,比如说 20 字节时,Go 会将其向上舍入到最接近的大小类(此例中为 24 字节),并使用该类的 span。我们会因为舍入而浪费几个字节,但换来的简单性和速度是值得的。

以下是一些例子:

对象大小Span 大小 (页数)每个 Span 的对象数
18 B8 KB (1 页)1024
432 B8 KB (1 页)256
10128 B8 KB (1 页)64
321024 B8 KB (1 页)8
413072 B24 KB (3 页)8
465376 B16 KB (2 页)3
518192 B8 KB (1 页)1
6018432 B72 KB (9 页)4
6527264 B80 KB (10 页)3
6732768 B32 KB (4 页)1

观察表格,页数看起来可能有点随机 —— 为什么一个 18KB 的对象需要 9 个页,而一个 8KB 的对象只需要 1 个页?规则其实很简单:从 1 个页开始,不断增加页数,直到 末尾的浪费空间(即无法再容纳另一个对象的剩余字节)小于 span 的 12.5%。对于像 32 字节这样的小对象,一个页正好容纳 256 个对象,没有剩余 —— 完美,无需更多页。对于像 3KB 或 5KB 这样的中等大小,单个页会留下太多不可用的空间,因此 span 会增长到 2、3 甚至最多 10 个页,以减少浪费。

你可能注意到有些类每个 span 只容纳 1 个对象 —— 比如类 51(8KB)或类 67(32KB)。这意味着进行一次分配后,span 就满了。使用更多页让 span 容纳更多对象不是更好吗?不一定。更大的 span 意味着即使你只需要该大小的一两个对象,也会有更多内存被预留。对于像 32 字节这样的小对象,程序通常会一次分配数百个,将 256 个对象打包到一个 span 中是有意义的。但对于较大的对象,大多数程序只需要少数几个,因此保持 span 较小可以避免浪费内存。

因此,大小类系统处理了从 8 字节到 32KB 的常见范围。但并非所有东西都能恰好适合这个范围。

边界:大对象与微小对象

你可能注意到了一些奇怪的地方:我们说有 68 个大小类,但表格中是从 1 到 67 —— 只有 67 个。缺失的那个在哪里?那就是 大小类 0,它被保留用于 大于 32KB 的对象。与其他类不同,它没有固定的对象大小或 span 大小。相反,一个类 0 的 span 会获得对象所需的确切页数,并且没有槽位细分 —— 一个对象,一个 span。

在另一端,非常小的对象,比如一个 bool 或一个 int8,会遇到另一个问题。最小的大小类是 8 字节,所以即使是一个 1 字节的值也会获得一个 8 字节的槽位 —— 对于这么小的东西来说,浪费很大。为了解决这个问题,Go 有一个 微小分配器(tiny allocator),它将多个微小对象(小于 16 字节且不含指针)打包到一个 16 字节的块中。这样,几个布尔值或小整数可以共享一个槽位,而不是各自占用一个。这极大地减少了分配大量小值的程序的浪费。

我们已经看到了 span 是如何按大小组织的 —— 但分配器关心的不仅仅是大小。

Span 类(Span Class):大小 + 指针

分配器不仅关心对象的 大小,还关心对象是否包含 指针(pointer)。为什么?因为垃圾收集器需要扫描包含指针的对象以跟踪引用并找到存活数据。不含指针的对象(如 [100]byte 或只包含整数的结构体)在 GC 期间可以被安全地跳过 —— 没有东西需要跟踪。

因此,Go 为每一种都维护了单独的 span:一种用于需要扫描的对象(scan),另一种用于不需要扫描的对象(noscan)。大小类与 scan/noscan 标志的组合称为 span 类(span class)。有 68 个大小类和 2 个变体,总共得到 136 个 span 类

综合以上所有,整体结构如下图所示:

Arena、页、span 和对象布局

分配器向操作系统请求 arena,将其划分为页,将页分组为 span,再将 span 划分为固定大小的槽位。每一层都使下一层更易于管理。但我们还有一个尚未讨论的大问题:Go 程序同时运行许多 goroutine,它们都需要分配内存。如何保持这一切的组织有序,而不让分配器成为瓶颈?

锁竞争问题

到目前为止,我们已经了解了内存是如何组织的 —— arena、页、span、槽位。但有一个关键问题我们还没有解决:当 多个 goroutine 同时尝试分配内存时会发生什么?

想象一下,你有一个全局的 span 列表。每次任何 goroutine 需要内存时,它都必须获取一个锁,找到一个空闲槽位,然后释放锁。在数千个并发运行的 goroutine 情况下,这个锁变成了瓶颈 —— goroutine 花在相互等待上的时间比实际做有用工作的时间还多。

这就是锁竞争问题,解决它也是 Go 分配器设计中最重要的方面之一。解决方案是一个 三层级结构,其中每一级都有不同的范围和不同的锁行为:

第 1 级:mcache(每个 P 独有,无锁)

还记得在 引导文章 中提到,Go 调度器有固定数量的 P(处理器),通常每个 CPU 核一个。每个 P 都有自己的 mcache —— 一个私有的 span 集合,每种 span 类一个。当运行在某个 P 上的 goroutine 需要分配时,它从该 P 的 mcache 中抓取一个槽位。由于一个 P 上同时只运行一个 goroutine,因此 不需要锁。这是快速路径,处理了绝大多数分配。

第 2 级:mcentral(每个 span 类,短暂持锁)

当一个 mcache 中特定 span 类的 span 满了,它需要一个新的。这时就需要 mcentral 出场了。对于 136 个 span 类中的每一个,都有一个 mcentral,它持有一个共享的 span 池。mcache 将其满的 span 归还给 mcentral,并抓取一个新的有空闲槽位的 span。这需要一个锁,但时间很短 —— 只是将一个 span 换成另一个。而且由于每个 span 类都有自己的 mcentral,分配不同大小或 scan/noscan 变体的 goroutine 之间不会相互竞争。

第 3 级:mheap(全局,昂贵锁)

当一个 mcentral 没有更多的 span 可以分发时,它会向 mheap 请求新的页来创建一个新的 span。mheap 是全局的页分配器 —— 只有一个,访问它需要一个全局锁。这是慢速路径。它涉及搜索空闲页,可能向操作系统请求一个新的 arena,并初始化一个新的 span。但这很少发生,因为上面的层级吸收了大部分需求。

这也是我们之前提到的大对象(>32KB)最终到达的地方 —— 它们跳过 mcache 和 mcentral,直接去找 mheap。

整个设计就像一个缓存链:

内存分配器层级结构

每一级都作为下一级的缓存。快速路径是无锁的,中速路径使用细粒度锁,慢速路径非常罕见,以至于其开销在实践中无关紧要。这基于一种称为 tcmalloc(Thread-Caching Malloc)的方法,最初由 Google 为 C/C++ 程序设计,但针对 Go 的特定需求进行了调整。

现在我们了解了结构和层级,让我们一步步看看实际发生了什么。

分配流程

所有分配都经过运行时中的一个函数:mallocgc()(位于 src/runtime/malloc.go)。

它做的第一件事是检查分配的大小。根据对象的大小,会走非常不同的路径。让我们从最简单的情况开始。

零大小分配

一个有趣的边界情况:如果你分配一个零大小的东西,比如 struct{}{},会怎样?Go 不会费心分配任何东西 —— 所有零字节分配都返回指向同一个全局变量(zerobase)的指针。这是安全的,因为你永远无法实际读取或写入一个零大小的对象。

现在来看真正的分配,从最小的开始。

微小对象

对于不含指针的微小对象 —— 比如 boolint8 或小的无指针结构体 —— 分配器使用我们之前提到的微小分配器。mcache 会跟踪一个 当前微小块(current tiny block)(只是来自大小类 2 的 span 的一个普通的 16 字节槽位,没什么特别)和一个 偏移量(offset),用于标记该块已被使用了多少。

当发生一个微小分配时,分配器首先将大小向上舍入以实现适当对齐(根据对象大小对齐到 2、4 或 8 字节),然后检查当前微小块从当前偏移量到末尾是否有足够的空间。如果空间足够,分配器返回一个指向 block + offset 的指针并推进偏移量。因此,一个 1 字节的 bool 后跟一个 1 字节的 int8 将会被打包在同一个 16 字节块内相邻的位置。

当当前微小块空间不足时,分配器从 mcache 的大小类 2 的 span 中抓取一个新的 16 字节槽位(使用常规的小对象路径),并将对象放在该槽位的开头。但这里有一个微妙的细节:分配器不会盲目地切换到新块。它会比较旧块剩余的空闲空间与新块(即 16 减去刚放置的对象大小)剩余的空闲空间。哪个块 剩余空间更多,哪个就成为当前的微小块。这最大限度地减少了浪费 —— 分配器总是优先选择为未来微小分配留出最多空间的块。无论哪种情况,你请求的对象都是从新槽位返回的;只是哪个块保留为“当前”微小块以供下一次微小分配的问题。

因为微小分配器捕获了所有 16 字节以下的无指针对象,它最终吸收了大部分预期会落入最小大小类的分配。实际上,8 字节大小类(类 1)专门用于 确实 包含指针的 8 字节值 —— 比如一个 *int 或一个单指针接口。一个 int64,尽管是 8 字节,却会通过微小分配器。

一旦对象达到 16 字节或更大(或包含指针),微小分配器就不再适用,我们进入主要的分配路径。

小对象(16B 到 32KB)

这是最常见的路径,也是整个层级结构为之优化的路径。流程如下:

  1. 向上舍入到最近的大小类 并确定 span 类(大小 + scan/noscan)。
  2. 检查 mcache:查看该 span 类的 span,并使用位图找到下一个空闲槽位。如果有空闲槽位,则返回它。完成 —— 无需锁,只需要一些位操作。
  3. 如果 span 已满,mcache 将其归还给 mcentral,并请求一个新的有空闲槽位的 span。mcentral 首先查看它已有的 span。如果找到一个尚未被垃圾收集器清扫的 span,它会先清扫它,然后将其交出。
  4. 如果 mcentral 没有可用的,它会请求 mheap 分配新的页并创建一个新的 span。
  5. 如果 mheap 没有足够的空闲页,它会向操作系统请求一个新的 arena。

大多数分配在第 2 步就停止了。第 3-5 步发生的频率逐渐降低,这就是系统性能良好的原因。

最后是大对象。

大对象(> 32KB)

正如我们之前提到的,这些对象完全跳过 mcache 和 mcentral,直接去找 mheap,由 mheap 分配所需的确切页数。

我们已经看到了内存是如何分配的,但反过来呢 —— 内存是如何被释放的?

与垃圾收集器的集成

内存分配器并不是单独工作的 —— 它与 垃圾收集器(garbage collector) 紧密相连。我们将在以后的文章中详细探讨垃圾收集器,但了解它们交互的基础知识是值得的,因为这会影响分配器的行为。

垃圾收集器的工作是找出堆上哪些对象仍在使用,哪些是垃圾。它通过遍历对象图来实现这一点 —— 从已知的根(全局变量、栈变量等)开始,沿着指针找到所有可达的对象。任何无法到达的对象都是死的,可以被释放。

这就是每个 span 中 两个位图 发挥作用的地方。每个 span 都有一个 allocBits 位图(哪些槽位已被分配)和一个 gcmarkBits 位图(GC 发现哪些槽位是存活的)。在一个 GC 周期中,收集器在 gcmarkBits 中标记存活的对象。标记完成后,运行时会交换这两个位图 —— 因此 allocBits 现在只反映存活的对象,而所有未被标记的对象实际上被释放了。分配器随后可以重用这些槽位。

这也解释了你在分配流程中可能注意到的一件事:当 mcentral 将一个 span 交给 mcache 时,有时需要先 清扫(sweep) 它。清扫是查看一个 span 的位图,并找出在 GC 周期后哪些槽位是空闲的过程。分配器会惰性地执行此操作 —— 它不会一次清扫所有 span,而是在需要为新的分配使用它们时按需清扫。这会将清扫的开销分散到所有分配中,而不是集中在一个大的停顿中。

如果一个 span 在清扫后完全变空(每个对象都是垃圾),它的页会被归还给 mheap,并可以重新用于不同的 span 类。

因此,GC 找出哪些对象是死的,分配器回收这些槽位。但还有一个问题:是否有任何内存会归还给操作系统?

内存释放与回收(Scavenging)

当垃圾收集器释放对象时,它们不会回到操作系统。这些槽位只是在它们的 span 中变得可用,准备用于下一次分配。这些页仍然保留在运行时 —— 从操作系统的角度来看,你的程序仍在使用所有这些内存。

但如果你的程序经历了一个活动高峰,分配了大量内存,而现在其中大部分都是垃圾呢?你就会在 mheap 中有很多空闲页,什么也不做,而操作系统认为你的程序仍在使用所有这些内存。

这就是 回收器(scavenger) 发挥作用的地方。它是一个后台 goroutine,定期查找一段时间未使用的空闲页,并告诉操作系统可以回收它们。这些页仍然映射在你的程序的地址空间中(这样运行时以后可以重用它们,而无需进行新的系统调用),但操作系统知道它可以取回它们背后的物理内存。在 Linux 上,这是通过 MADV_DONTNEED 实现的 —— 一个提示,表示“我现在不需要这些内存,可以随意在其他地方使用它们”。

这是一个平衡行为。过于急切地归还内存会损害性能 —— 如果程序很快又需要这些内存,就必须重新将其调入。但是,持有过多未使用的内存会浪费系统资源。回收器试图找到正确的平衡点。

总结

让我们回顾一下我们涵盖的内容。在编译时,逃逸分析决定哪些值需要存活在堆上。在运行时,内存分配器是实际管理堆内存的组件。运行时不每次都向操作系统请求内存,而是预先抓取大的 64MB arena,并将其细分为 8KB 的 。页被分组为 span,每个 span 为单一大小的对象保存固定大小的槽位 —— 这是从 8 字节到 32KB 的 68 个 大小类 之一。scan/noscan 的区别将其翻倍为 136 个 span 类,这样垃圾收集器可以跳过不含指针的对象。

为了避免锁竞争,分配器使用三层级结构:mcache(每个 P 独有,无锁)处理大多数分配,mcentral(每个 span 类,短暂持锁)用新 span 填充 mcache,而 mheap(全局)在其余都耗尽时分配页。微小对象被打包在一起,大对象则完全绕过层级结构。

分配器通过每个 span 上的双位图与垃圾收集器紧密协作,而 回收器 确保未使用的内存最终归还给操作系统。

如果你想自己探索实现,运行时源代码在 src/runtime/malloc.gomheap.gomcache.gomcentral.go 中,注释很好,而且出奇地可读。

在下一篇文章中,我们将探讨 调度器 —— 运行时中决定哪个 goroutine 在哪里运行,以及如何将数千个 goroutine 多路复用到少量操作系统线程上的部分。