Go 内存管理:分配器层次结构与并发垃圾回收器
前置知识
- ›第 1-4 篇:Repository 到 Scheduler
- ›垃圾回收理论(三色标记、写屏障、并发回收)
- ›虚拟内存与页级分配的基础知识
Go 内存管理:分配器层次结构与并发垃圾回收器
Go 的内存管理始终在两股力量之间寻求平衡:分配速度(每一次 make、new 和复合字面量)与回收效率(GC 必须在不产生长时间停顿的前提下回收内存)。解决方案是一套分层分配器——在常规路径上完全无锁——配合一个与程序并发运行的垃圾回收器。本文将深入剖析这两套系统。
分配器设计概览
分配器的设计理念在 malloc.go 开头的注释中有精彩的阐述:
整体层次结构受 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
小对象的分配流程如下:
- 将请求的大小向上取整到约 70 个 size class 之一
- 在当前 P 的 mcache 中找到对应的 mspan
- 扫描 mspan 的空闲位图,查找空闲槽位
- 若无空闲槽位,从 mcentral 获取新的 mspan
- 若 mcentral 无可用 span,从 mheap 申请页面
- 若 mheap 无可用页面,向操作系统申请(至少 1MB)
步骤 1-3 全程无锁——这条关键的快速路径使 Go 的分配性能在很多场景下能与栈分配相媲美。
mcache:Per-P 无锁分配
每个 P(正如我们在调度器篇中看到的)都拥有自己的 mcache:
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 负责分配页面:
堆以页为粒度(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 中有详细说明:
这是一个并发标记清除回收器,使用混合写屏障——将 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 使用的编译器指令如何将这些系统串联在一起。