WEBKT

拆解 Go 内存分配器:从 mspan 结构到三级缓存的运作机制

5 0 0 0

在现代编程语言中,内存分配器的性能直接决定了整个运行时的吞吐量。Go 语言的内存分配器源自 Google 的 Thread-Caching Malloc(TCMalloc)算法,并针对 Go 的垃圾回收(GC)和并发模型(GMP)进行了深度的定制与优化。

Go 内存分配的核心思想极其明确:多级缓存、大小分级、以及动静分离

本文将自底向上,深度拆解 Go 内存分配器中最核心的实体 mspan,以及它如何在 mcachemcentralmheap 组成的三级缓存架构中高效协同运作。


最小分配单元的载体:mspan

在 Go 运行时(Runtime)中,直接向系统申请的内存是以 Page(页,大小为 8KB) 为单位的。然而,程序中绝大多数对象都远小于 8KB。为了避免内存碎片并提高分配效率,Go 将多个连续的 Page 组合成一个更大的内存块,这就是 mspan(内存跨度)。

mspan 是 Go 内存管理的最基本单位。

+-------------------------------------------------------------+
|                           mspan                             |
|  +--------+--------+--------+--------+ ... +--------+       |
|  | Element| Element| Element| Element|     | Element|       |
|  +--------+--------+--------+--------+ ... +--------+       |
+-------------------------------------------------------------+
   |<----------- sizeclass 对应的大小 (如 8B, 16B...) --------->|

1. mspan 的内部结构

在源码 runtime/mheap.go 中,mspan 是一个双向链表结构:

type mspan struct {
    next      *mspan     // 链表后驱
    prev      *mspan     // 链表前驱
    startAddr uintptr    // 该 span 起始地址
    npages    uintptr    // 包含的 Page 数量

    manualFreeList gclinkptr // 手动释放的单向链表

    freeindex uintptr    // 标记下一次分配空闲对象的起始索引
    nelems    uintptr    // 这个 span 中能容纳的对象总数

    allocCache uint64    // 位图缓存,加速寻找空闲对象
    allocBits  *gcBits   // 标记哪些对象已被分配的位图
    gcmarkBits *gcBits   // GC 标记阶段使用的位图

    spanclass spanClass  // 对应的跨度类(大小与类型标记)
    // ... 省略部分统计和状态字段
}

2. 关键设计:Span Class 与对象分级

Go 默认定义了 67 种不同大小的级别(Size Class),从小到 8 字节,大到 32KB。
每个 mspan 在初始化时都会被赋予一个特定的 spanClass

spanClass 是一个 uint8 类型的数值,其生成逻辑如下:

  • 前 7 位:对应 1 到 67 种大小级别(Size Class)。其中 Class 0 比较特殊,表示大于 32KB 的大对象。
  • 最低 1 位noscan 标志位。1 表示该 span 存放的是不包含指针的对象(如 string[]byte 等),GC 时无需扫描;0 表示存放包含指针的对象,GC 必须扫描。

通过将 scannoscan 物理隔离在不同的 mspan 中,Go 大幅减少了垃圾回收时的 CPU 扫描开销。这也是 Go 相比普通 TCMalloc 的重要改进。


协同枢纽:三级缓存架构

为了在多核并发环境下实现高吞吐,Go 设计了三层内存屏障,层层分流分配请求,最大程度降低锁竞争。

+---------------------------------------------------+
|              M (Goroutine 执行上下文)             |
+---------------------------------------------------+
                         |
                         v
+---------------------------------------------------+
|            mcache (线程本地,无锁)                 |
|   [spanClass 0][spanClass 1] ... [spanClass 135]  |
+---------------------------------------------------+
                         |
           (若 mcache 不足,向 central 申请)
                         v
+---------------------------------------------------+
|            mcentral (全局共享,分段锁)             |
|   [spanClass 0]   [spanClass 1] ... [spanClass 135]
+---------------------------------------------------+
                         |
           (若 mcentral 不足,向 heap 申请)
                         v
+---------------------------------------------------+
|            mheap (全局堆内存,全局锁/Radix)        |
|  - arena / pageAlloc (页分配器)                   |
+---------------------------------------------------+

1. 第一级:mcache(线程本地缓存)

mcache 绑定在 GMP 模型中的 P(Processor) 上。
因为在同一时刻,一个 P 只能被一个 M(系统线程)绑定并运行 Goroutine,所以 Goroutine 在 mcache 上进行分配时是完全无锁的。

  • 内部结构mcache 持有一个大小为 136(即 68 * 2,包含 Class 0)的 *mspan 数组。
  • 分配逻辑:当 Goroutine 申请内存时,计算出对象的大小对应的 spanClass,直接从 mcache.alloc[spanClass] 中找到对应的 mspan,通过 allocCache 位图快速找到空闲位置并返回。

2. 第二级:mcentral(中央缓存)

mcache 中某个 spanClassmspan 已经被占满,无法继续分配时,P 就会向全局的 mcentral 申请一个新的、拥有空闲空间的 mspan

mcentral 收集了所有相同 spanClassmspan。为了避免全局单锁导致的竞争,Go 设计了 136 个独立的 mcentral 结构体,每个 mcentral 只负责一种 spanClass

type mcentral struct {
    spanclass spanClass
    partial   [2]spanSet // 存在空闲空间的 mspan 集合
    full      [2]spanSet // 已经被分配满、或被 mcache 占用的 mspan 集合
}
  • 分段存储partialfull 数组大小为 2,这是为了配合 GC 标记清除阶段。一个对应已清理(swept)的,另一个对应未清理(unswept)的。
  • 锁粒度:当 P 访问某个 mcentral 时,只需要锁住该 mcentral 实例,不同 spanClass 之间的分配完全并发,互不干扰。

3. 第三级:mheap(全局堆)

当某个 mcentral 也没有多余的 mspan 时,它会向 mheap 申请。
mheap 管理着整个 Go 进程的虚拟内存空间,其内部通过页分配器(Page Allocator,使用基数树 Radix Tree 实现)来检索和分配空闲的物理页。

如果 mheap 空间也不足,它会直接向操作系统(通过 mmapVirtualAlloc)申请新的内存页,补充到全局管理中。


内存分配的全景链路

当我们在代码中执行 new(T)make([]T, n) 时,Go 运行时会根据对象的大小,走不同的分配链路。

Go 将分配的对象分为三类:

  1. Tiny(微对象):大小 < 16B 且无指针。
  2. Small(小对象):大小在 16B 到 32KB 之间,或者 < 16B 但有指针。
  3. Large(大对象):大小 > 32KB。
                    对象分配请求
                         |
         +---------------+---------------+
         | < 16B 且无指针  | 16B ~ 32KB    | > 32KB
         v               v               v
    [ 微对象分配 ]    [ 小对象分配 ]   [ 大对象分配 ]
         |               |               |
   合并放入 mcache     计算 spanClass      直接绕过 cache
   的 tinySpan         从 mcache 获取      向 mheap 申请
         |               |               |
         |         (若 mcache 满)         |
         |               v               |
         +--------> 从 mcentral 补给 <---+
                         |
                   (若 mcentral 满)
                         v
                    从 mheap 申请
                         |
                    (若 mheap 满)
                         v
                   向操作系统 OS 申请

1. 微对象分配器(Tiny Allocator)

对于极小的对象(如一个小 int、一个小 struct),如果每个都分配一个 mspan 槽位,会产生大量的内部碎片。
Go 的 mcache 中有一个专门的 tiny 字段,它是一个 16 字节的缓冲区。

  • 多个 Tiny 对象会被合并打包存放进同一个 16 字节的区域中。
  • 只有当上一个 16 字节用完,或者无法对齐放下新对象时,才会分配新的 16 字节。

2. 小对象分配链路(Trace 追踪)

  1. 计算大小与 Class:根据对象字节数,计算出对应的 sizeclassspanClass
  2. 本地无锁尝试:访问当前 P 的 mcache.alloc[spanClass]
    • 如果该 mspan 还有空闲空间,通过 allocCache 快速定位并修改位图,返回地址。整个过程无锁,耗时纳秒级。
  3. 中央缓存补充:若 mcache 中该 mspan 满了:
    • mcache 将这个满的 mspan 退还给 mcentralfull 队列。
    • mcache 向该 spanClass 对应的 mcentral 申请一个有空闲空间的 mspan
    • mcentral 锁住自身,从 partial 列表中取出一个可用的 mspan 交付给 mcache
  4. 全局堆分配:若 mcentral 对应的 partial 也为空:
    • mcentral 向全局 mheap 申请特定数量的 Page。
    • mheap 使用页分配器进行高效检索,如果找到,则将其组装成一个新的 mspan 返回给 mcentral
  5. 系统调用:若 mheap 彻底没有可用页,调用 sysAlloc 向 OS 申请新内存。

3. 大对象分配链路

大对象(> 32KB)的分配极其简单直接。因为大对象的分配频率通常较低,且不适合塞进固定尺寸的 mspan

  • 绕过 mcachemcentral
  • 直接根据对象大小向上对齐到 Page 整数倍。
  • 锁住 mheap,直接从页分配器中划出对应的 Page 集合,并创建一个定制的 mspan 返回。

深度总结:Go 内存分配器的高效秘诀

Go 内存分配器之所以能支撑高并发的 Goroutine 频繁创建与销毁,主要得益于以下三点:

  1. 零锁开销(Lock-Free Path)
    mcache 绑定在 P 上,使 99% 的中小对象分配都在本地无锁链路上完成,极大地释放了多核 CPU 的并发性能。

  2. 极低的 GC 扫描成本(Zero-Scan Separation)
    通过 spanClass 的最低位,在物理内存层面将指针对象与非指针对象(noscan)隔离开。垃圾回收器在标记阶段,可以直接跳过整块 noscanmspan,极大缩短了 STW(Stop-The-World)的时间。

  3. 精细的内存规整(Bitmaps & Radix Tree)
    抛弃了传统的链表空闲块搜索,在 mspan 内部使用 uint64 位的 allocCache 位图,配合 CPU 的位运算指令(如 CTZ,Count Trailing Zeros),实现 $O(1)$ 的空闲槽位查找;在 mheap 层面使用 Radix Tree 页面分配器,大幅提高了大块内存的检索与合并效率。

编译大师 Go内存管理Go运行时

评论点评