06-01-源码-Incremental GC总览与设计
发布时间:2026/10/22 7:29:04 锦皓数字建站

源码深度Incremental GC 总览与设计篇章06-源码深度-Unity增量式GC |阅读时间约 50 分钟 |前置知识GC 基本概念、三色标记算法一、引言与前提纠正06 篇章是整个系列里离源码最近的一层。03-04 已经从架构层讲过 Unity 增量 GC 的四阶段划分、写屏障代价、帧尾空闲窗口与配置方式本文不再重复那些架构级结论只在衔接处点一句。这一章要从源码层补齐三件事真实的文件在哪、调用链怎么走通、每个设计决策为什么长成现在这个样子。先纠正一个大纲级误解。Unity 的收集器不是 SGen。官方文档的表述很直接Unitys 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_UNLIMITEDexternal/bdwgc/include/private/gcconfig.h核心平台特性开关第 27 到 28 行定义MANUAL_VDBMANUAL_VDBexternal/bdwgc/mallocx.c核心脏提示的接收端GC_end_stubborn_change()、GC_change_stubborn()external/bdwgc/gc_priv.h核心脏位宏与页位图GC_dirty()、GC_dirty_pagesmono/metadata/boehm-gc.cMono 胶水Mono 后端的增量开关转发mono_gc_set_incremental()、mono_gc_is_incremental()libil2cpp/gc/BoehmGC.cppIL2CPP 胶水IL2CPP 默认 GC 实现Initialize()、CollectALittle()、SetWriteBarrier()libil2cpp/gc/GarbageCollector.cpp、WriteBarrier.cppIL2CPP 胶水引擎侧 GC 接口与屏障转发非增量核心引擎调用入口libil2cpp/gc/NullGC.cppIL2CPP 胶水无收集器的空实现编译期可替换libil2cpp/il2cpp-api.cppIL2CPP 导出给引擎与绑定层用的 C APIil2cpp_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.cppInitialize 中的增量段按公开源码整理 #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 托管 APIbindings.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.cGC_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_pagesGC_read_dirty(FALSE)拉出来的基线才有意义。第二层是工程上的顺势而为。启用开关的时刻通常在启动期或加载期本来就是不怕长暂停的窗口。把这一次全量收集塞进这里等于把「启用成本」预付在没人看帧时间的时刻玩家进战斗后跑的每一轮都是干净的增量周期。反过来说如果允许在堆又大又乱的运行中途中开增量第一轮标记就得背着历史债务上路既拖长又不可靠。5.3 为什么是 MANUAL_VDB 而不是 mprotect这是整个 Unity fork 最关键的一处改动也是 03-04 只能从架构层带过、必须到源码层才能讲透的地方。上游 bdwgc 的虚拟脏位virtual dirty bitsVDB有两条老路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关掉自动触发、由自己每帧泵CollectIncrementalDisabled只认显式全量收集。Manual模式在源码上没有任何特殊分支它只是把「谁来调用GC_collect_a_little()」这个决定权从引擎挪到脚本收集器本身感知不到差别。03-04 讲过三种模式的使用场景源码层补充的一点是它们共享同一套堆与同一套脏位机制切换模式没有状态迁移成本。六、完整生命周期时序6.1 帧视角的四拍启用之后每一轮增量收集在帧视角下都是同一个节奏初始 STW 建立标记起点随后每帧一个时间片推进标记最后收尾 STW 处理积压的脏页然后一次性清扫。下面这张图把函数名放回帧里示意时间为典型估算量级帧 k-1 [ 正常逻辑 渲染 ] 帧 k [STW 根标记][切片 3ms][渲染] ← 触发一轮收集 │ GC_start_incremental_collection / │ GC_collect_a_little 第一次推进 帧 k1 [切片 3ms][渲染] ← GC_collect_a_little() 帧 k2 [切片 3ms][渲染] ← GC_collect_a_little() ... 标记状态跨帧保留断点续跑 帧 kn [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.cGC_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 增量 GCBoehm 加分片标记.NET / CoreCLR GC源码来源上游 Mono 运行时自带Unity 维护的 bdwgc forkexternal/bdwgc.NET 运行时分代是Nursery 加 Major否一轮收集面对整堆是Gen0/1/2 加 LOH标记执行方式可选并发标记增量分片每帧受时间预算约束后台 GC对象移动支持带压缩不移动非压缩支持带压缩脏跟踪机制分代写屏障页粒度脏位加显式写屏障MANUAL_VDB分代写屏障是否在 Unity 增量链路中否是Mono 与 IL2CPP 默认仅 Unity 6 的 CoreCLR 后端Boehm 增量开关对它无效读表有三个要点。第一Unity 增量链路与 SGen 无关这一点有官方背书2019.1 手册 FAQ 明说还在用 BoehmSGen 需要额外工作、以后再跟进。很多「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 modeMono 与 IL2CPP 共用同一个 bdwgc forkSGen 从未随 Unity 产品发布IncrementalGC.cpp这样的文件不存在脏跟踪用的是页粒度脏位而不是卡表。链路认错后面每一个机制解释都会跟着错。第二架构是三层。bdwgc 核心负责算法与时间片引擎胶水层Mono 的boehm-gc.cIL2CPP 的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 里的真实信号。本文的价值是先把地图画对后面每一篇都在这张图上走。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。