06-04-源码-增量式GC的分配与写屏障

发布时间:2026/10/23 2:54:36
06-04-源码-增量式GC的分配与写屏障
源码深度增量式 GC 的分配与写屏障篇章06-源码深度-Unity增量式GC |阅读时间约 40 分钟 |前置知识虚内存管理、写屏障一、引言增量标记期间应用线程仍然在分配新对象和修改引用。这两类操作必须与增量标记协调新分配的对象应该是存活的不能被视为垃圾引用修源码深度增量式 GC 的分配与写屏障篇章06-源码深度-Unity增量式GC |阅读时间约 50 分钟 |前置知识虚拟内存、写屏障、标记算法基础一、引言与前提纠正03-04 已经从架构层面讲清了 Unity 增量 GC 的四阶段短暂 STW 初始标记、跨帧推进标记、短暂 STW 最终标记、一次性清扫。本章下沉一层只盯住增量期间最要命的两件事标记进行中还在发生的分配以及标记进行中还在发生的引用写入。前者决定新对象会不会被本轮清扫误伤后者决定旧对象持有的新引用会不会被漏标。分配与写屏障就是围绕这两个问题建立的协调机制。动笔之前必须先纠正一个前提否则整章都会跑偏。卡表CARD_SIZE 8 字节、SATB 前置屏障、TLAB/LOH/SGenAlloc来组织内容这套描述不是 Unity 的实现。卡表card table与 SATBsnapshot-at-the-beginning前置屏障属于 .NET CoreCLR 与 Mono SGen 一代收集器的设计路线把堆切成固定大小的卡用屏障在写入时标脏对应的卡SGen 的分代分配TLAB、LOH同样是那条路线上的组件。而 Unity 的增量 GC 底座是 Boehm它在 Unity 的路径上根本没有卡表也没有 SATB 前置屏障。Unity 真正的机制是 MANUAL_VDB写屏障在引用写入后把被写指针所在的整个虚拟内存页在一个按页粒度组织的GC_dirty_pages表里标脏入口是GC_dirty(p)最终落到GC_dirty_inner(p)。脏的粒度不是字节不是卡而是操作系统的内存页典型 4KB。Boehm 的 stubborn-change APIGC_end_stubborn_change被复用为这个脏页提示的上报口。把旧大纲的卡表叙事当成 Unity 实现会得出每 8 字节一张卡、扫卡即可这类与源码完全对不上的结论所以本文开头就把这条线切断。按系列惯例本章路线如下第二节先从漏标场景推演出为什么必须追踪引用变更第三节讲 MANUAL_VDB 的设计与由来把它与上游的另外两种脏位策略对比第四节逐个点开 IL2CPP 与 Mono 两条写屏障注入链路第五节解释 stubborn API 为什么被复用第六节看脏页如何被消费、页粒度的代价有多大第七节回到增量期间的分配第八节剖析写屏障开销第九节交代平台差异最后收束总结。二、为什么增量标记必须追踪引用变更2.1 漏标场景推演非增量模式下这个问题不存在标记全程 STW堆在标记期间是静止的扫过的对象不可能再冒出新引用。增量把标记摊到多帧之后应用线程与标记线程并发堆在标记眼皮底下持续变化漏标窗口就此打开。把最危险的一幕拆成四个步骤【步骤 1】标记推进中对象 A 已被扫描完毕黑色对象 B 尚未被发现白色 --------------- --------------- | A黑色 | | B白色 | | field: --------旧引用 | 只有栈上局部变量指向它 --------------- --------------- ^ | 标记线程已经扫过 A不会再回头看它 【步骤 2】应用线程执行 A.field B把一个黑色对象写成指向白色对象 --------------- 写入 --------------- | A黑色 | ----field------ | B白色 | --------------- --------------- ^ | GC 此刻毫无感知A 是黑的B 还是白的 【步骤 3】标记宣告结束B 仍是白色清扫阶段把它当垃圾回收 --------------- --------------- | A黑色 | field | 已回收 | | | ---------------- | 空闲块 | --------------- --------------- | | A.field 现在是一个悬空引用 【步骤 4】下一次访问 A.field A.field - 已释放内存 / 空引用 - 崩溃且崩溃点远离 GC极难定位要点在步骤 2 到步骤 3 之间的认知差标记算法只知道我扫过谁不知道我扫完之后谁又被改了。一个已经变黑的对象在余下的标记窗口里写入新引用新引用的目标对象如果只被这一条边可达就会在收口时因为仍是白色而被回收。崩溃不会发生在 GC 里而会发生在之后某个平平无奇的字段访问上这是漏标最阴险的地方。2.2 Boehm 的答案不是插入屏障是持续重扫教科书对这个问题的标准解法是三色不变式加屏障Dijkstra 插入屏障在黑对象写入白引用时把目标染灰立刻纳入标记队列Yuasa 的 SATB 删除屏障则在写入前把旧值快照下来。两条路线的共同点是屏障本身就参与染色直接维护标记状态。Boehm 走的是第三条路。它的写屏障不染色只做一件事记账把被写内存所在的页标脏。正确性由另一个环节兜底在标记真正完成之前反复重扫所有脏页。页里的每一个字被重新当作潜在引用过一遍保守扫描步骤 2 里那个新写入的A.field B就在重扫中被重新发现B 因此被染灰、被扫、被保住。一句话概括屏障负责记住哪里动过标记线程负责动过的地方多看几遍看的次数一直持续到本轮标记收敛为止。这个设计与 Boehm 的保守扫描天然契合。保守扫描本来就允许宁可多看把页里所有像指针的值都当引用追一遍多标几个对象无非是浮动垃圾少标一个才是事故。持续重扫脏页把漏看的窗口压到接近零代价是页粒度带来的重复劳动这个代价的量化放在第六节。脏页重扫如何参与正确性06-02 已经从标记算法层面推过一遍本章接着往下挖的是这些脏页从哪里来、由谁记、怎么被消费。把三种思路放在一张表里对照方案屏障做什么屏障是否染色正确性靠什么收口代表实现Dijkstra 插入屏障黑写白时把目标染灰入队是标记队列推进到空教科书 / 部分精确 GCYuasa SATB 删除屏障写入前把旧值记入快照是记旧值快照集合处理完增量式收集器常见Boehm 脏页重扫只把被写页标脏否收口前反复重扫脏页直到不再产生新标记Unity 增量 GC读表的要点在第三列前两条路线里屏障是标记算法的直接参与者改一个字段等于改标记状态Boehm 把这两件事解耦了屏障只是一本账本标记算法可以在任意时刻暂停、在下一帧接着按账本补课。这个解耦正是增量分片能够成立的结构基础没有它标记做到一半停下来就意味着账目作废。三、MANUAL_VDB 的设计与由来3.1 脏位跟踪的三种策略哪些页在增量期间被写过这个问题bdwgc 上游给了三种回答方式统称虚拟脏位Virtual Dirty BitsVDBMPROTECT_VDB把堆页mprotect成只读应用线程第一次写某页时触发 SIGSEGV缺页处理器在信号处理函数里把该页标脏、恢复写权限后续写入畅通无阻。它的好处是写入路径完全不用改不给每次引用赋值加代码代价是每页的首次写入都要走一次信号与缺页路径在移动端这条路径既昂贵又会与平台对信号处理的限制发生冲突。GWW_VDB依赖硬件或操作系统提供的写监视能力在标记端批量查询哪些页被动过。写入侧几乎零开销但要平台提供相应 API覆盖面由目标系统决定。MANUAL_VDB谁写谁报。编译进代码的写屏障在每次引用写入后显式地把对应页标进脏页表。写入侧每处多几条指令换来的是零信号、零平台依赖、行为完全可预期。三条路的分野可以这样概括MPROTECT 把成本压在每页的第一次写入上GWW 把成本压在标记端的批量查询上MANUAL 把成本摊在每一次引用写入上。对桌面服务器端缺页信号每页只发生一次摊下来很便宜所以上游乐于用 MPROTECT对移动端堆页数量大、帧内写入密集信号路径的常数开销和平台对信号处理、内存保护的限制叠加在一起这条看似免费的路反而最贵。3.2 Unity 的选择一次两行宏的 fork上游 bdwgc 按平台在 MPROTECT_VDB 与 GWW_VDB 之间选择MANUAL_VDB 并不是上游打开的默认选项。Unity 的 fork 在include/private/gcconfig.h第 27 到 28 行显式定义了MANUAL_VDB对应提交是 2018-05-23 的 2ca67995作者 Jonas Echterhoff提交说明为 enable MANUAL_VDB mode for incremental collection。也就是说Unity 把脏位跟踪从内核帮你记改成了写屏障显式上报时间点与增量收集的引入同步。开启 MANUAL_VDB 之后gc_priv.h里的脏页入口长这样/* gc_priv.h节选 */ #define GC_dirty(p) (GC_incremental ? GC_dirty_inner(p) : (void)0)这个宏值得盯三秒。GC_incremental为假时整个调用退化为空操作非增量模式下即使屏障代码在也不会产生任何记账开销GC_incremental为真时才进入GC_dirty_inner(p)由它计算p所在页并把GC_dirty_pages中对应位置脏。写屏障的成本开关和增量模式的开关是同一个。3.3 三种策略对比策略脏页由谁记录写入侧开销主要限制在 Unity 路径中MPROTECT_VDB内核缺页 SIGSEGV 处理每页首次写入一次信号路径移动端信号路径代价高与平台信号限制冲突不采用GWW_VDB硬件 / 操作系统写监视接近零依赖平台 API覆盖不保证不采用MANUAL_VDB写屏障显式上报每次引用写入多几条指令屏障必须覆盖所有引用写入路径采用表中开销一栏是机制层面的定性判断不是实测数字。真正的量化在第八节那里的每个数字都标了典型估算。选 MANUAL_VDB 等于把复杂度从运行时挪进编译产物GC 内核不再需要操纵页保护位或查询硬件代价转移到每次引用赋值的指令流上。对游戏这种写入频繁、平台碎片化严重、又不能在信号处理上赌命的场景这笔交换是合理的也解释了为什么 Unity 没有沿用上游的缺页方案。四、写屏障注入点MANUAL_VDB 只规定了怎么记账账本上的内容还得由各个脚本后端把屏障插进引用写入路径才有。Mono 与 IL2CPP 两条链路的函数名不同形状一致先完成真实写入再上报脏页。4.1 IL2CPP 链路核心函数在libil2cpp/gc/WriteBarrier.cpp// libil2cpp/gc/WriteBarrier.cpp void WriteBarrier::GenericStore(void** ptr, void* value) { *ptr value; // 先把值真正写进去 GarbageCollector::SetWriteBarrier((void**)ptr); // 再把指针所在页标脏 }顺序是固定的先*ptr value后SetWriteBarrier。SetWriteBarrier最终走到 Boehm 的GC_END_STUBBORN_CHANGE(ptr)也就是第五节要讲的那个被复用的 stubborn 接口。引擎与业务代码里实际到处出现的不是这个函数而是WriteBarrier.h里的IL2CPP_OBJECT_SETREF宏族。宏把赋值加屏障打包成一个原子的书写单位让所有引用字段写入都走同一条路径避免某处手写赋值漏掉记账。AOT 编译产物里C# 的obj.field value会落到这一族宏或GenericStore上。批量场景另有一条快路径GarbageCollector.cpp中的SetWriteBarrier(ptr, size)形式接受一段范围一次调用覆盖整块内存的记账供数组拷贝、集合搬迁这类连续写入使用避免逐元素重复进入单指针屏障。4.2 Mono 链路Mono 后端的屏障集中在mono/metadata/boehm-gc.c// mono/metadata/boehm-gc.c关键函数的职责示意 void mono_gc_dirty(void** ptr) { GC_dirty(ptr); } // 直通 Boehm 脏页入口 void mono_gc_wbarrier_set_field_internal(...) { /* 先写字段 */ // 真实赋值 mono_gc_dirty(field_ptr); // 再把字段所在页标脏 } void mono_gc_wbarrier_generic_nostore_internal(...) { /* 不产生存储只对给定地址执行记账 */ // 用于值没变但需要报备的路径 } void mono_gc_wbarrier_arrayref_copy_internal(...) { /* memmove 拷贝整段引用数组之后 */ GC_dirty_range(...); // 用范围脏页一次覆盖整段 }三个入口对应三类写入形态单字段写、无存储仅记账、整段数组拷贝。数组路径用GC_dirty_range而不是循环调用单页函数与 IL2CPP 的批量SetWriteBarrier(ptr, size)是同一个思路。4.3 完整链路把两条后端合到一张图上C# 源码obj.field value | v 编译期 / AOT 注入写屏障官方文档写屏障只在增量模式下生成 | - IL2CPP - WriteBarrier::GenericStore(ptr, value) | (1) *ptr value | (2) GarbageCollector::SetWriteBarrier(ptr) | \-- GC_END_STUBBORN_CHANGE(ptr) | - Mono --- mono_gc_wbarrier_set_field_internal(...) (1) 写字段 (2) mono_gc_dirty(field_ptr) | v GC_dirty(p) gc_priv.h 宏GC_incremental 为真才继续 | v GC_dirty_inner(p) 计算 p 所在页号 - GC_dirty_pages[页号] 置脏位 | v 标记推进中 / 最终标记收口时复查脏页 - 该页整页重扫新引用被重新发现一个容易搞混的点值得点名屏障只在增量模式下由官方生成这条写在 Unity 文档里。非增量模式下引用赋值就是一条裸存储图上整条竖线都不存在一旦开启增量每次引用写入都要付这条链路的钱。五、Boehm stubborn API 的复用5.1 源码一个空函数和一行转调GC_dirty有了谁来调它IL2CPP 的SetWriteBarrier指向GC_END_STUBBORN_CHANGE这是一个本来另有用途的 Boehm 接口。在mallocx.c里它被改造成脏页上报口// mallocx.cMANUAL_VDB 下的形态示意 void GC_change_stubborn(void* p) { /* 空函数 */ } void GC_end_stubborn_change(void* p) { GC_dirty(p); // 把 p 所在的整页标脏 }GC_change_stubborn什么都不做GC_end_stubborn_change(p)退化成一次GC_dirty(p)。脏页粒度是页不是对象本身GC_dirty_inner按页号索引GC_dirty_pages一个对象落在哪页整页就被标脏页内其他对象跟着陪跑第六节量化这个陪跑的代价。5.2 为什么复用而不是新增接口stubborn 语义在上游 Boehm 里的原意是客户端管理的对象写入前后需要通知收集器收集器据此调整对该对象的处理。Unity 没有另起炉灶设计一套GC_mark_page_dirty之类的公开接口而是把这套既有 API 直接挪用为脏页提示理由可以还原成三条对上游改动最小。开启 MANUAL_VDB 的核心开关就是gcconfig.h里两行宏定义加上胶水层的调用适配GC_dirty、GC_dirty_inner、GC_dirty_pages这套脏页基础设施本来就在 MANUAL_VDB 分支里存在不需要新造。fork 的维护成本低。Unity 的 bdwgc 是从上游 fork 出来的新增接口意味着每次同步上游都要处理更多冲突点。改两行宏加一层转调把分叉面压到最小。语义本来就对得上。我刚改过这个对象你多看一眼与这页脏了标记时重扫是同一件事的两种说法stubborn-change 的调用时机天然就在写入完成之后与写屏障先写后报的顺序严丝合缝。代价是接口名字与真实行为脱节读源码时看到GC_END_STUBBORN_CHANGE必须知道在 Unity 的构建里它等于标脏一整页。这也是本章专门开一节讲它的原因。六、脏页的消费6.1 谁在读脏页脏页不是记完就完它的价值全在消费端。链路上有三个消费点标记推进过程中标记线程通过GC_read_dirty一类的路径读取脏页集合对仍标记为脏的页重新执行扫描把第二节场景里新写入的引用补上标记每次标记迭代此前被GC_end_stubborn_change标脏的对象所在页会被重新扫一遍页内所有指针槽位重新参与保守判定最终标记第三次 STW收口阶段把增量期间积累的脏页全部处理干净确保进入清扫前没有漏网引用。03-04 提过的最终标记补扫增量期间被改写的区域落到源码层面就是这三件事。脏页边记边扫、扫完可能又被写脏这个循环一直持续到标记收敛Boehm 才允许进入清扫。这里有个时序细节容易被忽略复查不是扫一遍就清账。一轮复查处理完某页后如果应用线程又往这页写了新引用写屏障会立刻把它重新标脏下一轮复查还得再来。脏页表因此不是一份静态清单而是一个在标记窗口内不断被写入端刷新的活账本。标记能否收敛取决于复查一轮消耗的脏页与复查期间新产生的脏页谁跑得快这也解释了为什么高写入密度会让一轮增量标记拖得特别长。复查的执行时机也值得注意。它不集中在一个阶段而是穿插在标记推进的过程中推进若干对象之后查一轮脏页把新发现的引用染灰并入队再继续常规推进。最终标记的那次 STW 只是收口把窗口最后阶段的残留脏页清干净。如果把所有复查都堆到最后一次 STW那么停顿时长又会随脏页数量膨胀增量分片就白做了所以复查必须前置并摊进各个时间片。6.2 页粒度的典型估算现在量化第二节留下的问题。一页典型 4KB一个引用槽位 8 字节那么一页最多容纳512 个指针槽位典型估算实际还受对象头、对齐、非引用字段影响。页粒度的直接后果是一页之内任何一个槽位的一次引用写入会让整页 512 个槽位在下次复查时全部重扫。写一个扫五百个这是 MANUAL_VDB 放大效应的来源。两个典型场景会把这个倍数放大到肉眼可见链表重排。每帧对链表做插入、摘除、重链接每次操作写的是两个节点的next字段。节点在堆上离散分布一次重链接可能命中两个不同页每次操作标脏两页而真正变化的只有两个指针槽。大数组引用填充。往一个引用数组里连续灌值比如每帧重建一个上千元素的缓冲区写入横跨多个页这些页全部变脏重扫量等于被写的页数乘 512其中大量槽位是从未变过的旧值。高写入密度下脏页率逼近饱和标记线程把预算大量花在重扫旧数据上推进变慢推进变慢又让下一轮复查面对更多脏页。这个正反馈一旦转起来标记无法在限制内收敛Boehm 回退为一次性全量 STW。回退的触发条件与帧时间表现在 06-03 讲性能数据归 06-05这里只需要记住页粒度是把双刃剑省下了卡表的维护成本换来了重扫的放大系数。七、增量标记期间的分配7.1 分配路径非分代的空闲链表先把舞台搭好Boehm 非分代、非压缩分配走空闲链表与 bump 等价路径03-02 已述没有新生代的线程本地分配缓冲也没有大对象堆的分代特例。这条分配路径本身不在本章展开只讨论它与增量标记的交互。7.2 新对象必须出生即已标记标记窗口内分配的对象面临一个生死问题本轮标记已经开始清扫尚未进行如果新对象按白色出生它会在清扫阶段被当成垃圾回收掉而程序下一秒就要用它。所以第一条规则很硬标记期间分配的对象必须出生即已标记直接按存活处理不进入本轮待回收集合。Boehm 通过分配器与标记状态的协同实现这一点示意如下// 示意标记窗口内的分配实际逻辑内嵌在 Boehm 分配器中 void* AllocDuringMark(size_t size) { void* obj 从空闲链表取块(size); if (GC_incremental 本轮标记进行中) { // 出生即已标记本轮清扫不碰它 // 它也不需要被加入标记队列因为它创建时还没有任何引用字段指向外部 SetMarked(obj); } return obj; }7.3 为什么新对象不需要插入屏障接着会冒出一个看似危险的问题新对象出生就是黑的如果它的构造过程或后续第一次赋值让它指向了一个白色对象这不正是第二节说的黑色引用白色吗为什么不需要插入屏障去染灰目标三个机制合起来兜住了这件事新对象直接视为已标记它不会被本轮清扫误伤这是前提保守扫描会把新对象所在页里的每个字都当潜在引用看一遍只要值像指针就会追进去误回收的窗口被压得很小脏页重扫仍然覆盖新对象对新对象引用字段的每一次写入本身就要走写屏障它所在的页会被标脏在标记收口前的复查中被重新扫过。也就是说Boehm 依然不需要 Dijkstra 式的插入屏障。新对象出生即存活解决它自己的生死脏页重扫解决它指向谁的问题两者都不染色都走事后重看的路子。顺带把一个常见疑问按掉既然新对象出生即已标记那它算黑色还是灰色在 Boehm 的脏页模型里这个纠结意义不大因为颜色并没有被写在每个对象头上供屏障读取标记的推进状态由标记队列与进度游标维护而新对象根本不进这个队列它只是从本轮的回收候选中被摘了出去。少了逐对象的颜色字段也就少了屏障每次写入都要读写颜色的那部分开销这是不染色路线在分配侧的连带收益。7.4 分配速度超过标记速度还有一条边界要交代如果分配速度快于标记推进速度标记窗口内堆持续膨胀每帧新增的已标记对象和脏页不断加入标记迟迟不能收敛。Boehm 不会让这个状态无限持续超过限制后回退为一次性全量 STW。这个不收敛问题在本章只是露个头它的调度细节归 06-03性能表现与回退数据归 06-05。八、写屏障开销剖析8.1 开销拆解没有屏障时一条引用赋值就是一条 store 指令。开启增量后每次引用写入多出两截条件判断。宏层的GC_incremental检查非增量时直接短路为空操作这条分支预测几乎总是命中成本接近可忽略GC_dirty_inner的记账。页号计算地址右移 12 位得到页索引加一次字节写GC_dirty_pages对应位置脏几条指令无分支、无系统调用。单次开销极小问题从来不在单次而在频率引用写入是游戏代码里频率最高的操作之一热循环里每次迭代都在付这两截钱。还有一个隐藏成本值得点出来指令流的膨胀。一条裸 store 是一条指令加上判断与记账后热路径上多了好几条指令循环体变长会挤占指令缓存与解码带宽即便分支永远预测成功取指与执行端的压力仍在。这类成本无法在微观基准里用数指令的方式完整体现只有在真实负载的 Profiler 对比里才现形也是为什么开销区间给出的是一个范围而不是定值。8.2 典型开销估算下面的数字都是典型估算量级不是任何项目的普适实测值验收必须以 Profiler 实测为准场景写屏障带来的回退典型估算引用写入密集的 tight loop链表构建、容器搬运5% 到 15%平均游戏负载2% 到 5%引用写入稀少、以值类型与原生计算为主接近 0同一份数据的详细基准与分项表放在 06-05本节只给机制与量级开销正比于引用写入频率跟堆大小、对象数量无关因为GC_dirty_inner不扫描任何东西只做一次索引加一次写。8.3 DOTS 路径天然零屏障值得补一句互操作层面的对照Burst 编译的代码与 JobSystem 里的 Job 不产生托管引用写入Job 内禁止持有托管对象引用所以 DOTS 的热路径完全不经过这条屏障链路天然是零屏障开销。这也是把重计算挪进 Job在增量 GC 下的额外收益不仅省了主线程还省掉了写屏障。这与第 10 篇讲互操作的内容呼应细节不在此展开。九、平台差异WebGL不支持增量 GCGarbageCollector.isIncremental恒为 false也就不存在增量模式的屏障注入引用赋值仍是裸存储。代价是也享受不到分片标记卡顿治理只能靠减少分配。CoreCLR 后端Unity 6用的是 .NET 自己的 GC 与它自己的写屏障体系Unity 围绕 Boehm 做的这套 MANUAL_VDB 链路不参与其中Use Incremental GC 这个开关对它无效。要评估写屏障开销得换成 .NET 后台 GC 的屏障模型来分析本章的源码结论不适用。Mono 与 IL2CPP本章主体两者都跑在 Boehm 上第四节的两条注入链路就是它们各自的形态本章的分析对这两个后端成立。两个后端的屏障在语义上等价差别只在胶水层的函数组织与批量路径的入口形状评估开销时可以合并看待。把三行放在一起还能读出一个规律本章整套机制的生效前提只有一个项目真的运行在 Boehm 增量模式下。平台支持、开关勾选、后端选择三者同时满足MANUAL_VDB 这条链路才存在任何一环不满足引用赋值就退回裸 store。所以排查写屏障相关问题时第一步永远是确认GarbageCollector.isIncremental的实际取值而不是先去猜代码路径。十、总结本章从源码层把增量 GC 的两条协调机制走了一遍写屏障负责记账把每次引用写入所在的页标进GC_dirty_pages分配器负责新对象的生死标记窗口内出生即已标记。两条机制都不维护三色状态都走事后重看的路线这正是 Boehm 与教科书式插入屏障的分野。MANUAL_VDB 这个 fork 里两行宏的选择把脏位跟踪从内核缺页搬进了写屏障换来的是一条可预期、零信号依赖、页粒度记账的链路。理解了写一个标脏一页、一页重扫 512 个槽也就理解了增量 GC 在高写入密度代码上的性能形状以及它什么时候会回退成全量 STW。本章总结旧大纲的卡表与 SATB 叙事属于 .NET/SGen 设计Unity 的实现是 MANUAL_VDB 页粒度脏表没有卡表。Boehm 不用插入屏障染色而是写屏障记账、标记完成前持续重扫脏页靠事后重看保证不漏标。Unity fork 在gcconfig.h定义MANUAL_VDBcommit 2ca679952018-05-23脏页入口GC_dirty(p)落到GC_dirty_inner在GC_dirty_pages页位置位。IL2CPP 的GenericStore、IL2CPP_OBJECT_SETREF宏族与 Mono 的mono_gc_wbarrier_*都是先写后报最终都汇入GC_dirty。GC_end_stubborn_change(p)被复用为脏页上报口GC_change_stubborn为空改动量压到两行宏加一层胶水。页粒度代价典型估算一页 4KB 含 512 个指针槽一次写入引发整页重扫链表重排与大数组填充放大规模可观是回退风险的来源之一。标记期间分配的对象出生即已标记配合保守扫描与脏页重扫新对象不需要插入屏障。写屏障开销典型估算密集引用写入回退 5% 到 15%平均负载 2% 到 5%以 Profiler 实测为准DOTS/Burst 路径零屏障。WebGL 不支持增量CoreCLR 后端走 .NET 自己的屏障体系本章结论适用于 Mono 与 IL2CPP。改必须被追踪不能导致漏标。分配和写屏障是实现协调的核心机制。二、增量标记期间的对象分配2.1 新分配对象的颜色// 增量标记期间分配的白色对象 → 被回收 → 程序崩溃 // 所以新分配对象不能是白色 void* AllocDuringIncrementalMark(size_t size) { void* obj SGenAlloc(size); if (g_incremental_mark_in_progress) { // 直接标记为黑色存活不需要再扫描引用 // 新对象刚创建时没有引用指向其他存活对象外的对象 // 因此它不会导致黑色对象引用白色对象的问题 SetMarked(obj); } return obj; }2.2 TLAB 在增量 GC 中的处理SGen 使用 TLABThread-Local Allocation Buffer加速分配。增量标记期间TLAB 的处理需要特殊考虑// TLAB 在增量标记期间的分配 void* TLABAllocDuringMark(size_t size) { ThreadLocalData* tld GetThreadLocalData(); // 优先从 TLAB 分配快速路径 if (tld-next size tld-limit) { void* obj tld-next; tld-next size; if (g_incremental_mark_in_progress) { MarkObject(obj); // 标记为黑色 } return obj; } // TLAB 耗尽获取新 TLAB return SlowPathAlloc(size); }2.3 LOH 分配大对象≥ 8KB不经过 Nursery直接在 Major Heap 分配。增量标记期间的大对象分配void* AllocLOHDuringMark(size_t size) { void* obj SGenAllocLarge(size); if (g_incremental_mark_in_progress) { MarkObject(obj); // 标记为黑色 // 大对象可能包含引用字段 // 如果大对象引用了白色对象写屏障会处理 } return obj; }三、卡表写屏障3.1 卡表的数据结构// WriteBarrier.cpp - 卡表实现 #define CARD_SIZE 8 // 每卡覆盖 8 字节 #define CARD_TABLE_SHIFT 3 // 2^3 8 // 卡表字节数组 // 每字节标记一卡0 干净, 非0 脏 uint8_t* g_card_table; size_t g_card_table_size; // 写入卡表 static inline void SetCard(void* addr) { size_t card_index ((uint8_t*)addr - g_heap_start) CARD_TABLE_SHIFT; g_card_table[card_index] 1; } // 检查卡是否脏 static inline bool IsCardDirty(void* addr) { size_t card_index ((uint8_t*)addr - g_heap_start) CARD_TABLE_SHIFT; return g_card_table[card_index] ! 0; }3.2 写屏障函数// IncrementalGC.cpp - JIT 调用的写屏障函数 extern C void IncrementalGCWriteBarrier(void** addr, void* value) { // 1. 执行赋值 *addr value; // 2. 如果正在进行增量标记标记脏卡 if (g_incremental_mark_in_progress) { SetCard(addr); } } // 优化版本没有条件分支的版本更快但更占内存 extern C void IncrementalGCWriteBarrier_Always(void** addr, void* value) { *addr value; SetCard(addr); // 始终标记脏卡不在热路径上检查 g_incremental_mark_in_progress // 增量标记结束后在最终标记中扫描所有脏卡即可 }四、脏卡的处理4.1 脏卡扫描每帧的增量标记步骤中调度器分配一部分工作单元给脏卡扫描// IncrementalGC.cpp - 脏卡扫描 void ScanDirtyCards(int max_cards_to_scan) { int scanned 0; while (scanned max_cards_to_scan g_dirty_card_index g_card_table_size) { if (g_card_table[g_dirty_card_index] ! 0) { // 清脏标记 g_card_table[g_dirty_card_index] 0; // 计算这张卡覆盖的内存范围 void* card_start g_heap_start g_dirty_card_index * CARD_SIZE; void* card_end card_start CARD_SIZE; // 扫描卡范围内的所有引用 ScanMemoryRangeForRefs(card_start, card_end); scanned; } g_dirty_card_index; } }4.2 脏卡压缩当脏卡数量很多时扫描全部脏卡可能消耗大量时间。Unity 的增量 GC 使用卡束Card Bundle技术——将 64 张卡合并为一个卡束如果卡束内没有脏卡则跳过整个卡束范围// 卡束64 卡合并为一个标记位 #define CARDS_PER_BUNDLE 64 uint64_t g_card_bundles[CARD_TABLE_SIZE / 64]; void ClearBundle(int bundle_index) { g_card_bundles[bundle_index] 0; } void SetBundle(int bundle_index, int card_index) { g_card_bundles[bundle_index] | (1ULL (card_index % CARDS_PER_BUNDLE)); }五、写屏障的 CPU 开销写屏障不是免费的。每次对象引用赋值都多了一个条件判断和可能的卡表写入。5.1 基准测试// 写屏障开销测量 using System.Diagnostics; class WriteBarrierBenchmark { class Node { public Node Next; } static long MeasureWriteBarrierCost(int iterations) { Node[] nodes new Node[iterations]; var sw Stopwatch.StartNew(); for (int i 0; i iterations - 1; i) { nodes[i].Next nodes[i 1]; // 写屏障在此触发 } sw.Stop(); return sw.ElapsedTicks; } }5.2 典型开销场景写屏障额外开销大量引用赋值链表构建8-12%平均场景5-8%少量引用赋值❤️%六、写屏障与 JIT 的协作写屏障不是在 C# 层实现的。它是在运行时层JIT 编译直接注入到机器码中的C# 代码obj.field value JIT 编译后的机器码有写屏障 mov rcx, [obj offsetof(field)] ; 旧值 mov [obj offsetof(field)], rdx ; 赋值 test [gc_marking_flag], 1 ; 检查是否需要屏障 jz skip call write_barrier ; 记录脏卡 skip: ...如果gc_marking_flag为 false写屏障的检查只是一个 test jz 指令约 1-2 个 CPU 周期开销极低。七、分配与写屏障的协同应用线程 GC 线程增量标记 │ │ │──new T()──→ AllocObj() ──→ MarkObject(obj) // 新对象标记为黑色 │ │ │──obj.ref x──→ WriteBarrier() │ → SetCard(addr) // 标记脏卡 │ │ │ │──ScanDirtyCards() // 处理脏卡 │ │──MarkStep() // 继续标记八、总结增量标记期间分配和写屏障是两个必不可少的协调机制。分配确保新对象不被误回收写屏障确保引用变更不被遗漏。两者协同实现了一边分配、一边标记的增量执行模式是增量 GC 正确性的双重保证。写屏障虽然带来 5-10% 的 CPU 开销但这是换取低暂停时间的必要代价。本章总结增量标记期间分配对象直接标记为黑色卡表写屏障追踪引用变更。两者协同实现标记正确性。