源码深度:Incremental GC 总览与设计
篇章:06-源码深度-Unity增量式GC |阅读时间:约 50 分钟 |前置知识:GC 基本概念、三色标记算法
一、引言与前提纠正
06 篇章是整个系列里离源码最近的一层。03-04 已经从架构层讲过 Unity 增量 GC 的四阶段划分、写屏障代价、帧尾空闲窗口与配置方式,本文不再重复那些架构级结论,只在衔接处点一句。这一章要从源码层补齐三件事:真实的文件在哪、调用链怎么走通、每个设计决策为什么长成现在这个样子。
先纠正一个大纲级误解。
- Unity 的收集器不是 SGen。官方文档的表述很直接:Unity's garbage collector uses the Boehm-Demers-Weiser garbage collector. By default, Unity uses it in incremental mode. Mono 和 IL2CPP 两个脚本后端用的都是它。2019.1 版手册的 FAQ 更说得明白:we are still using the Boehm GC… SGen requires additional work and will follow. 也就是说 SGen 还需要额外的工作、以后再跟进,它从未随 Unity 产品发布过。Unity 的 Incremental GC 是 Boehm-Demers-Weiser 收集器(下称 bdwgc)的 incremental mode,不是对 SGen 标记阶段的改造,也不存在分代意义上的 Nursery 与 Major 划分。
IncrementalGC.cpp这个文件在任何公开的 Unity 源码里都不存在。真实存在的是libil2cpp/gc/目录下的GarbageCollector.cpp与WriteBarrier.cpp,但它们是 IL2CPP 后端与收集器之间的胶水代码,负责把引擎调用转发给 bdwgc,本身不含增量标记的实现。标记的核心在收集器自己的仓库里,也就是下文反复出现的external/bdwgc。- 卡表不是 Unity 增量 GC 的脏跟踪机制。Unity fork 里的 bdwgc 用的是页粒度脏位(
GC_dirty_pages),由显式写屏障调用驱动,整条链路没有卡表这一层。旧大纲把 .NET 系收集器的概念搬了过来,属于张冠李戴。
为什么值得专门花一节做这个纠正。链路认错了,后面每个问题都会答错:回退为什么会发生、写屏障为什么关掉增量就消失、Mono 与 IL2CPP 为什么行为一致,这三个问题的答案全部落在 bdwgc 源码里,而不是引擎层某个不存在的IncrementalGC.cpp里。纠正之后才谈得上和 03-04 正确衔接:架构层讲的「Boehm 加时间分片」这句话,在源码层对应GC_enable_incremental()、GC_collect_a_little()与GC_time_limit这一族符号,本文就是把这句话展开成文件与函数。
本文的路线:第二节讲设计目标及其源码落点,第三节给出三层架构总览,第四节是逐文件的源码地图,第五节从 Player Settings 的复选框一路走到GC_enable_incremental(),第六节用帧视角拆一轮完整生命周期,第七节做定位对比,第八节收时间线,最后是本章总结。标记算法内部、时间片调度器、分配路径与写屏障、性能边界这四块,本文只留路标,分别由 06-02 到 06-05 逐一展开。
二、设计目标:把单次 STW 暂停换成时间预算封顶
2.1 问题的源码表述
非增量模式下,bdwgc 的一次收集是完整的 stop-the-world:暂停所有托管线程,标记整堆,清扫,恢复运行。暂停长度与堆规模、存活对象数成正比。堆涨到 100MB 量级时,一次全量收集出现 50ms 上下的停顿并不罕见(典型估算,实际取决于堆布局、存活率与硬件)。03-04 已经算过这笔帧预算账,这里只补源码层的对应关系。
在 bdwgc 里,"时间预算"不是引擎外挂的一层计时器,而是收集器自己的全局状态。include/gc.h暴露了这一族接口:GC_time_limit变量本身、GC_set_time_limit()、GC_set_time_limit_ns(),以及表示不限时的GC_TIME_UNLIMITED。整个增量调度器都围着这几个符号转:一片标记做不做得完、要不要现在让出、要不要一口气收口,判据最终都回到时间预算上。这说明分片能力是收集器内建的,引擎只是设置参数的人。
2.2 三个目标与它们的源码落点
设计目标有三条,每一条都能在源码里找到落点,这也是本文和纯架构文章的区别。
第一,暂停可预测。目标是把单帧暂停从随堆增长变成被预算封顶,堆再大,一个时间片干不完就下帧接着干。源码落点是GC_time_limit与GC_set_time_limit_ns();引擎侧再包一层GarbageCollector.incrementalTimeSliceNanoseconds暴露给脚本,单位纳秒,这是开发者唯一的手动旋钮。
第二,开发者零修改。业务代码一行不动,勾一个 Player Settings 里的 Use Incremental GC 即可。源码落点是整条启用链路全走运行时初始化:开与不开共用同一个堆、同一套对象布局、同一套保守扫描逻辑,切开关不需要重新编译业务程序集,也没有迁移成本。03-04 把这叫作「同一个收集器的两种运行模式」,在源码上体现为GC_incremental这个全局标志位翻了个面。
第三,平滑降级。标记追不上对象图变化时不崩溃,而是退回一次性 STW 把这一轮做完。源码落点在alloc.c的GC_collect_a_little_inner(),它内部是一组分岔:还有机会就让出、下帧再试,机会用光就切到GC_stopped_mark(GC_never_stop_func),在一个暂停里把剩余标记做完。第六节会把这段骨架贴出来逐行看。
2.3 官方口径里的代价
官方文档对增量模式的描述并没有只报喜:增量模式会产生写屏障,引用变更太频繁会把标记压垮,此时收集器会回退到非增量的全量收集。把回退写进文档,等于承认它不是异常路径,而是设计内的正常分支。这条口径与第三节的架构结论互相印证:增量不是让 GC 消失,而是给它装一个每帧生效的上限,代价是写屏障指令与总 CPU 上升(总耗时典型估算高出三到五成,03-04 已有分析,此处不重复)。
一句话概括本节:设计意图不是消灭暂停,是把随堆大小走的单次停顿换成被时间预算封顶的每帧小片,用总吞吐换峰值暂停。
三、架构总览:三层结构
3.1 三层图
把调用方向从上往下画清楚,本文后面所有章节都在这张图里选层展开:
业务 C# 代码 │ GarbageCollector.isIncremental / CollectIncremental / │ incrementalTimeSliceNanoseconds、System.GC 相关开关 ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 托管 API 层 Runtime/Export/Scripting/GarbageCollector.bindings.cs │ │ (引擎导出给脚本的绑定,只做参数搬运) │ └──────────────────────────────────────────────────────────────────┘ │ mono_gc_set_incremental / il2cpp_gc_collect_a_little ... ▼ ┌──────────────────────────────────────────────────────────────────┐ │ 引擎胶水层 Mono 后端:mono/metadata/boehm-gc.c │ │ IL2CPP 后端:libil2cpp/gc/BoehmGC.cpp │ │ (同目录 GarbageCollector.cpp / WriteBarrier.cpp / │ │ NullGC.cpp,接口化的另外一些实现) │ └──────────────────────────────────────────────────────────────────┘ │ GC_enable_incremental / GC_collect_a_little / │ GC_set_time_limit / GC_END_STUBBORN_CHANGE ... ▼ ┌──────────────────────────────────────────────────────────────────┐ │ bdwgc 核心 external/bdwgc(子模块,Unity-Technologies/bdwgc.git)│ │ misc.c / alloc.c / mallocx.c / include/gc.h / │ │ include/private/gcconfig.h / gc_priv.h │ └──────────────────────────────────────────────────────────────────┘3.2 每层的职责边界
bdwgc 核心是唯一真正干活的一层:增量标记的推进、时间片的让步与收口、脏位的记录与复查、清扫,全在这里。Mono 后端与 IL2CPP 后端共享同一个收集器,来源都是external/bdwgc子模块,对应仓库 GitHub - Unity-Technologies/bdwgc: The Boehm-Demers-Weiser conservative C/C++ Garbage Collector (libgc, bdwgc, boehmgc) · GitHub 。这一点解释了两个后端为什么在增量行为上高度一致:它们最终调的是同一个 fork 里同一批函数,差异只发生在胶水层怎么转发参数。
引擎胶水层做两件脏活。一是决定初始化时机与参数,什么时候调GC_enable_incremental()、时间片设多大;二是接口适配,把引擎内部的 GC 调用翻译成 bdwgc 的 C 接口。IL2CPP 这边的胶水是接口化的,BoehmGC.cpp是默认实现,同目录还有个NullGC.cpp,一个什么都不做的空实现,由il2cpp-config.h里的IL2CPP_GC_BOEHM 1在编译期选定。这个设计的含金量在于:上层只认胶水接口,换掉整个收集器实现时,业务代码与引擎调用点都不用动。
托管 API 层薄得几乎只剩转发。GarbageCollector.bindings.cs暴露三个成员:isIncremental(只读,当前是否真在增量模式)、incrementalTimeSliceNanoseconds(每片预算,纳秒)、CollectIncremental(ulong nanoseconds = 0)(手动推进一片)。03-04 从使用角度讲过这三个 API,本文关心的是它们的下游:CollectIncremental顺着胶水层走到GC_collect_a_little(),isIncremental顺着胶水层走到GC_is_incremental_mode(),全是查询与转发,没有任何逻辑藏在这一层。
3.3 为什么是三层而不是一层
值得多问一句:引擎为什么不直接调用 bdwgc,非要插两层壳。原因有三,都有源码证据。其一,Mono 与 IL2CPP 的运行时初始化时机不同,必须各自决定何时开启增量,胶水层就是这个分流点。其二,托管世界与 C 世界之间需要一层导出符号,il2cpp-api.cpp暴露的il2cpp_gc_collect_a_little等 C API 就是给引擎其他模块和绑定层用的。其三,NullGC.cpp的存在证明胶水层同时是收集器的可替换点,将来换后端(例如 Unity 6 的 CoreCLR)时,失效的只是这一层,而不是上面的 API 或下面的算法。
四、源码地图:每个关键文件与职责
4.1 总表
先给一张全景表,后文只挑最关键的几处展开:
| 文件 | 所属层 | 职责 | 关键符号 |
|---|---|---|---|
external/bdwgc/misc.c | 核心 | 增量模式的启用入口 | GC_enable_incremental() |
external/bdwgc/alloc.c | 核心 | 时间片推进、让步与回退 | GC_collect_a_little_inner()、GC_start_incremental_collection() |
external/bdwgc/include/gc.h | 核心 | 时间预算的公开接口 | GC_time_limit、GC_set_time_limit()、GC_set_time_limit_ns()、GC_TIME_UNLIMITED |
external/bdwgc/include/private/gcconfig.h | 核心 | 平台特性开关,第 27 到 28 行定义MANUAL_VDB | MANUAL_VDB |
external/bdwgc/mallocx.c | 核心 | 脏提示的接收端 | GC_end_stubborn_change()、GC_change_stubborn() |
external/bdwgc/gc_priv.h | 核心 | 脏位宏与页位图 | GC_dirty()、GC_dirty_pages |
mono/metadata/boehm-gc.c | Mono 胶水 | Mono 后端的增量开关转发 | mono_gc_set_incremental()、mono_gc_is_incremental() |
libil2cpp/gc/BoehmGC.cpp | IL2CPP 胶水 | IL2CPP 默认 GC 实现 | Initialize()、CollectALittle()、SetWriteBarrier() |
libil2cpp/gc/GarbageCollector.cpp、WriteBarrier.cpp | IL2CPP 胶水 | 引擎侧 GC 接口与屏障转发,非增量核心 | 引擎调用入口 |
libil2cpp/gc/NullGC.cpp | IL2CPP 胶水 | 无收集器的空实现 | 编译期可替换 |
libil2cpp/il2cpp-api.cpp | IL2CPP 导出 | 给引擎与绑定层用的 C API | il2cpp_gc_collect_a_little等 |
Runtime/Export/Scripting/GarbageCollector.bindings.cs | 托管 API | 脚本可见的三个成员 | isIncremental等 |
读这张表有一个容易踩的坑:GarbageCollector.cpp和WriteBarrier.cpp的文件名看起来像增量核心,实际上它们在libil2cpp/gc/里,职责是把 IL2CPP 引擎侧的调用转给 bdwgc,和BoehmGC.cpp是同一层的邻居。真正的增量逻辑一个字都不在它们里面,全在external/bdwgc的misc.c与alloc.c里。
4.2 Mono 胶水:boehm-gc.c 的三个转发
Mono 后端的增量相关代码集中在mono/metadata/boehm-gc.c,它几乎不做逻辑,只把 Mono 运行时的调用转发给 bdwgc:
// mono/metadata/boehm-gc.c:关键转发关系(按公开源码整理) gboolean mono_gc_is_incremental (void) { return GC_is_incremental_mode (); // 查询 bdwgc 当前模式 } void mono_gc_set_incremental (gboolean value) { if (value) GC_enable_incremental (); // 开增量 else GC_disable_incremental (); // 关掉,回到全量 STW }同一个文件里还有mono_gc_start_incremental_collection(),转发到GC_start_incremental_collection(),也就是显式启动一轮增量收集的入口。另外 Mono 运行时保留了一个incremental=运行时选项,可以在不改 Player Settings 的情况下,从启动参数控制增量开关,这条路径最终也汇入mono_gc_set_incremental()。查询、开关、启动,三个动作对应三个薄封装,Mono 后端的全貌就这么小。这个「小」本身就是信息:所有聪明都放在 bdwgc 里,胶水层故意保持无知。
4.3 IL2CPP 胶水:BoehmGC.cpp 的初始化块
IL2CPP 后端的实现比 Mono 稍厚,因为要带参数。libil2cpp/gc/BoehmGC.cpp的Initialize()里,增量相关的一段被写屏障宏包着:
// libil2cpp/gc/BoehmGC.cpp:Initialize 中的增量段(按公开源码整理) #if IL2CPP_ENABLE_WRITE_BARRIERS GC_enable_incremental(); GC_set_time_limit(IL2CPP_INCREMENTAL_TIME_SLICE); #endifIL2CPP_INCREMENTAL_TIME_SLICE是引擎侧给的默认时间片,翻译成 bdwgc 的全局预算GC_time_limit。整个BoehmGC.cpp对 bdwgc 的映射可以列成对照表:
| BoehmGC.cpp 方法 | 转发到的 bdwgc 调用 |
|---|---|
CollectALittle() | GC_collect_a_little() |
StartIncrementalCollection() | GC_start_incremental_collection() |
SetMaxTimeSliceNs(ns) | GC_set_time_limit_ns(ns) |
IsIncremental() | GC_is_incremental_mode() |
SetWriteBarrier(void** ptr) | GC_END_STUBBORN_CHANGE(ptr) |
最后一行值得停一下。SetWriteBarrier转发到的GC_END_STUBBORN_CHANGE名字看着很怪,它其实是 Boehm 原生的 stubborn 对象接口:上游 bdwgc 里GC_change_stubborn()与GC_end_stubborn_change()用来声明「这个对象在标记期间需要特殊对待」。Unity 没有使用这套语义,而是把它掏空再利用:mallocx.c里GC_change_stubborn被做成空函数,GC_end_stubborn_change(p)只剩一句GC_dirty(p),语义退化成「这一页动过了,请标脏」。借一个现成的函数名当写屏障的钩子,比新造一条 API 并贯通全链路的改动小得多,这是 fork 里很典型的工程取舍。
还有一处编译期开关要留意:GC_dirty宏只在GC_incremental为真时才真正干活,这句「开增量才有写屏障」的文档口径,源码出处就在这里。增量关掉,引用写入退化成原始存储指令,一行额外代码都没有。
4.4 托管 API:bindings.cs 只是出口
Runtime/Export/Scripting/GarbageCollector.bindings.cs定义脚本可见的GarbageCollector类:isIncremental映射到胶水层的模式查询,incrementalTimeSliceNanoseconds映射到GC_set_time_limit_ns()一族,CollectIncremental映射到GC_collect_a_little()。System.GC上与收集相关的开关也从这层绑定进来。理解这层的要点只有一个:它没有决策权,所有行为差异都发生在它下面两层,所以排查性能问题时不要在 C# 层找算法,那里只有转接线。
五、启用路径端到端
5.1 从复选框到 GC_enable_incremental
把整条链路画成一张图,起点是编辑器里的勾选:
Player Settings 勾选 Use Incremental GC(构建期配置,打进 Player 设置) │ ▼ 运行时读取设置 → GarbageCollector.GCMode = Incremental │ ▼ GC 初始化,随脚本后端分流 ┌────┴──────────────────────┐ Mono 后端 IL2CPP 后端 mono_gc_set_incremental() BoehmGC::Initialize() (或 incremental= 选项) #if IL2CPP_ENABLE_WRITE_BARRIERS │ GC_enable_incremental(); └────────────┬──────────────┘ GC_set_time_limit(时间片) ▼ GC_enable_incremental() (external/bdwgc/misc.c) │ ├─ 确认页大小、安装平台相关处理 ├─ GC_incremental = GC_dirty_init() │ └─ MANUAL_VDB:建立 GC_dirty_pages 页脏位图 ├─ if (GC_bytes_allocd > 0) │ GC_gcollect_inner(); │ └─ 堆上已有分配 → 先强制一次完整收集 └─ GC_read_dirty(FALSE) └─ 为接下来的增量周期建立脏位基线misc.c里GC_enable_incremental()的主体可以整理成下面这样(省略平台相关细节,保留顺序关系):
// external/bdwgc/misc.c:GC_enable_incremental 的关键顺序(按公开源码整理) void GC_enable_incremental (void) { /* ... 设置页大小、安装平台相关处理 ... */ GC_incremental = GC_dirty_init (); /* 建立脏位跟踪,MANUAL_VDB 下即初始化页脏位图 */ if (GC_bytes_allocd > 0) { GC_gcollect_inner (); /* 堆已有分配,先做一次完整收集 */ } GC_read_dirty (FALSE); /* 建立脏位基线 */ }顺序即语义:先让脏位机制上线,再决定要不要强制全量,最后拉基线。
5.2 为什么启用时要先强制一次完整收集
GC_bytes_allocd > 0表示自上次收集以来堆上已有分配,此时源码坚持先调GC_gcollect_inner()把堆收干净,然后才允许增量周期开始。从调用顺序可以读出两层设计意图(源码没有写注释解释,以下是基于顺序的合理推断,不是官方说法)。
第一层是状态对齐。启用之前收集器一直跑在非增量语义下,历史写入没有按增量规则被记录成脏位;脏位机制刚刚由GC_dirty_init()建立,如果直接进入增量标记,第一个周期要面对一堆从未登记过的引用变更,基线不可信。先做一次完整收集,等于把整个堆重置到干净状态,之后的每次写入都会经过增量模式的写屏障进入GC_dirty_pages,GC_read_dirty(FALSE)拉出来的基线才有意义。
第二层是工程上的顺势而为。启用开关的时刻通常在启动期或加载期,本来就是不怕长暂停的窗口。把这一次全量收集塞进这里,等于把「启用成本」预付在没人看帧时间的时刻,玩家进战斗后跑的每一轮都是干净的增量周期。反过来说,如果允许在堆又大又乱的运行中途中开增量,第一轮标记就得背着历史债务上路,既拖长又不可靠。
5.3 为什么是 MANUAL_VDB 而不是 mprotect
这是整个 Unity fork 最关键的一处改动,也是 03-04 只能从架构层带过、必须到源码层才能讲透的地方。
上游 bdwgc 的虚拟脏位(virtual dirty bits,VDB)有两条老路:MPROTECT_VDB把堆页用mprotect设为只读,应用线程写入触发缺页异常,处理函数把该页标脏后放行;GWW_VDB在少数平台上直接用硬件写跟踪。03-04 提过前一条路的原理,而源码层要补的事实是:Unity 的 fork 根本不走这两条路。include/private/gcconfig.h的第 27 到 28 行定义了MANUAL_VDB,上游 bdwgc 没有这个宏。出处是 2018-05-23 的 commit 2ca67995,作者是 Unity 工程师 Jonas Echterhoff,提交信息一句话说清了意图:enable MANUAL_VDB mode for incremental collection。
为什么移动端要换掉 mprotect 加缺页信号的方案?从平台约束出发有三条理由(结合移动平台特性的工程推断):其一,iOS 与 Android 上 SIGSEGV 是稀缺资源,崩溃上报、调试器、系统的内存保护机制都在抢这个信号,收集器再往信号处理器里塞翻页权限的逻辑,和别的机制打架;其二,MPROTECT_VDB每轮标记开始都要成片改页权限,叠加缺页陷入的成本,在移动 CPU 上是持续的缓存与 TLB 抖动;其三,GWW_VDB依赖的硬件写跟踪在手机平台上基本没有可用实现。三条合起来,结论只有一个:脏位必须由软件自己记。
于是就有了这条链路。托管侧引用写入经胶水层SetWriteBarrier(ptr)转到GC_END_STUBBORN_CHANGE(ptr),落到mallocx.c里就是GC_dirty(p);gc_priv.h的GC_dirty宏在GC_incremental为真时把对应页写进GC_dirty_pages位图。整套机制零信号依赖、纯软件实现,代价是粒度:一次标脏的单位是一整页,复查范围比卡表粗,多扫几个无辜对象换来了在移动端的可靠运行,这笔交换在游戏场景里是划算的。脏页如何被复查、如何参与正确性论证留给 06-02,写屏障如何记录脏页则是 06-04 的正题,本文只需要记住结论:Unity 的增量正确性靠「显式写屏障加页脏位」,不靠卡表,也不靠缺页异常。
5.4 手动路径与运行时选项
除了构建期的复选框,链路上还有几个入口值得在源码地图上标出来。Mono 后端的incremental=运行时选项是一条旁路,最终同样汇入mono_gc_set_incremental()。托管侧的GarbageCollector.GCMode三个取值对应三种行为:Incremental走自动路径,Manual关掉自动触发、由自己每帧泵CollectIncremental,Disabled只认显式全量收集。Manual模式在源码上没有任何特殊分支,它只是把「谁来调用GC_collect_a_little()」这个决定权从引擎挪到脚本,收集器本身感知不到差别。03-04 讲过三种模式的使用场景,源码层补充的一点是:它们共享同一套堆与同一套脏位机制,切换模式没有状态迁移成本。
六、完整生命周期时序
6.1 帧视角的四拍
启用之后,每一轮增量收集在帧视角下都是同一个节奏:初始 STW 建立标记起点,随后每帧一个时间片推进标记,最后收尾 STW 处理积压的脏页,然后一次性清扫。下面这张图把函数名放回帧里(示意,时间为典型估算量级):
帧 k-1 [================= 正常逻辑 + 渲染 =================] 帧 k [STW 根标记][切片 3ms][渲染] ← 触发一轮收集: │ GC_start_incremental_collection / │ GC_collect_a_little 第一次推进 帧 k+1 [切片 3ms][渲染] ← GC_collect_a_little() 帧 k+2 [切片 3ms][渲染] ← GC_collect_a_little() ... (标记状态跨帧保留,断点续跑) 帧 k+n [STW 标记收口][清扫][渲染] ← 脏页复查完毕,标记完成,进入清扫(3ms 是典型估算量级,不是固定常数;真实长度由GC_time_limit与设备性能共同决定。)
启用那一刻的那次强制全量收集(第 5.2 节的GC_gcollect_inner())相当于一个前置的零号周期,它不跨帧、就是一次完整 STW,之后才进入上面的循环节奏。
6.2 一片是怎么推进的
跨帧推进的入口是GC_collect_a_little(),内部实现在alloc.c的GC_collect_a_little_inner(int n)。把公开源码的骨架整理如下(省略统计与锁细节,保留控制流):
// external/bdwgc/alloc.c:GC_collect_a_little_inner 的控制流骨架(示意性整理) static int GC_collect_a_little_inner (int n) { /* 收集进行中时,循环推进标记: 反复调用 GC_mark_some((ptr_t)0),直到累计推进量 达到 GC_rate * n 这个额度 */ while (GC_collection_in_progress) { /* 每一步 GC_mark_some 都会扫描一小段对象图, 随时可以被中断 */ if (/* 本片时间已到 */) { if (GC_n_attempts < max_prior_attempts && GC_time_limit != GC_TIME_UNLIMITED) { /* 还有机会:短暂 STW 收尾这一小步后让出, 下一帧继续(GC_timeout_stop_func) */ return GC_stopped_mark (GC_timeout_stop_func); } else { /* 机会用光或不限时:一次 STW 做完剩余全部标记 (GC_never_stop_func,即回退路径) */ return GC_stopped_mark (GC_never_stop_func); } } } /* 一轮收集完成 */ }这段骨架值得逐个分岔讲清楚。正常路径里,函数每次被调用只做一小步:从标记栈取灰对象、扫描它的引用、把新发现的白对象染灰,进度游标和标记栈都留在收集器的持久区域里,所以下一帧能精确续上,不存在「重扫一遍」的浪费。这就是 03-04 所说的跨帧保留状态在源码上的载体。
超时分岔是整篇文章的枢纽。时间片到点时,如果尝试次数还没超过上限、且GC_time_limit是有限值,就走GC_timeout_stop_func让出,把控制权还给游戏逻辑;如果尝试次数已尽,或者时间预算被设成GC_TIME_UNLIMITED,就走GC_never_stop_func,在一个 STW 里把剩余标记做完。官方文档里那句「引用变更太频繁会导致回退到非增量收集」,指的就是后一条分支。回退不是崩溃,是设计内的兜底:它保证任何情况下收集都能收敛,代价是这一轮既付了若干帧的切片开销,最后还得挨一记接近全量水平的长暂停。
6.3 分配路径也会泵切片
一个容易被忽略的源码细节:推进增量标记的不只有引擎每帧的调用点。alloc.c的分配路径(约 1564 到 1584 行)在时间限制内也会主动泵几片标记,也就是说,分配压力本身就推动标记前进。这个设计把「收集」和「分配」耦合进同一条时间预算:堆涨得越快,标记被推着走得越急,同时也越接近上面的回退条件。它还解释了一个运行时现象:即使引擎侧的调度因为某些帧结构异常没来得及调用CollectALittle,只要游戏还在分配内存,增量周期就不会完全停滞。分配路径与写屏障的完整细节属于 06-04 的主题,本文点到为止。
6.4 收尾 STW、清扫,以及保守扫描的角色
标记完成后,收尾 STW 要做的是脏页复查:把增量期间被GC_dirty标记过的页重新扫一遍,凡是新增或改写的引用补上标记。做完这一步,漏标的风险窗口就关闭了。随后的清扫保持一次性、不分片:Boehm 是标记-清扫式且不移动对象,清扫只是把未标记的块挂回空闲链表,速度远快于标记,也不改对象图,为它引入跨帧状态得不偿失。这一点 03-04 从架构层解释过,源码层补一个视角:清扫不分片与「只有标记能被GC_mark_some增量推进」在代码结构上是同一件事,GC_collect_a_little_inner里只有标记阶段有让步分岔,清扫阶段根本没有对应的接口。
最后提一句保守扫描在切片模式下的角色。标记窗口被拉长到多帧之后,世界持续在变,栈上的值随时可能被改写;Boehm 本来就保守地把所有「看起来像指针」的字当作候选,切片带来的漏标风险由两道保险兜住:写屏障记录的脏页会被复查,可疑指针会被多看一眼。代价是可能多保留一些本该回收的对象,在非压缩、非分代的前提下这个代价可接受。标记算法的内部细节(灰栈管理、大对象扫描、保守识别的边界情况)是 06-02 的正题。
七、与 SGen、.NET Core GC 的定位对比
03-04 已经从架构层做过一次横向对比,本文不重复那张表,这里换一个视角:从源码链路看三种收集器在 Unity 语境下的位置。Mono SGen 一列只是划界用的对照,它在 Unity 的增量链路里不存在。
| 维度 | Mono SGen(对照,Unity 未随产品发布) | Unity 增量 GC(Boehm 加分片标记) | .NET / CoreCLR GC |
|---|---|---|---|
| 源码来源 | 上游 Mono 运行时自带 | Unity 维护的 bdwgc fork(external/bdwgc) | .NET 运行时 |
| 分代 | 是(Nursery 加 Major) | 否,一轮收集面对整堆 | 是(Gen0/1/2 加 LOH) |
| 标记执行方式 | 可选并发标记 | 增量分片,每帧受时间预算约束 | 后台 GC |
| 对象移动 | 支持,带压缩 | 不移动,非压缩 | 支持,带压缩 |
| 脏跟踪机制 | 分代写屏障 | 页粒度脏位加显式写屏障(MANUAL_VDB) | 分代写屏障 |
| 是否在 Unity 增量链路中 | 否 | 是(Mono 与 IL2CPP 默认) | 仅 Unity 6 的 CoreCLR 后端,Boehm 增量开关对它无效 |
读表有三个要点。第一,Unity 增量链路与 SGen 无关,这一点有官方背书:2019.1 手册 FAQ 明说还在用 Boehm,SGen 需要额外工作、以后再跟进。很多「Unity 用 SGen」的说法其实来自上游 Mono 的默认配置,Unity 这条产品线并没有走到那里。
第二,为什么不直接切到 SGen。从工程角度可以推出一个合理判断:Boehm 已经稳定跑在 Mono 与 IL2CPP 两个后端上,把标记过程切片只需要动收集器内部的推进逻辑,堆管理、对象布局、上层调用全部不用改,这是改动最小的路径;换用 SGen 则意味着同时迁移两个后端的整条收集链路,风险与工作量都不在一个量级。2ca67995 这个 commit 的形态本身就是佐证:一个只加了MANUAL_VDB定义与配套改动的 fork,而不是一个重写的收集器。
第三,分代与压缩这两件事在 Unity 链路里根本不存在,所以两件常被混谈的问题必须分开:碎片不会因为开了增量而消失,因为对象从不移动;堆也不存在小步快跑的新生代缓冲,每一轮增量标记面对的都是整堆的可疑指针,标记总量与堆规模直接挂钩。这也是为什么 Unity 项目的长期解法要么在代码层把分配降下来,要么在 Unity 6 上迁到 CoreCLR 后端换成 .NET 那套分代加压缩的方案。增量 GC 解决的只有暂停形态问题。
八、时间线:从一个 fork commit 到默认开启
把关键节点排成一条竖线:
2018-05-23 bdwgc fork commit 2ca67995(作者 Jonas Echterhoff) 「enable MANUAL_VDB mode for incremental collection」 ↑ 增量能力的载体诞生在收集器 fork 内部 2019.1.0a8 实验特性出现在 Player Settings,需手动勾选 2019.1.0f1 正式发布,opt-in(默认关闭,自行开启) 2019.4 LTS 默认开启 Unity 6 默认开启;CoreCLR 后端不支持;WebGL 平台从不支持这条时间线可以读出三件事。
其一,改动的落点从第一天起就在收集器 fork 内部。2018 年 5 月这个引入MANUAL_VDB的 commit,比 2019.1 的实验特性早了大半年,说明先有收集器侧的工程改造,后有产品化的开关。整个增量能力的物质基础就是「fork 一份 bdwgc,把脏位方案换成软件记录」,没有另起炉灶的痕迹。
其二,从 alpha 的实验特性到 2019.1.0f1 的 opt-in,再到 2019.4 LTS 默认开启,官方用了超过一年做验证,默认值的变化本身就是验收结果:这个特性在真实项目里的收益大于回退风险,才敢在 LTS 上翻成默认。对还在评估的项目来说,这条演进路径比任何基准测试都更能说明官方的信心。
其三,边界从一开始就画得很清楚。WebGL 平台从不支持增量 GC,勾了复选框也无效,构建跑的仍是非增量路径;Unity 6 的 CoreCLR 后端用的是 .NET 自带的 GC,不走 bdwgc 这套机制,Use Incremental GC对它同样无效。两个排除项的共同点是都不在 bdwgc 的能力范围内:前者受限于 WebAssembly 运行环境(官方文档未给出更细的说明),后者压根换了收集器。部署到这两个目标的项目,增量相关的分析一律不适用。
本章总结
本文从源码层把 Unity Incremental GC 拆开看了一遍,要点收成五条。
第一,前提要摆正。Unity 的 Incremental GC 是 Boehm-Demers-Weiser 收集器的 incremental mode,Mono 与 IL2CPP 共用同一个 bdwgc fork,SGen 从未随 Unity 产品发布,IncrementalGC.cpp这样的文件不存在,脏跟踪用的是页粒度脏位而不是卡表。链路认错,后面每一个机制解释都会跟着错。
第二,架构是三层。bdwgc 核心负责算法与时间片,引擎胶水层(Mono 的boehm-gc.c,IL2CPP 的BoehmGC.cpp等)负责时机、参数与接口适配,托管 API 层(GarbageCollector.bindings.cs)只做转发。所有设计决策的源头都在最底下一层。
第三,启用是一条有序链路。从 Player Settings 的复选框到GC_enable_incremental(),中间经过mono_gc_set_incremental()或BoehmGC::Initialize()两个分流点;bdwgc 内部先由GC_dirty_init()建立MANUAL_VDB脏位机制,堆上已有分配时强制一次GC_gcollect_inner()全量收集,再用GC_read_dirty(FALSE)拉好基线。强制全量是为了让第一个增量周期站在干净的状态上。
第四,生命周期是四拍。初始 STW 定起点,每帧GC_collect_a_little()一片、内部靠GC_mark_some()推进,超时后按「还有机会就让出、机会用光就 STW 收口」的分岔决定让步还是回退,最后收尾 STW 复查脏页、一次性清扫。分配路径也在时间预算内泵切片,回退写在官方文档里,是设计内行为而非异常。
第五,这套方案的工程基因很重。用MANUAL_VDB换掉 mprotect 加缺页信号,是对移动端平台约束的直接回应;复用 stubborn 接口当写屏障钩子,是对 fork 改动成本的精打细算;只切分标记、不动清扫,是对「什么值得引入跨帧状态」的清醒判断。它没有发明新算法,把暂停从堆大小决定改成时间预算决定,靠的是这些一层层垒起来的工程选择。
后续四篇会往深处走:06-02 拆标记算法内部,看灰栈、页脏位复查与保守识别的具体实现;06-03 拆时间片调度器,看GC_time_limit、GC_rate与让步条件如何共同决定每帧的推进量;06-04 拆分配路径与写屏障,看GC_dirty的完整调用链与它在两种模式下的成本差异;06-05 拆性能边界与回退场景,把源码结论对应到 Profiler 里的真实信号。本文的价值是先把地图画对,后面每一篇都在这张图上走。