第21章 GC
本章的公共语义基于 Go 1.26,内部实现快照基于 Go 1.26.4。Go 1.26 已默认启用 Green Tea GC;它在 Go 1.25 才是实验开关。GC 暂停和吞吐取决于堆形态、根集、CPU、分配速率和工具链,本书不承诺固定延迟。
Go 的 GC 可以概括为:
- 追踪式:从根出发遍历指针图,而不是用引用计数。
- 并发标记-清扫:标记和清扫的主体与业务 goroutine 并发。
- 非分代:当前不将 Go 堆分成新生代和老年代。
- 非移动:普通 Go 堆对象在生命周期内不因 GC 压缩而搬迁。
- 有写屏障和分配辅助:业务代码在并发标记期间修改指针时维持可达性,分配过快时需要帮助标记。
三色模型
三色是理解并发追踪的抽象,不是每个对象里真有一个颜色字段:
| 颜色 | 抽象含义 |
|---|---|
| 白 | 本轮尚未发现,暂时是回收候选 |
| 灰 | 已发现,但它指向的对象尚未全部扫描 |
| 黑 | 已发现且它的指针字已扫描 |
基本流程:
- 把根集指向的堆对象标记并加入待扫描工作。
- 取出一个灰对象,扫描其指针字,标记新发现的对象。
- 当根工作和灰色工作都耗尽时,标记结束。
- 仍未标记的已分配 slot 不可达,sweep 使它们可再利用。
根主要包括:
- goroutine 活跃栈帧和必要的寄存器上下文;
- 包全局变量(
.data/.bss); - Runtime 管理的堆外指针、finalizer/cleanup 等特殊记录。
标记位与工作队列
mspan 持有 slot 分配位和 GC 所需的标记元数据。传统工作队列是 gcWork 的两个本地 workbuf;缓冲交换把对全局工作池的竞争摊薄到一批对象上。
Go 1.26 默认的 Green Tea GC 又为小对象增加了 span queue 和按 span 组织的 mark/scan bits。当一个 span 中有对象待扫描时,工作器可以批量处理同一 span,改善局部性和多核扩展。当前 gcWork 同时包含:
// runtime/mgcwork.go,概念快照
type gcWork struct {
wbuf1, wbuf2 *workbuf // 对象指针工作
spanq spanQueue
ptrBuf *pointerBuffer
bytesMarked uint64
heapScanWork int64
}
Go 1.26 Release Notes 表示,在 GC 较重的真实程序中,新 GC 预期可减少约 10%~40% 的 GC 开销,部分新 amd64 CPU 还可用向量指令扫描小对象。这是发布说明的预期范围,不是每个程序的保证;分配少、根集主导或内存带宽受限的程序可能不同。
Go 1.26 还允许在构建时用 GOEXPERIMENT=nogreenteagc 临时回退,官方计划在 Go 1.27 移除这个 opt-out。它只适合定位工具链回归,不应成为长期运行配置。
noscan 为什么重要
不含 Go 指针的对象位于 noscan span。GC 需要记录其存活性,但不需要遍历对象内容寻找子指针。因此,同样的存活字节数下,指针密集的图结构和大块 []byte 的标记成本可完全不同。可用以下指标区分:
/gc/scan/heap:bytes
/gc/scan/stack:bytes
/gc/scan/globals:bytes
混合写屏障
并发标记时,业务 goroutine 仍在修改指针图。如果它把某个白对象从未扫描区域移到已扫描对象中,GC 可能漏掉这个仍存活的对象。写屏障让这类指针写入可见。
Go 自 1.8 起使用 Yuasa 删除屏障与 Dijkstra 插入屏障组合的混合屏障。Go 1.26.4 runtime/mbarrier.go 给出的概念伪代码是:
// 概念伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
shade(*slot) // Yuasa:保护被覆盖的旧引用
if currentStackIsGrey() {
shade(ptr) // Dijkstra:当前栈尚未扫描时保护新引用
}
*slot = ptr
}
新值的 shade 是有条件的:当前 goroutine 栈一旦已扫描成黑,栈上不会隐藏未标记对象,删除侧屏障可以防止它之后把其他对象隐藏到栈上。因此不能把当前实现简化为“每次永远无条件 shade 新旧两个对象”。
实际代码还有几个重要细节:
- 屏障在发布新指针之前执行。
- 指向当前栈帧的可证明写入通常不需屏障;若编译器无法证明目标不是堆,会保守地生成屏障。
- 全局变量指针写入需要屏障,从而避免在标记终止时重扫全局区。
- slice/map/channel 和反射的批量复制使用 bulk barrier,不是简单对每个字调用一次 Go 函数。
- 新分配对象在并发标记期间直接视为黑色,避免刚分配就丢失。
屏障实现属于编译器与 Runtime 的内部协议。不要给“一次指针写”附会固定纳秒成本;开销受 CPU、存储层次、写入密度和 GC 是否处于 mark 阶段影响。
GC 周期
runtime/mgc.go 对当前周期的描述可归纳为四个主阶段:
1. Sweep Termination 与 Mark 准备(STW)
Runtime 停止世界,结束仍未完成的上轮 sweep,清理 pool,设置 _GCmark,在所有 P 上启用写屏障与 mutator assist,并排队 root mark jobs。在所有 P 都观察到屏障开启之前,不能开始扫描。
注意:这个起始 STW 主要是建立一致的标记阶段,不是在暂停中一次性扫完所有 goroutine 栈。
2. Concurrent Mark
恢复世界后,调度器的后台 mark worker 和分配路径上的 assist 共同执行:
- 扫描全局变量、Runtime 根与 goroutine 栈;
- 消费对象 workbuf 和 Green Tea span queue;
- 更新标记位、扫描量和 pacer 账务。
扫描某个 goroutine 栈时,Runtime 会先把该 goroutine 停在可扫描状态,在 system stack 上扫描其活跃帧,然后允许它继续运行。这些根工作属于并发 mark,不意味着所有栈在同一段全局 STW 中扫描。
Mark Assist 把标记负债按分配字节计入 goroutine。当分配速率超过后台标记进度时,mallocgc 会先执行一部分 GC 工作再允许继续分配。所以 GC 压力可能表现为业务路径的 CPU 与尾延迟,而不只是 STW。
3. Mark Termination(STW)
当分布式终止检测确认没有剩余 root job 和灰色工作时,Runtime 再次停止世界:
- 进入
_GCmarktermination; - 禁用 mark worker 和 assist;
- 刷新 P-local 状态和
mcache; - 统计存活堆与扫描工作,更新下轮 pacer 目标;
- 转回
_GCoff,禁用写屏障并准备 sweep。
混合写屏障避免了在这个阶段重扫所有栈和全局区,但暂停时间仍会受 P 停顿、程序规模和 Runtime housekeeping 影响,没有“一定亚毫秒”的承诺。
4. Concurrent Sweep
恢复世界后,span 按下一个 sweep generation 标记为待清扫。后台 sweeper 和需要取 span 的分配者会清扫:
- 比较分配状态与本轮标记状态;
- 回收未标记 slot;
- 把有空位的 span 归还
mcentral; - 必要时把全空 span 归还页分配器,之后由 scavenger 考虑向 OS 归还物理页。
STW 应该怎么看
每轮 GC 有两个主要全局暂停窗口:起始的 sweep termination/mark setup 与结尾的 mark termination。暂停总时间又包含两部分:
- 请求停止世界后,等待所有 P 到达安全状态的 stopping latency。
- 世界已停止后执行 GC 转换工作的时间。
Go 1.26 可直接观察两类分布:
/sched/pauses/stopping/gc:seconds
/sched/pauses/total/gc:seconds
/gc/pauses:seconds 已废弃,虽然当前与 total GC pause 相同,新代码应使用 /sched/pauses/total/gc:seconds。
工程上不要只看平均 STW:
- 同时查 P99/P999 pause histogram、mark assist CPU 和请求尾延迟。
- 大量 goroutine 主要增加栈根扫描、调度与栈内存成本;不应简化成“它们全都在起始 STW 被扫描”。
- 异步抢占降低了紧循环阻塞停世界的风险,但 Runtime 和 cgo 的特殊不可抢占区域仍需要用 trace 定位。
runtime.LockOSThread本身不是 GC 暂停过长的证据。
Pacer、GOGC 与 Mark Assist
GOGC 控制堆相对上轮存活数据与 GC 根的增长目标。官方 GC Guide 给出的概念模型是:
target = live heap + (live heap + GC roots) * GOGC / 100
GOGC=100 表示增长预算大致是“存活堆 + 根集”的 100%,不是在所有程序中简单保证“进程内存翻倍后触发”。实际 pacer 还会考虑预测扫描量、当前分配速率、触发余量和内存限制。
- 提高
GOGC:通常用更多峰值堆换更少 GC CPU。 - 降低
GOGC:通常用更多 GC CPU 换更小堆。 GOGC=off禁用基于增长的自动触发,但显式runtime.GC()与GOMEMLIMIT仍可促使 GC 工作。
Pacer 要让并发标记在堆超过 goal 前完成。后台 worker 跟不上时,assist 以“标记工作/分配字节”的比例限制分配者。观察:
/cpu/classes/gc/mark/assist:cpu-seconds
/cpu/classes/gc/mark/dedicated:cpu-seconds
/cpu/classes/gc/mark/idle:cpu-seconds
/cpu/classes/gc/total:cpu-seconds
/gc/heap/goal:bytes
/gc/heap/live:bytes
assist 高通常意味着分配速率大、可扫描堆大或 CPU 余量不足。它不能单靠“把 STW 压低”解决。
GOMEMLIMIT
GOMEMLIMIT 和 runtime/debug.SetMemoryLimit 提供 Runtime 管理内存的软限制。当增长式 GOGC 目标超出可用预算时,pacer 会提前 GC 并更积极地 scavenging。
Runtime 尝试维持的量为:
runtime.MemStats.Sys - runtime.MemStats.HeapReleased
# 等价 runtime/metrics 表达
/memory/classes/total:bytes - /memory/classes/heap/released:bytes
不包括:
- Go 二进制本身的代码与只读映射;
- C 代码分配的内存;
- 应用自行用
syscall.Mmap管理的内存; - 内核为进程持有的部分资源。
因此它不是 RSS 硬上限,不能替代 cgroup 限制,也不保证永不 OOM。若限制低于 Runtime 已需内存,GC 可能频繁运行;Runtime 会在避免 GC thrashing 与尽量尊重限制之间取舍,应用仍可超出该软目标以保持进展。
配置原则:
- 从容器/主机硬上限减去经过测量的非 Go 内存、线程栈、突发余量与观测误差。
- 先保留默认
GOGC,观察 live heap、goal、assist CPU、RSS 和尾延迟。 - 只在容量测试证明后再调整,不套用“内存上限的固定 90%”之类通用比例。
Cleanup、Finalizer 与 KeepAlive
Go 1.24 引入 runtime.AddCleanup,用于给 Go 对象包装的外部资源增加遗漏保护:
type Handle struct {
fd int
}
func newHandle(fd int) *Handle {
h := &Handle{fd: fd}
runtime.AddCleanup(h, func(fd int) {
_ = syscall.Close(fd)
}, fd)
return h
}
必须同时记住:
- Cleanup 不是析构函数,不保证在进程退出前运行。文件、锁、事务、缓冲刷新等必须依赖显式
Close/Unlock/Commit/Flush。 - Cleanup 可与业务 goroutine 及其他 Cleanup 并发,没有顺序保证。
cleanup或arg若反向使ptr保持可达,Cleanup 永远不会运行;arg == ptr的简单情况会 panic。Cleanup.Stop()可撤销未运行的 cleanup,但 Stop 与已开始的回调之间仍要按 API 契约处理并发。- 对微小无指针对象,Runtime 可批量共享一个分配 slot;同 slot 内其他对象仍可达时,cleanup 可能不运行。
- 对象可在函数最后一次源码使用之后立即变为不可达。底层系统调用仍需要资源存活时,在最后一次外部使用后调用
runtime.KeepAlive(obj)。
runtime.SetFinalizer 仍存在,但新代码应优先考虑更不易出错的 AddCleanup。Finalizer 会让对象在首次不可达后重新可达以运行回调,还有依赖顺序和环的限制,不应作为常规资源管理机制。
观测与调优
第一步:证明是 GC 问题
GODEBUG=gctrace=1 ./service
go test -bench=. -benchmem ./...
go tool pprof -alloc_space ./cpu-or-heap-profile
go tool trace trace.out
gctrace 的三段 clock 时间分别对应起始 STW、并发 mark 与 scan 窗口、结尾 STW;sweep 在后续阶段并发推进,不是中间数字的名称。堆的 A->B->C 表示 GC 开始时堆、GC 结束时堆和本轮存活堆。具体格式会演进,应对照目标 Go 版本的 gctrace 文档。
稳定取样的常用指标:
/gc/cycles/automatic:gc-cycles
/gc/cycles/forced:gc-cycles
/gc/heap/live:bytes
/gc/heap/goal:bytes
/gc/scan/heap:bytes
/gc/scan/stack:bytes
/cpu/classes/gc/total:cpu-seconds
/cpu/classes/gc/mark/assist:cpu-seconds
/sched/pauses/total/gc:seconds
runtime/metrics 的指标集是可演进的实现接口。应用应从 metrics.All() 发现指标并校验 ValueKind,不要假设所有 Go 实现和版本拥有同一 schema。
第二步:减少真实分配与扫描量
- 从 alloc profile 的累计字节和对象数开始,不要只看单次分配大小。
- 已知规模时预分配 slice/map,用流式 I/O 避免全量中间 buffer。
- 用
strings.Builder、bytes.Buffer、strconv.Append*减少中间字符串,但检查容量滞留。 sync.Pool只用于可丢失的临时对象,并为超大实例设置丢弃上限。- 接口转换、闭包和指针返回是否导致堆分配,用
-gcflags=-m=2验证,不用“必然装箱逃逸”的经验口号。 - 指针密集对象即使总字节不大,也可能让
/gc/scan/heap:bytes很高。布局优化必须同时测试 cache locality 和可读性代价。
第三步:用负载测试调参
没有通用的 GOGC=50、GOGC=800 或“内存上限 90%”配方。至少要比较:
| 维度 | 需要记录 |
|---|---|
| 业务 | 吞吐、P50/P99/P999、错误率 |
| GC | cycle 速率、assist CPU、总 GC CPU、pause histogram |
| 内存 | live heap、heap goal、Runtime 管理内存、RSS/cgroup working set |
| 环境 | Go 补丁版、GOEXPERIMENT、GOMAXPROCS、CPU 配额、请求模型 |
先选择能在峰值负载下留有内存余量的 GOMEMLIMIT,再比较不同 GOGC 对 CPU 和尾延迟的影响。若 live heap 本身已接近限制,调 GOGC 无法创造内存;应先降低常驻集或提高真实预算。
常见误区
| 误区 | 正确理解 |
|---|---|
| “Go GC 永远亚毫秒” | 没有固定保证;看自己的 pause histogram 和尾延迟 |
| “所有栈都在 GC 起始 STW 扫描” | root jobs 在并发 mark 中逐个停止并扫描 goroutine |
| “写屏障永远 shade 新旧值” | 删除侧无条件,插入侧在当前栈尚灰时需要 |
“手动 runtime.GC() 能修复泄漏” | 仍可达的对象不会被回收;用 heap profile 找持有链 |
“GOMEMLIMIT 是 RSS 硬限制” | 它只是 Runtime 管理内存的软目标 |
| “值传递永远比指针快” | 看拷贝、逃逸、cache、写屏障和 API 语义的综合 benchmark |
“runtime.Pinner 能减少 GC 扫描” | Pinner 用于满足 cgo 指针保留规则,不是通用 GC 优化器 |
| “Green Tea 在 Go 1.26 仍是 opt-in” | Go 1.26 已默认启用;nogreenteagc 只是临时回退 |
源码阅读路线
src/runtime/mgc.go:先读文件头的 GC 周期说明,再看gcStart和gcMarkDone。src/runtime/mgcpacer.go:heap goal、trigger、assist 与内存限制。src/runtime/mgcmark.go:root jobs、scanstack、mark worker 与终止检测。src/runtime/mgcwork.go:gcWork、workbuf 和 Green Tea span queue。src/runtime/mgcmark_greenteagc.go:Go 1.26 默认小对象 span 扫描路径。src/runtime/mbarrier.go与mwbbuf.go:混合写屏障证明、批量屏障与缓冲。src/runtime/mgcsweep.go与mgcscavenge.go:sweep 和物理页归还。
每次都固定到具体 Go tag。Green Tea 在 1.25 与 1.26 的默认状态已不同,直接阅读 master 或旧文章很容易混淆。
本章小结
- Go 1.26 使用非分代、非移动、并发标记-清扫 GC,Green Tea 已默认启用。
- 三色是可达性抽象;当前工作系统同时使用对象 workbuf 和 Green Tea span queue。
- 混合写屏障保护旧引用,并在当前栈尚灰时保护新引用,从而无需在 mark termination 重扫所有栈。
- 一轮 GC 的主干是起始 STW、并发 mark、结尾 STW 和并发 sweep;栈 root jobs 在并发 mark 中执行。
GOGC调整 CPU/堆增长取舍,GOMEMLIMIT约束 Runtime 管理内存的软目标,二者都不是 RSS 保证。- 调优顺序是先量化分配、存活堆、扫描量、assist 和 pause,再减分配与指针密度,最后用代表性负载调整
GOGC/GOMEMLIMIT。