第二章 Go 语言混合写屏障的 GC 全场景分析
垃圾回收(Garbage Collection,GC)是编程语言中提供的自动内存管理机制,GC 能够自动释放不需要的内存对象,让出存储器资源,其释放过程中无须程序员手动执行。
GC 机制在现代很多编程语言得到支持,针对 GC 性能的优劣程度,也是不同语言之间的对比指标之一。
Go 语言在 GC 的演进过程中也经历了几次重大改变,如下:
- Go V1.3之前的STW的标记和清除(SWT Mark Sweep)。
- Go V1.5的三色并发标记法。“强-弱”三色不变式、插入屏障、删除屏障。
- Go V1.8混合写屏障机制。
Go V1.3 标记-清除算法
此算法主要有两个步骤:
- 标记(Mark Phase)。
- 清除(Sweep Phase)。
找到需要被清除的内存数据,然后一次性清除。
标记清除(Mark and Sweep)算法的详细过程
- 暂停程序业务逻辑,对可达和不可达的对象进行分类,然后做上标记。
- 开始标记。程序找出它所有可达的对象,并做上标记。
- 标记完了之后,开始清除未标记的对象。
Mark and Sweep 算法在执行的时候,需要程序暂停,即 STW(Stop The World)。在 STW 的过程中,CPU不执行用户代码,全部用于垃圾回收。
这个过程影响很大,所以 STW 也是一些回收机制最大的难题和希望优化的点。 - 停止暂停,让程序继续运行,然后重复这个过程,直到 Process 程序生命周期结束。
标记清除算法的缺点
标记清除算法简单明了,但是也有非常严重的问题:
- STW 让程序暂停,所以程序会出现卡顿。
- 标记需要扫描整个 Heap。
- 清除数据会产生 Heap 碎片。
Go V1.3 版本执行 GC 的基本流程就是首先启动 STW,使程序暂停,然后执行标记,再执行数据回收,最后停止STW。
全部的 GC 时间都是包裹在 STW 范围之内的,Go V1.3 做了简单的优化,将停止 STW 的步骤提前,缩小 STW 暂停的时间范围。
将 Sweep 清除的步骤放到停止 STW 之后执行,因为这些对象已经是不可达对象了,不会出现回收写冲突等问题。
但是无论怎么优化,这个算法都会暂停整个程序。
Go V1.5 三色标记法
三色标记法 GC 过程和其他用户 Goroutine 可并发运行,但根对象扫描仍然依赖 STW,这是该版本 GC 停顿的主要来源。
对象的初始颜色状态
在一次 GC 周期开始时:
堆中所有存活对象在逻辑上均视为“白色”
白色表示:当前 GC 轮次中尚未被发现为可达对象
需要注意的是:这里的“程序”并不是一个抽象整体,而是由一组 GC Root 组成,包括:
- 所有 goroutine 的栈
- 全局变量
- runtime 内部引用的对象
从 GC 的视角看,“程序是否可达”本质上等价于“是否能从 Root 出发访问到”。
GC 开始:STW 根扫描阶段
Stop-The-World(第一次)
在 Go 1.5 中,三色标记开始前必须先进入 STW,原因是:
- goroutine 栈 不能并发扫描
- 必须冻结世界,保证 root 集合稳定
Root 扫描与初始标记
在 STW 状态下,GC 执行以下操作:
扫描所有 GC Root
将从 Root 直接可达的对象:
- 从 白色标记为灰色
- 放入 灰色标记队列(mark queue)
这一步是:
- 一次性扫描
- 非递归
- 仅处理 Root 的第一层可达对象
例如:Root 直接引用对象 1 和对象 4 ,则对象 1、对象 4 被标记为灰色
启用写屏障并恢复世界
Root 扫描完成后:
- 启用 写屏障(Write Barrier)
- 恢复用户 goroutine 执行
GC 进入并发标记阶段
这一阶段的 STW 时间与:goroutine 数量、栈大小 强相关
并发三色标记阶段(Concurrent Mark)
灰色对象扫描规则
GC worker 与用户 goroutine 并发运行,不断执行以下过程:
- 从灰色标记队列中取出一个灰色对象
- 扫描该对象的所有指针字段
- 对每一个被引用的对象:
- 若为白色 → 标记为灰色,并加入灰色队列
- 当前灰色对象扫描完成后:
- 标记为黑色
- 从灰色集合移动到黑色集合
例如:
- 对象 1、对象 4 初始为灰色
- 扫描对象 1,发现对象 2、对象 7
- 对象 2、对象 7 由白色 → 灰色
- 对象 1、对象 4 扫描完成后 → 黑色
重复扫描直到灰色集合为空
上述过程不断重复:
- 灰色对象 → 黑色
- 白色对象 → 灰色
直到:
- 灰色标记队列为空
- 所有可达对象均已被访问
此时,内存中的对象只剩下两种颜色:
- 黑色:从 Root 可达的存活对象
- 白色:从 Root 不可达的对象(垃圾)
并发正确性的保证:Go 1.5 的写屏障
在并发标记期间,用户程序仍可能修改对象引用关系,例如:
- 黑对象新增指向白对象的引用
- 可能导致白对象被错误回收
为解决该问题,Go 1.5 引入 写屏障机制:
在指针写操作时:
- 若新写入的对象尚未被标记
- 则将其标记为灰色
写屏障的作用是:
防止在并发标记过程中遗漏任何逻辑上可达的对象
代价是:
- 每一次指针写入都会增加额外开销
- 写密集型程序在 Go 1.5 中 GC 成本明显偏高
标记结束确认:Mark Termination(STW)
当并发标记完成后,会进行下面的操作。
Stop-The-World(第二次)
Go 1.5 会再次进入 STW,用于:
- 确认灰色队列为空
- 确保没有遗漏的标记工作
- 关闭写屏障
这一次 STW 相对较短,但仍然存在。
清除阶段(Sweep)
并发清除白色对象
GC 进入清除阶段:
- 遍历堆中的内存 span
- 回收所有 白色对象
- 黑色对象继续存活
- 回收的内存重新进入分配池
清除阶段是 并发执行的,不再 STW。
Go V1.5 的屏障机制
并发标记下的问题本质:三色不变式
在 并发三色标记阶段,用户程序(mutator)仍在运行,可能发生:
- 黑对象新增指向白对象的引用
- 灰对象到白对象的可达路径被删除
如果不加限制,会直接导致:仍被程序使用的对象,被错误回收
为此,Go 1.5 通过 三色不变式 和 写屏障 来约束并发行为。
并发标记下的问题本质:三色不变式
强三色不变式
- 不允许黑色对象直接引用白色对象
- 一旦发生引用:白色对象必须立即转为灰色
特征:
- 安全性强
- 写屏障成本高
- 标记精度高
弱三色不变式
- 允许黑色对象引用白色对象
- 但要求:该白色对象仍然被某个灰色对象间接保护
特征:
- 屏障成本低
- 允许对象“多活一轮”
- 标记精度较低
插入屏障(Insertion Barrier)
- 满足 强三色不变式
- 防止黑对象新增指向白对象的引用
当执行指针写入操作:
1 | A.field = B |
若:A 为黑色, B 为白色。则将 B 标记为灰色
插入屏障 仅用于堆对象 不作用于栈对象
原因:栈写频繁,屏障成本不可接受
栈上新增的白色对象引用,无法被插入屏障捕获
因此,在 Go 1.5 中:
并发标记结束后,必须对栈进行一次 STW 重新扫描,以防止栈中仍存在“黑 → 白”的未保护引用
这正是 Go 1.5 GC 停顿的主要来源之一。
删除屏障(Deletion Barrier)
- 满足 弱三色不变式
- 防止“可达路径被并发删除”
当一个对象引用被删除或覆盖时:
1 | old = A.field |
若 old 为白色或灰色:将 old 标记为灰色
保证:即使引用被删除或覆盖,原可达对象仍能在本轮 GC 中被扫描
被删除但已标灰的对象:可能在本轮 GC 中存活,只能在下一轮 GC 中回收
删除屏障 牺牲回收精度,换取并发安全性
Go V1.8 的混合写屏障
在 Go 1.5 中:
- 插入屏障 保证强三色不变式,结束阶段必须 STW 重新扫描栈
- 删除屏障 无需栈 Re-scan ,GC 回收精度低(快照语义)
Go 1.8 引入了混合写屏障机制(Hybrid Write Barrier),避免了对栈 Re-scan(重新扫 描)的过程,这也极大地减少了 STW 的时间,同时结合了插入写屏障和删除写屏障两者的优点
混合写屏障(Hybrid Write Barrier)规则
把“栈”在 GC 语义上直接视为“永远安全的黑色区域”,通过“堆上的写屏障 + 初始栈全黑”,同时覆盖插入与删除两种风险。
- GC 开始时,一次性 STW 扫描栈。所有栈可达对象全部标记为黑色,栈对象在整个 GC 期间都被视为安全,不会对栈进行第二次扫描。
- GC 期间,栈上新创建的对象直接为黑色。新对象如果分配在栈上,直接视为黑色。栈对象永远不会成为白色
- 堆上“删除引用” → 被删除对象标记为灰色。
- 堆上“新增引用” → 被新增对象标记为灰色。
混合写屏障实际上满足的是一种变形的弱三色不变式。
写屏障只作用于堆的写操作,栈写入永远不出发屏障。
一些理解上的问题
Go 的 GC 回收哪些内存?栈和堆分别由谁管理?
- GC 只回收堆内存
- 栈内存不归 GC 管理
| 内存类型 | 管理者 | 回收方式 |
|---|---|---|
| 栈 | 编译器 + runtime | 函数返回直接回收 |
| 堆 | GC | 标记-清除 |
栈帧随着函数调用/返回自动创建和销毁,回收成本极低,不适合也不需要 GC 介入。
为什么栈对象“不会被 GC 回收”,却仍然参与 GC?
- 栈对象本身不回收
- 栈上的指针是 GC 的根(Root Set)
- 堆对象会被栈或全局变量间接引用
GC 需要判断 堆对象 是否还能被 栈或全局变量间接引用,以便确定堆对象是否可以被回收。
混合写屏障带来了什么,代价是什么?
混合写屏障通过 对堆做插入屏障和删除屏障,对栈特殊处理不做屏障。
好处:
- 不会误删仍可达的堆对象
- 不需要 STW 栈 re-scan,极大缩短 STW 时间
- 栈完全无屏障,极大降低写路径成本
缺点:
- 回收精度降低(延迟回收):不保证本轮 GC 回收所有垃圾,某些对象可能延迟一轮回收
- 内存峰值略有抬高:因为保护对象增多,延迟回收
- 不是严格实时的 GC
一些源码
参考 Go 版本为 1.25.5
整体流程
- gcStart() 是 GC 的入口
- 防止 GC 在“不安全的执行上下文”(
gp == mp.g0 || mp.locks > 1 || mp.preemptoff != "")中启动gp == mp.g0当前在 system stackmp.locks > 1持有 runtime 锁mp.preemptoff != ""明确禁止抢占
- 补扫 sweep,
sweepone(),扫描并释放上一轮 GC 未 sweep 完的 span ,保证 heap 状态在 GC 开始前“干净” - 获取“GC 启动权”,防止多个 goroutine 同时启动 GC
- STW
stw = stopTheWorldWithSema(stwGCSweepTerm)停止所有 P ,停止所有 G ,建立 STW 边界casGToWaitingForSuspendG(getg().m.curg, _Grunning, waitReasonStoppingTheWorld)把“当前发起 STW 的 goroutine”标记为 _Gwaiting。允许 GC mark worker 扫描它的栈,tracer 观察它的状态,GC 和 调度器 的交叉点,防止死锁。sched.stopwait = gomaxprocs;sched.gcwaiting.Store(true);preemptall()stopwait=GOMAXPROCS,设置全局 GC 停顿标志,向所有的 P 发起抢占。for sched.stopwait > 0 { ...等待所有 P 停止,周期性抢占(preempt),防止遗漏- STW 成功后的校验:统计每个 P 的停顿 CPU 时间,为 GC pause time 记账
- STW 最终状态:
worldStopped();casgstatus(getg().m.curg, _Gwaiting, _Grunning)
finishsweep_m()确保所有 span 都处于可分配状态,为 mark 阶段准备 heap。语义上确保上一轮 GC 的 sweep 在 mark 开始前彻底完成clearpools()清空 sync.Pool,防止池中对象延迟一个 GC 周期回收
- GC 全局状态初始化
gcController.startCycle(now, int(gomaxprocs), trigger)控制 GC 频率,计算 assist 比例,决定 mutator 要“干多少 GC 活”gcCPULimiter.startGCTransition(true, now)控制 GC 占用 CPU 的上限,防止 GC 抢光 mutator CPU
- 进入 mark 阶段
setGCPhase(_GCmark)切换 GC phase(全局语义切换),写屏障将开始生效,三色标记语义正式成立_GCoff:本轮 GC 已完成标记,允许 sweep,关闭写屏障_GCmark:三色标记生效,写屏障开启,新分配对象直接当作黑色,灰色队列开始 drain_GCmarktermination:写屏障仍然开启,所有 P 必须协助 GC,即将切换到GCoff。这是为了安全地过渡
gcBgMarkPrepare()构造一个“永远不会因为 worker 不够而提前结束 mark 的判定条件”,它并不启动任何 goroutine,真正启动 mark worker 的地方是 STW 之前的gcBgMarkStartWorkers()。gcPrepareMarkRoots()准备 root,标记:goroutine 栈、全局变量、runtime roots。为后续 gcMarkRoot 做准备,“栈只扫描一次”的入口就在这之后
把“本轮 GC 需要扫描的所有 root”,拆分成可并发调度的 root jobs- Global Roots(data 已初始化的全局变量 / bss 零值全局变量):按 block 切分,避免一个巨大 module 的 root 扫描被一个 worker 独占
- Span Roots(finalizer specials):带 specials 的 span 主要是:finalizer、weak references(未来)
- Stack Roots:栈的 root
- Fixed Roots(runtime 内部):不属于 data/bss、不在任何 goroutine 栈上、但 runtime 必须长期持有、且可能指向堆对象的全局结构(调度器相关结构、所有 goroutine 管理结构、空闲 G / stack 复用池、定时器系统(timer heap)、同步原语等待队列(sudog)等等)。
- 生命周期 ≈ 程序生命周期
- 不在用户可见的全局变量中
- 只能由 runtime 主动扫描
gcMarkTinyAllocs()tiny alloc 特殊处理,tinyalloc block 直接置黑,避免分配路径插入额外屏障atomic.Store(&gcBlackenEnabled, 1)分配会“强迫 mutator 帮 GC 干活”
- 恢复世界,进入并发标记
now = startTheWorldWithSema(0, stw)mutator 恢复运行,写屏障开启,并发 mark worker 开始 drain 灰色队列。GC 的“并发标记阶段”正式开始assertWorldStopped();mp := acquirem()恢复世界前的准备,保证世界确实是停的、当前 P 不会被抢占,防止 P 错乱list, delta := netpoll(0);injectglist(&list);netpollAdjustWaiters(delta)处理 STW 期间积累的外部事件,是 I/O 与 STW 的边界处理p1 := procresize(procs);sched.gcwaiting.Store(false)重新建立 P 的规模与归属关系,处理 GOMAXPROCS 变化、清除 gcwaiting 、唤醒 sysmon(如果需要)- 把 P 重新交还给 M(或创建新 M),是调度恢复的核心
sched.stwTotalTimeGC.record(...);trace.STWDone()记录 STW 总耗时和 tracewakep()唤醒调度系统,防止 runnable G 堆积 、P 全部 idle
可以发现,这里的 gcStart 流程只是为 GC 进行环境的准备,保证上一次 GC 完全结束,并且拿到所有的 root。
真正的标记阶段是 gcStart 启动的异步的,在 STW 之后才开始的。
并且这里没有真正的对白色对象进行清理。Go GC 的“清理阶段(Sweep)”不在 gcStart 里作为一个完整阶段出现,是因为:Sweep 是「跨 GC 周期、并发 + 增量」执行的,而不是一个集中、封闭的阶段。
gcStart 中会有一部分清理(sweepone 和 finishsweep_m),但真正的大部分清理工作是在 GC mark 结束之后,下一轮 GC 开始之前,且与 mutator 并发执行的。
真正清理阶段:
- Sweep 的入口:
gcMarkTermination() - Sweep 的执行:
sweepone() / sweepLocked(),Sweep 不是一个统一的 loop,而是被“顺便”触发的。- 分配内存
- 后台 sweep goroutine
- 下一轮 GC 启动前
- heap 不足
- 后台 sweep worker,Go 有专门的后台 sweep worker:在 GC 结束后启动,与 mutator 并发,慢慢把 span 扫干净。
bgsweep() / sweepone()
源码参考
入口:gcStart():go1.25.5/src/runtime/mgc.go:643
1 | // gcStart starts the GC. It transitions from _GCoff to _GCmark (if |
setGCPhase(_GCmark) 切换 GC phase(全局语义切换):go1.25.5/src/runtime/mgc.go:255
1 | // Garbage collector phase. |
gcBgMarkPrepare:go1.25.5/src/runtime/mgc.go:1393
1 | // gcBgMarkPrepare sets up state for background marking. |
gcPrepareMarkRoots:go1.25.5/src/runtime/mgcmark.go:60
1 | // gcPrepareMarkRoots queues root scanning jobs (stacks, globals, and |
stopTheWorldWithSema(reason stwReason):go1.25.5/src/runtime/proc.go:1617startTheWorldWithSema(now int64, w worldStop):go1.25.5/src/runtime/proc.go:1763
1 | // stopTheWorldWithSema is the core implementation of stopTheWorld. |