Go 内存分配器底层源码走读:mcache、mcentral、mheap 与页分配器

封面信息图

在现代高并发后端服务中,内存分配的速度与碎片控制直接决定了系统的并发吞吐上限。

如果每个 Goroutine 分配一个小对象都要向操作系统发起一次内核态的 malloc 或 brk/mmap 系统调用,不仅会有严重的内核上下文切换开销,多线程并发争抢全局锁还会让性能瞬间瘫痪。

Go 语言在用户态实现了一套极其精密的 TCMalloc(Thread-Caching Malloc)衍生内存分配器。

在 Go 运行时内部:

  • 内存分配被划分为 微对象($<16\text{B}$)、小对象($16\text{B}\sim 32\text{KB}$)与大对象($>32\text{KB}$) 三级流水线;
  • 架构上构建了 mcache(P 本地无锁缓存) $\rightarrow$ mcentral(全局跨 P 共享中心) $\rightarrow$ mheap(全局大堆与页分配器) 的三级物理分层金字塔。

今天我们深入 Go 官方运行时源码(src/runtime/malloc.go, mcache.go, mcentral.go, mheap.go),深度走读这套工业级内存分配器的运转核心。


一、Go 内存分配器的三级金字塔架构全景图

flowchart TD
    G[Goroutine 发起 new / make 内存分配] --> SizeCheck{对象大小分类}
    
    SizeCheck -->|微对象 < 16B 且无指针| TinyAlloc[mcache.tiny 微对象无锁合并分配]
    SizeCheck -->|小对象 16B ~ 32KB| SmallAlloc[查找 67 种特定 SpanClass]
    SizeCheck -->|大对象 > 32KB| LargeAlloc[直接穿透至 mheap 页分配器分配]
    
    SmallAlloc --> P_Local[1. 访问当前 P 绑定的 mcache (完全无锁, 纳秒级极速!)]
    P_Local -- mcache 中该规格 Span 空闲链表用尽 --> Central[2. 加锁访问全局 mcentral 批量补充 Span]
    Central -- mcentral 亦无空闲 Span --> Heap[3. 向全局 mheap 申请空闲物理内存页 (PageAlloc)]
    Heap -- mheap 物理内存不足 --> OS[4. 调用 mmap 向操作系统申请虚拟内存空间]

二、核心三大组件物理数据结构剖析

1. mcache(绑定到每个 P 的本地私有缓存)

打开 src/runtime/mcache.go:

  • 每个逻辑处理器 P 独占一个 mcache;
  • 内部维护了一个大小为 numSpanClasses = 136(67 种不同大小规格 $\times$ 2,包含有指针与无指针标记)的 alloc [numSpanClasses]*mspan 数组;
  • 核心物理收益:当前 P 上的所有 Goroutine 在申请小对象时,直接从本地 mcache 取出,完全不需要加任何互斥锁,单次分配仅耗费 2~5 个 CPU 周期!

2. mcentral(全局跨 P 的中枢分发池)

打开 src/runtime/mcentral.go:

  • 全局共有 136 个 mcentral 实例,每种规格对应一个;
  • 内部维护了两个 mspan 链表:
    • partial:包含尚有空闲对象可供分配的 Span 列表;
    • full:所有对象均已被占满的 Span 列表;
  • 当某个 P 的 mcache 消耗完毕时,会向对应规格的 mcentral 加锁请求补充一批 mspan。

3. mheap 与现代 PageAlloc(基数树页分配器)

打开 src/runtime/mheap.go:

  • 代表 Go 运行时的整个堆空间,管理着连续的 8KB 物理页(Page);
  • Go 1.14+ 引入了基于基数树(Radix Tree)的 PageAlloc 页分配器:使用高效的位图与多级基数树,将大内存块的空闲查找与合并(Buddy Allocation)复杂度从 $O(N)$ 压低至严格的 $O(\log N)$!

三、微对象分配器(Tiny Allocator)的内存极致压榨

对于小于 16 字节且不包含指针的极小变量(如 int8、小字符串头、空结构体):
Go 不会为每个小变量单独分配一个完整的内存块,而是采用 tiny 块合并机制:

  • mcache 中维护了一个 16 字节的 tiny 内存块游标;
  • 多个小对象(如两个 4 字节整数)会被紧凑地拼装塞进同一个 16 字节的物理槽位中;
  • 这一机制直接消除了大量小对象对堆内存造成的严重碎片化浪费!
// src/runtime/malloc.go 片段
// 微对象分配快速路径
if size <= maxSmallSize {
    if noscan && size < maxTinySize {
        // 进入 Tiny 内存合并无锁快速通道
        off := c.tinyoffset
        // 内存对齐计算并紧凑打包...
    }
}

四、对象大小与 67 种 SizeClass 分级映射表

Go 运行时将 0 到 32KB 的小对象精细划分为 67 个固定级别(Size Classes):

Class ID单对象大小 (Bytes)单个 mspan 包含的页数 (8KB/页)单个 mspan 容纳的对象总数最大内部碎片率
18 字节1 页 (8KB)1024 个对象0.00%
216 字节1 页512 个对象0.00%
324 字节1 页341 个对象0.44%
...............
6628,672 字节7 页 (56KB)2 个对象1.83%
6732,768 字节4 页 (32KB)1 个对象0.00%
  • 为什么做这种分级? 无论申请多大尺寸的对象,都会向上取整吸附到最近的 SizeClass 上,将整个堆内存的内部碎片率死死控制在 12.5% 以下!

五、生产级高性能开发启示

  1. 避免对象跨越 32KB 边界:32KB 以内的对象享受 mcache 本地无锁分配的极速性能;一旦超过 32KB(如 make([]byte, 32769)),必须直接向全局 mheap 加锁申请页,分配开销激增数十倍!
  2. 善用 sync.Pool 避免频繁穿透到 mcentral:虽然 mcache 很高效,但在高并发突发涌入时,本地 Span 耗尽仍需去 mcentral 加锁竞争。合理复用缓冲区对象可让内存分配始终保持在栈与本地 CPU 缓存中;
  3. 结构体属性按字节对齐排列:通过紧凑排列结构体字段,减少 Padding 填充,让对象体积落入更小的 SizeClass 规格内。

把 Go 内存分配器的三级分层与页分配器底层摸透,在高吞吐、极低延迟的架构设计中,你才能真正拥有把硬件性能压榨到极致的技术底气。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐