Read OSS

Go 内存管理:分配器层次结构与并发垃圾回收器

高级

前置知识

  • 第 1-4 篇:Repository 到 Scheduler
  • 垃圾回收理论(三色标记、写屏障、并发回收)
  • 虚拟内存与页级分配的基础知识

Go 内存管理:分配器层次结构与并发垃圾回收器

Go 的内存管理始终在两股力量之间寻求平衡:分配速度(每一次 makenew 和复合字面量)与回收效率(GC 必须在不产生长时间停顿的前提下回收内存)。解决方案是一套分层分配器——在常规路径上完全无锁——配合一个与程序并发运行的垃圾回收器。本文将深入剖析这两套系统。

分配器设计概览

分配器的设计理念在 malloc.go 开头的注释中有精彩的阐述:

src/runtime/malloc.go#L5-L76

整体层次结构受 Google tcmalloc 启发,但已有较大差异。小对象(≤32KB)从特定 size class 的缓存中分配,大对象则完全绕过缓存,直接交给页分配器处理。

graph TD
    subgraph "Per-P (lock-free)"
        MC["mcache<br/>~70 size classes<br/>+ tiny allocator"]
    end
    subgraph "Per-size-class (locked)"
        MCE["mcentral<br/>spans with free slots"]
    end
    subgraph "Global (locked)"
        MH["mheap<br/>page-level allocation"]
    end
    subgraph "Operating System"
        OS["mmap / VirtualAlloc"]
    end

    MC -->|"empty span"| MCE
    MCE -->|"no spans"| MH
    MH -->|"no pages"| OS
    OS -->|"≥1MB chunks"| MH
    MH -->|"span of pages"| MCE
    MCE -->|"span with free slots"| MC

小对象的分配流程如下:

  1. 将请求的大小向上取整到约 70 个 size class 之一
  2. 在当前 P 的 mcache 中找到对应的 mspan
  3. 扫描 mspan 的空闲位图,查找空闲槽位
  4. 若无空闲槽位,从 mcentral 获取新的 mspan
  5. 若 mcentral 无可用 span,从 mheap 申请页面
  6. 若 mheap 无可用页面,向操作系统申请(至少 1MB)

步骤 1-3 全程无锁——这条关键的快速路径使 Go 的分配性能在很多场景下能与栈分配相媲美。

mcache:Per-P 无锁分配

每个 P(正如我们在调度器篇中看到的)都拥有自己的 mcache:

src/runtime/mcache.go#L14-L59

type mcache struct {
    nextSample  int64
    scanAlloc   uintptr
    tiny        uintptr
    tinyoffset  uintptr
    tinyAllocs  uintptr
    alloc       [numSpanClasses]*mspan
    // ...
}

alloc 数组以 span class(size class 与 noscan/scan 的组合)为索引,每个条目指向一个带有空闲槽位的 mspan。分配时,runtime 找到对应的 span 并扫描其空闲位图——这只是一次简单的位运算,无需任何锁。

tiny allocator 是专为小于 16 字节且不含指针的对象设计的特殊优化。它将多次 tiny 分配打包进一个 16 字节的块,大幅降低了小字符串、单字节值等场景下的开销。

提示: 运行 go test -bench=. -benchmem 可以查看分配次数。每个 "alloc/op" 都代表一次经过分配器层次结构的调用。对于性能敏感的 Go 代码,零分配的热路径是最高追求。

mcentral 与 mheap:中央与全局分配

当某个 mcache 的特定 size class span 耗尽时,它会向 mcentral 请求新的 span:

src/runtime/mcentral.go#L22-L40

每个 mcentral 管理单一 size class 的 span,并维护两个集合,在每次 GC 周期中交替角色:已清扫 span 和未清扫 span。sweepgen 计数器(每个周期递增 2)决定哪个集合扮演哪个角色。这一设计意味着分配操作可能触发清扫——当你需要一个 span 时,可能需要先清扫一个 span 才能找到空闲对象。

当 mcentral 也无可用 span 时,mheap 负责分配页面:

src/runtime/mheap.go#L1-L20

堆以页为粒度(8KB/页)管理内存,使用 arena——在 64 位系统上每个 arena 为 64MB 的连续块。每个 arena 都关联着相应的元数据:用于指针扫描的堆位图,以及记录每个页面归属于哪个 span 的 span map。

graph LR
    subgraph "Arena (64MB)"
        P1["Page 0<br/>mspan A"]
        P2["Page 1<br/>mspan A"]
        P3["Page 2<br/>mspan B"]
        P4["Page 3<br/>free"]
        P5["..."]
    end
    subgraph "Metadata"
        HB["Heap Bitmap<br/>(pointer/scalar per word)"]
        SM["Span Map<br/>(page → mspan)"]
    end
    P1 --- HB
    P1 --- SM

malloc.go 中描述的虚拟内存布局正是这套 arena 系统的体现:地址空间被视为一系列 arena 帧,通过两级 arena map(mheap_.arenas)进行索引。这使得 Go 堆可以使用地址空间的任意部分,同时将元数据查找的时间复杂度保持在 O(1)。

GC 算法与四个阶段

垃圾回收器的算法在 mgc.go 中有详细说明:

src/runtime/mgc.go#L1-L83

这是一个并发标记清除回收器,使用混合写屏障——将 Dijkstra 的插入屏障与 Yuasa 的删除屏障结合在一起:

src/runtime/mbarrier.go#L24-L35

writePointer(slot, ptr):
    shade(*slot)          // Yuasa: shade old referent
    if current stack is grey:
        shade(ptr)        // Dijkstra: shade new referent
    *slot = ptr
stateDiagram-v2
    [*] --> SweepTerm: GC triggered
    SweepTerm --> Mark: STW - enable write barrier
    note right of SweepTerm
        Phase 1 - Stop the world,
        sweep remaining spans
    end note
    Mark --> MarkTerm: all grey objects drained
    note right of Mark
        Phase 2 - Concurrent marking
        with write barriers active
    end note
    MarkTerm --> Sweep: STW - disable write barrier
    note right of MarkTerm
        Phase 3 - Stop the world,
        flush mcaches
    end note
    Sweep --> [*]: all spans swept
    note right of Sweep
        Phase 4 - Concurrent sweeping
        in background and on allocation
    end note

阶段 1(清扫终止): 停止世界(STW)。清扫上一轮周期遗留的所有未清扫 span。

阶段 2(并发标记): 启动写屏障后恢复运行。标记 worker 扫描根对象(栈、全局变量、runtime 内部结构)并持续消耗灰色对象队列。此阶段与用户程序并发执行。

阶段 3(标记终止): 再次 STW。验证所有标记已完成,刷新各 P 的缓存。

阶段 4(并发清扫): 禁用写屏障后恢复运行。在后台以及分配时懒惰地清扫 span。新分配的对象为白色。

两次 STW 停顿(阶段 1 和阶段 3)对大多数程序而言通常在毫秒以下。真正的重头戏——扫描堆——在阶段 2 中与程序并发完成。

GC 调速器与调优

GC 调速器决定何时触发下一次回收周期,其实现位于 mgcpacer.go

src/runtime/mgcpacer.go#L16-L39

目标是将 GC 标记的 CPU 使用率控制在 25%(gcBackgroundUtilization = 0.25)。调速器会在堆增长到目标大小之前提前触发 GC,力求在堆恰好达到目标时完成标记。

控制 GC 行为有两个关键参数:

  • GOGC(默认值 100):堆增长比率。GOGC=100 表示当堆大小相比上次回收翻倍时触发 GC;GOGC=50 表示增长 50% 时触发;GOGC=off 则完全禁用 GC。
  • GOMEMLIMIT:内存软上限。设置后,GC 会更积极地将内存控制在该限制以下,即使这意味着比 GOGC 设定的频率更频繁地运行。

GC Assist 是防止高频分配的 goroutine 超过 GC 进度的机制。每个 goroutine 维护一个 gcAssistBytes 额度。当额度变为负数(说明该 goroutine 分配量超出了其应有的份额),它必须先完成一部分标记工作才能继续分配。这形成了自然的背压——分配越快,就需要承担越多的 GC 工作。

flowchart TD
    A["Goroutine calls malloc"] --> B{"gcAssistBytes > 0?"}
    B -->|Yes| C["Allocate, decrement credit"]
    B -->|No| D["Perform mark work<br/>(scan grey objects)"]
    D --> E["Earn assist credit"]
    E --> C
    C --> F["Return allocated memory"]

提示: 使用 GODEBUG=gctrace=1 可以查看每个 GC 周期的详细计时信息,包括堆大小、停顿时间和 CPU 使用率。在生产环境调优时,GOMEMLIMIT 往往比 GOGC 更实用,因为它可以直接对应容器的内存预算。

内存管理与调度器的关联

内存管理与调度系统紧密交织在一起。Per-P 的 mcache(来自调度器的 P 结构体)实现了无锁分配;GC 利用调度器的 STW 机制完成两次停顿阶段;GC 标记 worker 本身就是 goroutine,由我们在上一篇介绍的同一套 G-M-P 机制调度运行;GC Assist——goroutine 参与标记工作——也被集成进了分配快速路径之中。

在最后一篇文章中,我们将探索 Go 的并发原语与 I/O 基础设施:channel 如何以加锁的环形缓冲区实现、网络轮询器如何与调度器集成,以及仅限 runtime 使用的编译器指令如何将这些系统串联在一起。