Archipelago

Lec 7垃圾回收机制与写屏障

讲义完成更新于 2026-09-28

垃圾回收机制与写屏障

TL;DR

  • Go 采用并发、非分代、非压缩的三色标记清扫(Concurrent Tri-color Mark-Sweep)垃圾回收算法,以吞吐微损换取亚毫秒级低停顿(STW)。
  • 为保证用户并发修改指针时不发生存活对象误回收,运行时通过维护强/弱三色不变式来确保安全性。
  • Go 1.8 引入的混合写屏障(Hybrid Write Barrier)结合了插入屏障与删除屏障的优势,彻底消除了标记终止阶段对所有 Goroutine 栈的二次 STW 重扫。
  • Mark Assist(标记协助)机制形成了天然的负反馈闭环:内存分配过快的 Goroutine 必须替 GC 工作还债,防止堆内存在并发标记期无序失控暴涨。

垃圾回收器演进全景

Go 语言在垃圾回收技术上的演化史,本质上是一场将 STW 停顿时间从秒级降至亚毫秒级的极限优化史:

版本架构核心变革STW 停顿量级
Go 1.3传统的串行单线程 Mark-Sweep,全过程全局冻结数百毫秒至数秒
Go 1.5引入并发三色标记,GC 标记线程与用户程序并发执行~10ms(需 STW 重扫栈)
Go 1.8混合写屏障(Hybrid Write Barrier),消除重扫栈< 100µs(亚毫秒级)
Go 1.14引入信号异步抢占,解决计算密集循环卡死 GC 的问题停顿可预测性进一步提升
Go 1.19引入 GOMEMLIMIT 软内存限制,兼顾内存利用率与 OOM 防护资源受限容器环境下极度稳定

三色标记法与并发三色不变式

三色标记法是一种逻辑上的对象着色抽象,用于在遍历对象引用关系图时追踪存活性:

  • 白色对象:尚未被垃圾回收器访问的潜在垃圾。在标记阶段初期所有对象均为白色;标记完全结束时依然为白色的对象即被判定为垃圾并被清扫。
  • 灰色对象:已经被回收器发现并标记,但其直接引用的下游子对象尚未被完全遍历扫描。灰色对象构成了遍历推进的“活动工作波面”。
  • 黑色对象:自身已被遍历,且其直接引用的所有下游字段也已全部扫描完成。黑色对象被确定为存活对象,GC 绝不会再次递归扫描它。
[GC Roots: 全局变量 / 各协程栈 / 寄存器]
                  │
                  ▼
         +-----------------+
         | 灰色对象 (波面)  | ─── 扫描完成 ───> +-----------------+
         +-----------------+                    | 黑色对象 (存活)  |
                  │                             +-----------------+
                  ▼ (递归引用扫描)
         +-----------------+
         | 白色对象 (未触达) | ─── 最终未触达 ──> [垃圾回收清扫]
         +-----------------+

并发突变问题与三色不变式

在并发标记过程中,用户 Goroutine(称为 Mutator)与 GC Worker 同时运行。用户程序可能会在垃圾回收扫描进行到一半时修改指针引用关系:

  1. 黑色对象 AA 突然被赋予了对白色对象 CC 的直接引用(A→CA \to C)。
  2. 同时,用户程序删除了原本从某个灰色对象 BB 指向 CC 的唯一间接路径。
  3. 由于 AA 已经是黑色,GC 不会再次扫描它;而 BB 已经与 CC 断开连接。导致存活对象 CC 始终维持白色,最终在清扫阶段被灾难性地误当成垃圾回收!

为了在数学上绝对杜绝此类漏标,并发回收必须维护以下两个三色不变式之一:

  • 强三色不变式(Strong Tri-color Invariant):严禁任何黑色对象直接持有对白色对象的指针引用。
  • 弱三色不变式(Weak Tri-color Invariant):黑色对象可以指向白色对象,但该白色对象必须能够通过一条包含其他灰色对象的链路上溯可达(即有灰色波面为其兜底)。

写屏障机制的演化

写屏障(Write Barrier)是编译器在所有指针写入操作(如 *slot = ptr)处插入的一段微小的运行时监控代码。

Dijkstra 插入写屏障与 Yuasa 删除写屏障

  1. Dijkstra 插入屏障(Go 1.5–1.7):
    • 规则:当发生新指针写入时,将新引用的目标对象着色为灰色(shade(ptr))。
    • 维护目标:强三色不变式。
    • 致命缺陷:由于用户栈(Goroutine Stack)上的指针写入频率极其高昂,如果对栈内存也开启写屏障,性能开销不可承受。因此 Go 仅在堆上开启插入屏障,导致栈上依然可能存在黑色指白色的情况。为了保证正确性,在并发标记结束后必须触发较长时间的 STW 全局冻结,重新全量扫描所有 Goroutine 的栈。
  2. Yuasa 删除屏障:
    • 规则:当旧指针被覆盖或解除引用时,将即将被断开的旧对象强制标灰(shade(*slot))。
    • 维护目标:弱三色不变式。保护并发删除操作发生前的存活对象快照。

Go 1.8 混合写屏障(Hybrid Write Barrier)

Go 1.8 将插入屏障与删除屏障融合,提出了革命性的混合写屏障:

// 编译器在指针写操作 *slot = ptr 前插入的伪代码
func writePointer(slot *unsafe.Pointer, ptr unsafe.Pointer) {
    shade(*slot) // 1. 将被覆盖的旧对象标灰 (Yuasa 机制)
    if 当前栈处于未完全扫描或并发标记阶段 {
        shade(ptr) // 2. 将新赋予的对象标灰 (Dijkstra 机制)
    }
    *slot = ptr
}

我的理解

彻底消除栈重扫的核心精髓: 在混合写屏障规则下,GC 在启动初期短暂 STW 期间,将当前活跃的所有 Goroutine 的栈一次性全部标记为黑色。在后续并发执行期间,在栈上分配的所有新对象直接默认标记为黑色(存活),而在堆上发生的任何指针覆写操作均通过屏障将旧对象标灰。 由于栈永远保持黑色且堆操作有屏障兜底,在标记结束时根本不需要再次 STW 重新扫描栈!这一举措直接将 Go 程序的垃圾回收 STW 停顿时间从几十毫秒一举拉低至 100 微秒以内。

GC 阶段划分与两次微 STW

一轮完整的垃圾回收周期分为四个连续的阶段:

  1. Sweep Termination(清扫终止与 STW ①):
    • 触发微秒级 STW 停顿。
    • 确保上一轮遗留的并发清扫彻底完成。
    • 开启全局写屏障,统计 GC Roots,将所有 Goroutine 栈置为黑色。
  2. Concurrent Mark(并发标记):
    • 恢复所有用户 Goroutine 执行,GC Worker 并发运行,默认占用约 25% 的 CPU 计算资源(占用 GOMAXPROCS4\frac{\text{GOMAXPROCS}}{4} 个 P)。
    • 从灰色队列出发,并发向前推进行走三色标记波面。
  3. Mark Termination(标记终止与 STW ②):
    • 触发微秒级 STW 停顿。
    • 关闭全局写屏障,完成各处理器 P 的标记统计与汇总,准备清扫元数据。
  4. Concurrent Sweep(并发惰性清扫):
    • 恢复用户程序执行。
    • 后台守护协程 bgsweep 惰性回收所有白色对象,并在用户后续申请对应规格的 mspan 时由分配器按需并发清扫。

标记协助机制(Mark Assist)

在并发标记阶段,用户 Goroutine 仍在源源不断地分配堆内存。如果用户分配速率远远大于后台 GC Worker 的标记速率,堆内存将在标记尚未收尾前彻底失控溢出。

为了维系平衡,Go 运行时引入了 Mark Assist(标记协助) 负反馈控制算法:

  • 每个 Goroutine 在运行时都关联一个配额账户 gcAssistBytes。
  • 当 Goroutine 尝试向堆申请新内存时,如果其信用余额为负,调度器将强行拦截该 Goroutine,逼迫其协助 GC 扫描一定比例的堆对象作为偿还。
  • 分配越激进的业务线程,被迫参与垃圾回收扫描的时间就越长,从而自动压制了堆内存的无序增长,给后台 GC Worker 赢得了完成标记的缓冲时间。

Pacer 步调器与触发调优

垃圾回收的启动时机由运行时的 GC Pacer(步调调节器)动态调度:

  1. 堆内存翻倍触发(GOGC 机制): NextGC 阈值=上次存活堆内存×(1+GOGC100)\text{NextGC 阈值} = \text{上次存活堆内存} \times \left(1 + \frac{\text{GOGC}}{100}\right) 默认 GOGC=100,即当堆内存达到上一轮 GC 存活量的 2 倍 时自动点火。调大 GOGC 可降低 GC 频率换取更高吞吐,但会增加常驻内存;调小则更加激进地节省内存。
  2. 2 分钟兜底机制:由系统常驻线程 sysmon 监控,若系统空闲超过 2 分钟未发生回收,无条件强制唤醒一次 GC。
  3. 软内存限制 GOMEMLIMIT(Go 1.19+): 在容器化部署中,传统单纯依赖 GOGC 极易在流量突增时突破容器内存 Quota 触发内核 OOM Killer。GOMEMLIMIT 允许用户声明一个绝对内存软阈值(如设定为容器配额的 85%)。当内存逼近该阈值时,GC Pacer 会自动提升回收频率,极大地提高了生产环境的稳定性。

常见误区

为什么 Go 不采用 Java 式的分代垃圾回收(Generational GC)? 分代回收的核心假设是“绝大多数对象朝生夕灭”,但为了维持分代,必须在跨代指针修改时维护代价高昂的代际写屏障与记忆集(Remembered Set)。 Go 的前置编译器拥有极强的逃逸分析能力,绝大多数生命周期短暂的短命临时对象早已在栈上被直接分配,并随着函数调用结束无成本瞬间回收。因此分代模型在 Go 中的收益被大幅削弱,反而会破坏运行时极致的低停顿与实现简洁性。