03-04-架构篇-Unity增量式GC架构解析
Unity 增量式 GC 架构解析篇章03-架构篇 |阅读时间约 45 分钟 |前置知识了解增量式 GC 概念一、引言Unity 在 2019.1 首次引入 Incremental GC。它当时以实验特性的身份出现在 Player Settings 中需要手动勾选经过几个版本的迭代后续 Unity 版本把它变成了默认开启的正式选项。它要解决的问题很单一把 GC 那种几十毫秒的整段停顿拆开摊到连续的多帧里让每帧只承担几毫秒的开销。动配置之前先纠正一个流传很广的误解Unity 的增量 GC 不是 SGen 的增量改造也不是一个独立的新收集器。官方文档的表述很明确Unity 使用 Boehm-Demers-Weiser 收集器并且默认以增量模式运行Mono 与 IL2CPP 两个脚本后端用的都是它。所以Unity 增量 GC的准确含义是 Boehm 的 incremental mode底层对应GC_enable_incremental一族接口加上 Unity 在引擎侧做的调度与封装。Boehm 本身非分代、非压缩、保守式识别引用这些性质在增量模式下一条都没有变。本文按原理、定位、机制、代价、限制、评估的顺序展开先讲清设计思路与权衡第二节明确它在 GC 架构中的位置第三节再拆解标记机制与写屏障第四、五节随后是时间预算、回退与受限场景第六到第八节最后是配置 API、性能评估与架构对比第九到第十一节。二、设计原理2.1 问题背景Boehm 在非增量模式下的一次收集是完整的 stop-the-world暂停所有托管线程标记整个堆清扫然后恢复运行。单次暂停的长度与堆大小、存活对象数量成正比。堆涨到 100MB 量级时一次全量标记加清扫出现 50ms 上下的停顿并不罕见示例量级实际取决于堆布局、存活率与硬件。50ms 对游戏意味着什么60FPS 下每帧预算只有 16.7ms一次 50ms 的停顿会直接吃掉 3 帧左右暂停到 100ms 就是 5、6 帧。GC 停顿最麻烦的地方是它的突发性前一帧输入响应正常这一帧整个世界冻结下一帧又恢复正常玩家感知到的是规律性的顿挫而不是平均帧率的下降。画质、逻辑复杂度都救不了它因为停顿全部发生在收集器内部。背后还有一层结构性原因Boehm 不分代没有新生代那种小步快跑的回收缓冲每一轮收集都要面对整堆堆越大、存活对象越多暂停就越长。移动项目的堆又往往随运行时间只涨不掉单次时长和触发频率会一起恶化。这就是增量改造被提上日程的直接背景。2.2 时间分片增量式 GC 的思路是把一次做完改成每帧做一点。同一段标记工作在两种模式下的时间线差异如下非增量普通模式一次性 STW 标记 帧 N-1 帧 N 帧 N1 [运行] [ 50ms 全停标记 清扫 ] [运行] ↑ 这一段里 3 帧的逻辑与渲染全部冻结 增量时间分片标记每帧 3ms 时间片 帧 N 帧 N1 帧 N2 帧 N3 帧 N4 帧 N5 帧 N6 [3ms] [3ms] [3ms] [3ms] [3ms] [3ms] [收尾] └──────────── 同样的标记工作摊到多帧每帧只多出 3ms ────────────┘3ms 为典型估算量级不是固定常数实际由帧预算与堆规模共同决定。2.3 关键权衡增量不是免费的它是一笔明确的交易用更高的总 CPU 消耗换更低的单帧峰值暂停。峰值暂停从随堆增长的几十毫秒压到时间片量级每帧 3ms 上下典型估算。总 CPU 时间一轮增量周期的总耗时通常比非增量高出 30% 到 50%典型估算不是实测常数。开销来自三处写屏障在每次引用写入时附加的指令标记被摊薄后缓存局部性变差跨帧维护标记状态本身的成本。附带收益暂停变得可预测。堆再大单帧的 GC 开销也被时间预算封顶帧时间从不可控变成可控。对游戏这类延迟敏感的负载这笔交易几乎是稳赚的。玩家对平均帧率的感知远弱于对卡顿尖峰的感知总吞吐略降、偶发冻结大幅减少正是帧率优化想要的方向。三、架构定位与 Boehm 的关系3.1 官方定位Unity 官方文档写得直接Unity 的垃圾收集器使用 Boehm-Demers-Weiser 收集器默认以增量模式运行Mono 后端与 IL2CPP 后端都使用它。由此可以推出三条结论Unity 的 GC 底座自始至终是 Boehm不存在一个Unity 自研收集器开启增量不是切换到另一个 GC而是同一个 Boehm 打开 incremental 开关底层即GC_enable_incremental非分代、非压缩、保守式扫描这三条性质原样保留。对象永远不被移动堆里的空洞只能靠空闲链表复用碎片不会因为开了增量而消失。3.2 非增量与增量的结构对比非增量全量 STW 标记 触发 │ ▼ ┌──────────────────────────────────────┐ │ STW根扫描 → 标记整个堆 → 清扫整个堆 │ 暂停时长 ≈ O(堆大小) └──────────────────────────────────────┘ │ ▼ 恢复运行 增量Boehm 增量模式分片标记 触发 │ ▼ [短暂 STW] 初始标记扫描根建立标记起点 │ ▼ [帧 k] 推进标记 ←─┐ [帧 k1] 推进标记 │ GC_collect_a_little / GC_mark_some [帧 k2] 推进标记 │ 每次只推进一小步标记状态跨帧保留 [帧 kn] 推进标记 ──┘ │ ▼ [短暂 STW] 最终标记处理增量期间积累的脏区域 │ ▼ 清扫一次性完成通常很快 → 恢复运行3.3 与 SGen 的界线Unity 增量 GC 是 SGen 的运行模式这个说法需要明确否定历史上的实际情况是这样的SGen 曾经在 Unity 内部被实验性地试过但从未随产品发布团队最终选择在 Boehm 上做时间分片标记理由是改动最小Boehm 已经稳定跑在两个后端上把标记过程切片不需要动收集器的其他部分所以 Unity 增量 GC 既不是 SGen 的一部分也不是SGen 加分片它的准确描述只有一个Boehm 时间分片标记。把它理解成Boehm 的一种运行模式还有实用价值关掉增量开关你得到的就是原生 Boehm 的全量 STW 收集。两者共享同一套堆、同一套对象布局、同一套保守扫描逻辑来回切换没有迁移成本。顺带一提SGen 是上游 Mono 运行时自带的分代收集器很多Unity 用 SGen的说法就来源于此它并不在 Unity 的增量链路里。四、增量标记的工作机制4.1 四个阶段一轮增量收集分为四个阶段只有中间的推进阶段跨帧阶段执行方式工作内容典型耗时示例1. 初始标记短暂 STW扫描根栈、静态字段、注册根确定起点0.1ms 量级2. 增量标记跨多帧每帧一个时间片从灰色对象出发逐层推进对象图每帧 3ms 量级3. 最终标记短暂 STW补扫增量期间被改写的区域收口标记0.1ms 量级4. 清扫一次性回收未标记对象的内存不分片1ms 量级清扫不进入分片是有原因的。Boehm 是标记-清扫式且不移动对象清扫只是把未标记的块挂回空闲链表速度远快于标记也不改对象图为它引入跨帧状态得不偿失。4.2 从 Boehm 看推进接口从 Boehm 的接口视角增量推进的入口是GC_collect_a_little它内部通过GC_mark_some执行一小段标记工作后返回。返回值区分两种情况这一小步做完但整轮未完成或者标记已经收尾。未完成时引擎在后续帧继续调用直到标记结束才进入清扫。标记栈与进度游标这些状态保存在 GC 管理的持久区域里跨帧保留因此任意一帧中断后下一帧都能从断点精确续上。初始根扫描必须 STW因为扫描根的同时应用线程可能正在改写栈上的引用。好在根扫描通常在 0.1ms 量级示例值不是暂停的大头。真正被切片的是中间遍历对象图的部分也就是耗时与堆大小成正比的那一段。还有个容易忽略的细节标记窗口内新分配的对象怎么办。答案是它们出生即按已标记处理不会被这一轮清扫误伤也不需要回头补扫。代价在于增量窗口拉得越长这类边扫边生的对象就越多堆在标记期间持续膨胀正是回退风险的来源之一见第七节。4.3 写屏障与脏位增量标记最难的不是分几次做完而是做的过程中世界还在变。标记开始后应用线程可能把一个已扫描对象的引用改指向一个尚未标记的对象。没有拦截的话这个新引用指向的对象会被漏标清扫时被当成垃圾回收随后就是难以定位的空引用崩溃。Boehm 的应对是记录脏区域非增量模式下标记期间没有应用代码运行开始时把整堆视为脏即可不需要细粒度记录增量模式下必须边跑边记。Boehm 的传统手段是虚拟脏位virtual dirty bitsVDB用mprotect把堆页设为只读应用线程写入触发缺页异常处理函数把该页标脏后放开写权限GC 最后只需复查这些脏页在支持硬件跟踪的平台上则直接用系统提供的写跟踪能力Unity 侧的配套改动是增量模式下额外为托管代码生成写屏障引用字段的写入会顺带标记对应的脏页或脏卡。最终标记阶段第三次 STW要做的就是把脏区域重新扫一遍凡是增量期间新增或改写的引用补上标记。四阶段的顺序设计正是为了让分片推进与引用持续变化这两件事同时成立。Boehm 本身是保守式收集器可疑的值会被多看一眼这进一步压低了漏标风险代价是可能保留本该死掉的对象。五、写屏障的代价5.1 引用写入变慢写屏障不免费。非增量模式下引用赋值就是一条普通存储指令开启增量后Unity 必须为引用字段的写入额外生成屏障代码告诉 GC 这块内存动过了。单次开销只有几条指令但引用写入是游戏代码里频率最高的操作之一频率乘以指令数累积起来能被 Profiler 看到。5.2 哪些代码受影响最吃紧的是引用写入密集的 tight loop每帧大量改写引用的容器操作反复塞入、清空、交换引用粒子、寻路、行为树这类逐帧改引用字段的系统热循环里对类字段的反复赋值。// 增量模式下每次迭代都要多付一次写屏障 for (int i 0; i buffer.Length; i) buffer[i].next scratch[i]; // 引用写入 → 触发屏障纯值类型写入与非托管内存写入不受影响它们不涉及托管引用变更。这也是能用 struct 搬数据就别搭 class 引用链在增量模式下收益更直接的原因。5.3 与非增量模式的对比维度非增量全量 STW增量分片标记引用写入成本无屏障原始写入每次写入附带屏障指令GC 执行方式周期性一次性长暂停每帧小片推进总 CPU 消耗基线典型估算高出 30%~50%帧时间形态规律性尖峰平稳偶发回退尖峰所以增量并非在所有项目里都更快。堆很小、GC 很少触发、但引用写入极其频繁的项目可能只感受到吞吐的轻微下降。开不开、开了有多大收益要靠 Profiler 数据说话直觉靠不住。六、时间预算与帧尾空闲时间6.1 默认按帧的剩余时间工作增量标记每帧推进多少取决于这一帧还剩多少时间。默认策略就是吃帧尾渲染和逻辑跑完之后如果本帧还有剩余预算GC 就用它推进一片标记。换成具体数字感受一下16.7ms 的帧预算里扣掉 3ms 的时间片留给逻辑和渲染的还有 13ms 以上多数场景吸收得了同样 3ms 落进本就吃紧的帧就会直接顶破预算。所以增量 GC 在帧本身有余量的项目里效果最好在已经超帧的项目里只是把停顿挪了地方帧预算本身还是得先治。6.2 VSync 与 targetFrameRate 决定有没有帧尾存在帧尾空闲是有前提的。Unity 的行为是设置了 VSync垂直同步不为 Dont Sync或者设置了Application.targetFrameRate时帧与帧之间存在等待窗口Unity 会自动利用这些空闲时间推进增量标记两者都没设、任由帧率跑满时每帧被逻辑和渲染塞满没有天然空闲增量推进的空间被压缩一轮标记可能拖得更久完不成时更容易触发回退见第七节。所以启用增量 GC 的项目最好把目标帧率或 VSync 一起配好这两件事是配套的。6.3 手动控制时间片需要更精确的控制时GarbageCollector.incrementalTimeSliceNanoseconds可以手动指定每片的时间预算单位是纳秒。设为 0 表示不限制单次时长设为具体数值则每次推进不超过该时长。常见用法是加载期临时调大让一轮收集尽快完成进入帧敏感阶段前调小把 GC 压进空闲缝隙里。6.4 一次增量周期的帧时间分布示例帧 0触发初始标记 STW 0.1ms 增量标记 3.0ms 3.1ms 帧 1帧 5 增量标记 3.0ms × 5 帧 15.0ms 帧 6收尾最终标记 STW 0.1ms 清扫 1.0ms 1.1ms ------------------------------------------------------------ GC 总耗时 19.2ms摊在帧 0 到帧 6 共 7 帧上单帧最大 3.1ms 对照同一堆规模下非增量模式的一次性停顿约 50ms以上为示例数据用于说明分摊结构不代表任何项目的普适实测值。这里不要把 19.2ms 和 50ms 读成增量总时间更短。真实场景里增量的总耗时通常更高写屏障与分片都会加钱这组数字想说明的只是结构变化单帧峰值从 50ms 掉到 3ms 量级3 帧的冻结消失换成 7 帧里各多花几毫秒。七、增量失败与回退7.1 什么时候会回退增量标记本质上是边跑程序边做标记。如果程序改引用的速度超过 GC 推进的速度标记会一直无法收敛刚补上的脏区域又被改脏进度原地踏步。Boehm 不会让这个状态无限持续超过限制后回退fallback为一次性的全量 STW 收集在一个暂停里把整轮工作做完。回退是安全兜底不会导致崩溃但代价实打实这一轮同时付了若干帧的增量开销最后还得挨一记接近非增量水平的长暂停增量的收益在这一轮归零属于最差情况。7.2 什么模式容易触发回退两类行为叠加时最容易踩到高分配速率每帧产生大量临时对象标记期间堆持续膨胀标记追不上新分配的速度高频引用改写每帧重建大对象图、反复清空再填充集合、大量交换引用脏区域率居高不下。具体例子包括每帧用字符串拼接堆日志和协议、每帧反序列化一大坨 JSON 再立刻改写整棵树、没有池化的特效数据流。识别信号在 Profiler 里很清楚大部分帧的 GC 都很短却周期性冒出一帧很长的 GC.Collect通常就是回退。7.3 应对思路还是回到分配本身把分配速率降下来池化、复用、缓存结果把重建大对象图的重活挪到加载期或低频节点让战斗帧里的对象图保持稳定。增量 GC 解决的是暂停长度问题解决不了分配太多问题别指望一个开关打包解决两件事。八、不支持与受限的场景8.1 WebGL 不支持WebGL 平台不支持增量 GC。勾选了 Use Incremental GC 也不会生效构建跑的仍然是非增量路径。面向 Web 发布的项目卡顿治理只能靠减少分配与加载期收集指望分片是没戏的。8.2 Unity 6 的 CoreCLR 后端不支持Unity 6 引入的 CoreCLR 后端用的是 .NET 自带的 GC不走 Unity 这套 Boehm 增量机制Use Incremental GC 对它无效。项目迁到 CoreCLR 之后评估对象应该换成 .NET 的后台 GC 与 Server GC 配置本文的分析不适用。8.3 Editor 与 Player 的行为差异Editor 的帧结构和 Player 不一样编辑器自身的重绘、菜单、多窗口都会干扰帧节奏帧尾空闲时间的分布也完全不同。同一个项目在 Editor 里看到的切片长短、回退频率跟真机可能相差很远。所有关于增量 GC 的性能结论都必须以 Development Build 连真机 Profiler 的数据为准Editor 里的观察只用来确认开关生效。九、配置与 API9.1 开关路径启用入口在 Project Settings Player Other Settings Configuration Use Incremental GC编辑器里就是 Edit → Project Settings → Player → Other Settings → 勾选 Use Incremental GC。这是构建期配置勾上之后打包生效对业务代码完全透明不需要改任何调用。9.2 模式与 API运行时用GarbageCollector.GCMode查询与切换它有三个取值取值行为典型用途Incremental自动回收优先走增量路径默认正常运行期Manual不自动触发只响应显式调用自己按帧泵收集的框架Disabled关闭自动回收仅System.GC.Collect()显式触发极短的受控窗口三个核心 API 的分工GarbageCollector.CollectIncremental()推进一次增量收集返回值告诉你这轮是否已经完成。Manual 模式下由自己按帧调用相当于手动版的自动推进。System.GC.Collect()无视当前模式立刻做一次全量阻塞收集等于一次性付清。适合加载完成点、关卡切换这类非帧敏感时刻。GarbageCollector.isIncremental只读查询当前是否真的运行在增量模式受设置与平台共同决定适合启动时打日志确认WebGL 上它就是 false。9.3 加载期禁用 GC做法与风险一个常见技巧是进 loading 前把GCMode设为Disabled加载完再恢复避免收集器在加载过程中插入一次长暂停。收益明确风险也必须讲清楚禁用期间分配只增不减如果加载资源本身很大或者反复进出同一批加载堆可能在恢复之前就顶到内存上限低端机上直接分配失败更隐蔽的是恢复路径一旦加载流程抛异常或回调没走到GC 会一直关着后面越跑越危险。稳妥写法是三步禁用前先System.GC.Collect()腾出余量恢复放进finally或状态机的必经路径恢复后再收集一次清底。只在时长可控、分配量可估的短加载上用它把它当常驻配置是拿 OOM 换少掉几帧通常不划算。9.4 实际项目启用指南先排除不适用平台WebGL 与 CoreCLR 后端直接跳过勾选 Use Incremental GC同时确认 VSync 或Application.targetFrameRate已配置保证帧尾有空闲出 Development Build连 Profiler 跑最重的几个场景战斗、大场景加载、UI 堆叠记录启用前后的 P99 帧时间、最大帧时间、掉帧次数别只记平均值对照 Timeline 看 GC 段是否从长条变成碎段仍是长条就说明在回退回到第七节先把分配压下去只有短加载有需要时才引入 Manual 或 Disabled 模式并按 9.3 的清单检查恢复路径。勾上开关只是第一步真正的收益要拿真机数据验收别停在 Editor 里看一眼就宣布完工。十、性能对比与评估方法10.1 评估误区平均帧时间骗人评估增量 GC 最常见的错误是只看平均帧时间。增量天生会让平均值轻微变差写屏障拉长了每次引用写入标记摊到更多帧上总 CPU 也更高。拿启用前后的平均值做验收很容易得出没用甚至更慢的错误结论而真正的收益完全没被衡量。该盯的是分布的尾部P99 帧时间与最大单帧 GC 暂停这是增量直接优化的对象卡顿帧数量超过 16.7ms60FPS或 33.3ms30FPS的帧是否明显减少帧时间方差帧节奏是否更稳顺带盯总 CPU确认额外开销在可接受范围典型估算三到五成。可靠的评估姿势是同场景 A/B同一个存档、同一条路径启用前后各跑固定时长采集完整帧时间序列后比较分位数与卡顿帧计数而不是截图某一次表现。采样段要覆盖 GC 密集的时刻比如战斗中段和连续切场景否则测到的只是空闲期的假象。10.2 项目实测示例87ms 到 4ms某个项目的实测示例单项目数据非普适结论同一场景、同一堆规模下非增量模式全量 STW 标记的最大单次 GC 暂停为 87ms勾选 Use Incremental GC 后最大单次暂停降到 4ms。87ms 在 60FPS 下是连续 5 帧以上的冻结4ms 完全落在单帧预算内玩家反馈的周期性顿挫随之消失代价是该轮 GC 总耗时上升和第二节的权衡一致。非增量[ 87ms ] 单次长冻结连掉 5 帧 增量 [4ms][4ms][4ms][4ms] ... [4ms] 每帧一小段落在预算内10.3 Profiler 中如何验证四个观察点按顺序做CPU 模块的 Timeline找以GC.Collect命名的暂停段启用前是单帧一根长条启用后应是分散在多帧的小段仍频繁出现长条就是在回退帧时间尖峰与 VSync 的对齐关系确认切片是否吃在帧尾空闲里Memory 模块的 GC Alloc 曲线例如GC Allocated In Frame计数器注意增量 GC 不减少分配它只重排暂停所以这条曲线本该基本不变曲线在涨是分配问题另案处理想看更细的分片临时把incrementalTimeSliceNanoseconds调小让一轮收集跨更多帧对比前后的帧时间分布。十一、架构对比把四种放在一起对照。注意表里的 Mono SGen 是上游 Mono 运行时自带的收集器列出来只为划清界限Unity 的增量链路与它无关架构分代并发 / 增量压缩适用场景Mono SGen对照2 代Nursery Major可选并发标记支持上游 Mono 运行时的通用场景Boehm非增量无无全量 STW 标记不压缩Unity 传统全量回收路径Unity 增量 GCBoehm 时间分片无增量标记分帧推进不压缩Unity 项目压峰值暂停.NET Core GC3 代 LOH后台 GC支持服务器与 Unity 6 CoreCLR 后端读表有三个要点。第一Unity 增量 GC 与非增量 Boehm 是同一个收集器的两种运行模式差别只在标记怎么执行堆管理完全一致。第二两者都不分代、不压缩所以堆只涨不掉、碎片持续累积这类长期问题不在增量的职责范围内。第三要根治还是两条腿走代码层面把分配降下来、控制堆规模运行时层面必要时迁到 CoreCLR 后端把 GC 换成 .NET 那套分代加压缩的方案。总结Unity 增量 GC 的要点可以收成几句话。它是 Boehm GC 的增量运行模式Boehm 加时间分片Mono 与 IL2CPP 后端通用。不是 SGen也不是独立的新收集器关掉开关就是原生 Boehm 全量回收。机制是四阶段短暂 STW 初始标记、跨帧推进标记、短暂 STW 最终标记、一次性清扫。写屏障与脏位记录在增量期间兜住对象图一致性帧尾空闲时间是它的工作窗口incrementalTimeSliceNanoseconds是手动旋钮。代价与边界同样清楚总 CPU 典型估算高出三到五成引用写入密集的代码要多付屏障改写过快会回退成全量 STWWebGL 与 Unity 6 CoreCLR 后端用不上Editor 数据不能当验收依据。评估看 P99 与卡顿帧数量不看平均帧时间。启用只是一个开关的距离真正的长期收益来自把分配降下来让增量 GC 有完成一轮标记的喘息空间。代码示例检测与控制增量 GCusing UnityEngine; public static class IncrementalGCDemo { // 启动时确认状态WebGL 上 isIncremental 恒为 false public static void LogGCStatus() { Debug.Log($incremental{GarbageCollector.isIncremental}, $mode{GarbageCollector.GCMode}); } // 手动控制每片预算2ms只给 GC 留小缝隙 public static void SetTimeSlice(long nanoseconds) { GarbageCollector.incrementalTimeSliceNanoseconds nanoseconds; } // Manual 模式下由自己按帧泵收集 public static bool PumpCollectIncremental() { return GarbageCollector.CollectIncremental(); } // 加载收尾恢复自动增量模式再做一次全量阻塞收集清底 public static void FinishLoading() { GarbageCollector.GCMode GarbageCollector.Mode.Incremental; System.GC.Collect(); } }using Unity.Profiling; using UnityEngine; // Profiler 验证片段统计每帧 GC 分配配合 Timeline 观察 GC.Collect 段 public class GCAllocWatch : MonoBehaviour { ProfilerRecorder m_GcAlloc; void OnEnable() { m_GcAlloc ProfilerRecorder.StartNew( ProfilerCategory.Memory, GC Allocated In Frame); } void OnDisable() m_GcAlloc.Dispose(); void Update() { // 增量 GC 不改变分配量这里监控分配是否异常上涨 if (m_GcAlloc.Valid m_GcAlloc.LastValue 40 * 1024) Debug.LogWarning($GC alloc spike: {m_GcAlloc.LastValue / 1024} KB); } }