03-06-架构篇-运行时与GC的嵌入关系

发布时间:2026/10/16 16:02:16

03-06-架构篇-运行时与GC的嵌入关系 运行时与 GC 的嵌入关系篇章03-架构篇 |阅读时间约 45 分钟 |前置知识了解运行时初始化一、引言1.1 一份双向契约GC 从来不是一个可以独立启动的程序。它没有自己的 main 函数没有权限直接遍历托管栈也无法凭空判断这个寄存器里存的是不是对象引用。它依赖运行时供给的四样东西根Roots、线程状态、GCInfo、安全点Safepoint。反过来运行时也离不开 GCnew T()生成的分配代码最终要落到 GC 的分配入口OutOfMemory 的判定来自 GC 的堆统计连这块内存要不要清零也由 GC 说了算。GC 向运行时回供两样核心服务分配allocation与回收reclamation。运行时把托管堆的生杀大权交出去换回一条分配只需几条指令的热路径。这份交换构成了所有托管运行时的地基。1.2 耦合如何决定精确性与效率这种双向依赖不是实现细节它直接决定 GC 的两个核心指标。精确性由元数据质量决定。JIT 或 AOT 生成的 GCInfo 精确到每个安全点上哪些栈槽和寄存器持有引用GC 就能只扫真引用不误伤、不漏标。元数据缺失或过期GC 就退化为保守扫描把机器字一律当潜在引用导致浮动垃圾和无法移动对象。类加载器漏注册一组静态字段第一轮标记就会把活对象当垃圾回收直接崩溃。效率由接口厚度决定。分配热路径上多一次间接调用、安全点轮询多一条指令、写屏障多一个分支乘以每秒数百万次的分配与回跳就是可观的性能差。挂起线程的机制若低效每次 STW 暂停都会被拉长。1.3 本文视角前面的章节分别解剖过 Mono03-01、Boehm03-02、SGen03-03、Unity 增量式 GC03-04和 .NET Core GC03-05。本章换一个角度不看某个 GC 内部怎么工作而是看GC 怎么被装进运行时并且把 Mono、IL2CPP、.NET Core CLR 三个运行时并排放在一起对照它们的嵌入形态、初始化顺序、元数据供给、挂起模型与事件出口。横向对照是本章的主线。二、三大运行时的 GC 嵌入对照2.1 三种嵌入形态三个运行时把 GC 装进系统的方式截然不同可以归纳为三种形态接口抽象层Mono。GC 是运行时内部的子系统运行时不直接调用某个具体 GC而是调用一组预先注册的函数指针。SGen 和 Boehm 各自实现这组接口运行时代码完全不感知背后是哪个回收器。这是策略模式在运行时设计中的典型应用。编译期静态链接IL2CPP。Boehm 以原生库的形式在构建期静态链进 il2cpp 可执行文件调用点被il2cpp的 GC API 包装一层。包装层的存在让 IL2CPP 的其余代码只面对统一的分配入口也为将来换后端留了缝。运行时可插拔组件.NET Core CLR。GC 挂在 VM/EE执行引擎接口后面作为一个独立组件与执行引擎对话执行引擎负责挂起线程、走栈、提供 GCInfoGC 负责堆管理。GC 与 EE 之间的接口边界清晰Workstation 与 Server 两种 GC 可以按进程配置切换。三种形态的光谱很有意思静态链接把耦合前移到构建期换来零间接开销接口抽象把耦合推迟到初始化期换来后端可换CLR 的组件化则把耦合做成双向协议换来 GC 内部算法可以独立演进从分代到 Background 到 Region 化执行引擎几乎不用改。2.2 并排对照表维度Mono 运行时IL2CPPUnity.NET Core CLR嵌入形态接口抽象层策略模式编译期静态链接 API 包装运行时可插拔组件默认 GCSGen开源 Mono 自 2.8 起默认Boehm静态链入Workstation / Server GCUnity 中的实际情况Unity 后端默认 Boehm支持增量模式Unity 默认 Boehm支持增量模式不适用.NET 侧接口位置GC 接口层alloc / alloc0 / collect / register_rootil2cppGC API 包装GC ↔ EE/VM 接口GCInfo 来源JIT 发射的栈映射精确扫描编译期生成的元数据混合精确/保守JIT 发射的 GCInfo走帧依据线程挂起协作式安全点轮询运行时实现 suspend / resume运行时挂起 GCInfo 走帧根注册类加载时注册静态字段、线程创建注册栈全局/静态根注册 原生区排除静态根、句柄表、线程栈写屏障分代/增量时由编译器插入增量模式下需要分代 并发标记都需要收集模式STWSTW / 增量Unity 增量模式分片STW 后台 GC 线程GC 线程运行时 GC 线程Boehm 内部线程 运行时挂起逻辑专用后台 GC 线程需要特别澄清一个常见误解Unity 并未内置 SGen。Unity 的 Mono 后端和 IL2CPP 后端默认都使用 Boehm并支持增量模式SGen 是开源 Mono 运行时的默认选择。把Mono SGen直接套到 Unity 上会得出错误的性能结论。2.3 形态差异带来的实际影响嵌入形态决定了换 GC的成本。Mono 换后端只需实现接口层的一组函数IL2CPP 换后端要动链接配置和包装层CLR 换 GC 只要新组件满足 EE 接口协议。也决定了排障路径Mono 的问题常出在接口契约根没注册、线程没挂起IL2CPP 的问题常出在原生边界原生区被误扫、静态根遗漏CLR 的问题常出在协议时序挂起与走帧的竞态。三、运行时初始化中的 GC 设置3.1 四步启动顺序运行时启动时按固定顺序完成初始化这个顺序不是随意排列的每一步都依赖前一步的产物运行时启动示意 │ ├─ 1. 线程管理器初始化 │ ├─ 创建主线程描述结构ThreadInfo │ ├─ 建立线程表 / TLS 槽 │ └─ 预留安全点轮询用的全局标志位 │ ├─ 2. GC 初始化 │ ├─ 读取 GC 配置模式、代数、堆上限 │ ├─ 申请初始堆段heap segment │ ├─ 创建 GC 工作线程 │ ├─ 建立挂起用的 mutex / condvar │ └─ 把当前主线程注册为可扫描对象 │ ├─ 3. 类加载器初始化 │ ├─ 加载核心类型System.Object 等 │ ├─ 为静态字段分配存储 │ └─ 把静态字段地址注册进 GC 根表 │ └─ 4. JIT / AOT 初始化 ├─ 装载编译管线 ├─ 准备每方法 GCInfo 表的存储结构 └─ 约定分配入口的调用方式mono_gc_alloc 一类的入口3.2 每一步分配了什么线程管理器先建数据结构因为它产出的是 GC 的扫描对象线程表记录了所有托管线程的栈范围TLS 槽存放每个线程的分配缓冲指针与安全点标志。没有这些GC 无从知道该挂起谁、该扫谁的栈。GC 初始化是四步里动作最多的读取配置决定堆布局申请初始堆段让后续分配有处可去创建 GC 工作线程承担标记与清扫建立 mutex 与 condvar 让挂起/恢复有同步原语可用。Mono 中mono_gc_init在此阶段读取MONO_GC_PARAMS、切分 Nursery、准备 Major 堆初始段细节见 03-01。类加载器加载核心类型为静态字段分配存储并把这些地址批量注册进 GC 根表。这一步产出的根会存活到类型卸载为止。JIT/AOT最后启动因为它编译出的分配代码必须能调到已经就绪的 GC 分配入口编译期产出的 GCInfo 也要有地方挂表。3.3 顺序颠倒会发生什么顺序是约束不是风格。几种典型的违规后果GC 在线程管理器之前没有线程表和 TLS第一次触发 GC 时挂起不了任何线程标记阶段扫描不到线程栈活对象被当成垃圾回收。这是最致命的一种。GC 在类加载器之后加载类型本身要分配内存但堆还没建立分配入口直接空指针。同时静态字段无处注册根表从一开始就是残缺的。JIT 在 GC 之前编译new T()时找不到分配入口或者编译器生成的安全点轮询没有全局标志位可查编译产物直接失效。线程管理器在 GC 之后主线程无法被注册为可扫描对象GC 第一次运行时漏扫当前线程的栈。一句话概括初始化顺序的本质是依赖拓扑。根、线程、堆、编译产物这四类资源必须按先有扫描对象再有扫描器再有扫描元数据的次序就位。四、GC Roots 注册4.1 根注册表运行时的各个子系统在生命周期的关键节点向 GC 注册根子系统Roots 来源注册方式注册/注销时机JIT 编译器方法表、GCInfo编译时注册方法栈描述信息方法首次编译 / 卸载类加载器静态字段地址类加载时注册所有静态变量地址类初始化 / 卸载线程管理器线程栈线程创建时注册栈底地址线程创建 / 退出AppDomain应用程序域根域创建时注册域创建 / 卸载4.2 每一类根的注册机制静态字段在类加载时注册。类加载器为静态字段分配存储后把这块存储的地址连同范围交给 GC。此后每次 GC这块内存都会被当作根扫描。类卸载时必须成对注销否则根表里残留已释放的地址下一次 GC 就是在扫野指针。register_root/deregister_root的成对性是接口契约里最容易被破坏的一条。栈根靠 JIT 栈映射不靠注册。线程栈上的局部变量不会逐个注册GC 通过线程注册拿到栈范围再用该方法的 GCInfo 反推这个栈帧的哪些位置是引用。注册的是栈边界解析的是元数据两者配合才构成完整栈根。线程栈在创建时注册。线程管理器创建线程时登记栈底与栈范围线程退出时注销。运行时还额外维护一个当前挂起线程列表GC 发起挂起时按表逐个通知谁先到安全点谁先停。域与程序集卸载要批量注销。AppDomain 或程序集卸载时它注册过的静态根、方法表、类型缓存必须一次性从根表移除。漏注销的后果与静态字段泄漏同类GC 反复扫描已失效的地址轻则拖慢标记重则误判引用。4.3 寄存器根与 callee-saved栈之外还有寄存器。对象引用可能正躺在某个通用寄存器里而 GC 挂起线程时必须把它也算作根。主流做法是依赖callee-saved 寄存器约定线程被挂起时运行时保存的上下文里包含全部 callee-saved 寄存器GC 直接扫描这份保存的上下文即可。调用者保存的寄存器则在安全点处已按编译器约定溢出到栈上由栈映射覆盖。这个约定解释了为什么安全点必须选在调用边界这类寄存器状态已知的位置。五、GCInfo 与精确扫描5.1 编译器如何发射每方法 GC 图精确 GC 的前提是编译器为每个方法生成一张GCInfoGC 图回答一个问题在方法内的每个安全点上哪些栈槽和寄存器持有对象引用。JIT 编译时编译器在决定插入安全点的位置记录此刻的引用分布栈上哪些偏移是引用槽root slot、哪些是纯数据哪些寄存器当前存引用、存的是哪个变量。这些信息被压成紧凑的位图或表项附在方法的元数据里。方法越长、安全点越多表越大所以编译器会做合并与压缩只记录差异。5.2 收集器如何用 GCInfo 走帧GC 挂起线程后从栈顶当前帧开始按帧指针或返回地址逐帧下钻。每到一帧读出该帧的 GCInfo按位图把栈槽里的引用逐个交给标记器遇到被冻结的寄存器从保存的寄存器上下文里取值。走完整条栈再处理静态根、句柄表和堆内对象图。GCInfo 是走帧的说明书没有它收集器走到栈上只会看到一串裸机器字分不清哪是引用哪是整数。5.3 没有 GCInfo 会怎样元数据缺失时只能保守回退把栈槽里所有对齐的机器字都当潜在引用尝试标记。代价立刻显现一个恰好等于堆地址的整数会把整棵子树钉住造成浮动垃圾保守扫描的对象无法移动压缩式 GC 退化成非移动式标记阶段要遍历的引用数量暴涨暂停时间变长。保守扫描是保命手段不是性能手段。5.4 AOTIL2CPP的等价元数据IL2CPP 走 AOT 路线没有运行时 JIT元数据的故事换了一种讲法C# 代码先被编译成 C引用分布的信息要么在编译期固化成可扫描的布局要么退化为保守扫描。IL2CPP 的策略是混合的托管堆内的对象靠 Boehm 的描述符与分配类型区分是否含指针GC_malloc与GC_malloc_atomic的分野见 05-04原生栈与原生区则通过显式的排除列表避免被误扫。没有运行时编译器就没有运行时发射 GCInfo 的机会精确性必须由构建期元数据和显式注册来赎回。六、安全点与线程挂起6.1 协作式轮询与抢占式挂起GC 要扫描线程栈前提是线程停在一个栈状态已知的位置。两种让线程停下来的方式协作式cooperative safepoint pollJIT 在生成代码里插入轮询指令线程主动检查GC 是否要求挂起是则自行停住。Mono 与 .NET Core 都以这种方式为主。抢占式preemptive suspension运行时直接向目标线程发信号或利用操作系统机制打断它强行保存其上下文。Boehm 在部分平台上依赖类似机制IL2CPP 则需要自己实现 suspend/resume 来补 Boehm 的空缺。协作式的优点是停下的位置可控、栈状态干净缺点是停不下来就没有安全点。抢占式反之能停住任何线程但要处理异步中断带来的状态一致性问题。6.2 安全点放置策略JIT/AOT 在以下位置插入安全点检查方法调用边界call / callvirt几乎任何控制流都会经过是最可靠的放置点也保证了 callee-saved 寄存器状态已知。循环回跳backward branch纯计算的长循环可能长时间不调用任何方法回跳处必须补一个轮询否则死循环会永远躲过 GC。选择性插入是编译器在代码膨胀与响应性之间的权衡。分配路径newobj / arr.new分配本身就是 GC 最关心的时机分配点天然就是安全点。6.3 长原生调用导致的 GC 卡死放置策略有一个结构性漏洞线程进入原生代码后不再执行任何托管轮询。如果一次原生调用网络 IO、文件读写、阻塞的系统调用迟迟不返回该线程就永远到不了安全点而 STW 的 GC 必须等所有线程都停下才能开始标记。GC 卡死时间线示意 ─────────────────────────────────────────────────────────────► 时间 GC 线程 [发起挂起]──[等待线程A]····························[线程A到位]──[标记/清扫]──[恢复] 线程 A [托管代码]──[进入原生 IO 阻塞]·····················[返回托管]──[下次轮询才停] ▲ 这段期间没有安全点GC 只能干等 线程 B [托管代码]──[到安全点立即停]──[等待 GC 完成]···························[恢复]这就是部分游戏突然卡住不动、几秒后恢复的根因之一某个线程在漫长的原生调用里没回到托管代码整次 GC 被它拖住所有托管线程跟着一起停。这个故障模式在真机上比在编辑器里更容易复现因为真机的 IO 与系统调用延迟更不可控。6.4 各运行时的缓解Mono / .NET Core 把安全点插得足够密调用边界 回跳 分配并保证原生调用边界P/Invoke 进出有明确的挂起协调点.NET Core 还提供只在分配点轮询的轻量模式来减少代码膨胀。IL2CPP 需要显式实现线程 suspend/resume配合 Boehm 完成 STW并把原生调用纳入挂起管理。Unity 的增量式 GC 则从另一个角度缓解把单次 STW 切成跨帧时间片每帧只停很短的时间即使某个安全点等待被拉长影响也被限制在单帧预算内见 03-04。七、GC 触发链路与触发路径全景7.1 从new T()到 STW 的完整链路把原稿里两条流程合并成一条权威链路标注每一步的分支去向托管代码分配 new T()示意 │ ▼ JIT/AOT 生成的分配序列 │ ├─ 线程本地缓冲TLAB还有空间 │ └─ 是 → bump pointer移动指针即完成 ← 99.9% 的分配停在这里 │ └─ 否 → 慢路径 ├─ 当前代预算还有余额 │ └─ 是 → 申请新缓冲 / 新堆段返回 └─ 否 → 请求 GC ├─ 触发轻量回收minor / nursery GCSTW 短 │ ├─ 回收到内存 → 重试分配 │ └─ 仍不足 → 触发完整回收major / full GCSTW 长 ├─ 回收到内存 → 重试分配 └─ 仍不足 → 扩堆失败 → OutOfMemoryException7.2 预算触发GC 不是等堆满了才跑真正决定何时触发的不是堆满而是预算budget。运行时为每一代保留一个目标存活量与增长阈值分配慢路径检查这一代还允许我长多少超了就请求回收。基于预算而非水位的触发有三个好处GC 有提前量不必等到分配失败才被动启动每次回收的目标量可控暂停时间可预测配合增量模式可以把预算拆成多帧完成。.NET Core 把这套预算做成了可配置的生存率目标GC 策略层里的可调参数SGen 按 Nursery 容量与晋升率推进Boehm 的增量模式按工作量分片推进。机制不同预算先行的原则一致。7.3 回退阶梯与 99.9% 快速路径上面的链路本质是一个回退阶梯fallback ladder快速路径 → 轻量回收 → 完整回收 → OOM每一级只在上一级失败时才触发。GC 设计的关键在于让 99.9% 的分配停在第一级慢路径与回收都是罕见事件。这里的 99.9% 是概念性说法示意实际比例取决于分配模式对象多为短命小对象时绝大多数分配确实只做一次指针加法频繁分配大对象或缓冲区时慢路径占比会明显上升。阶梯设计也解释了为什么GC 次数不等于GC 开销大量 Gen0/nursery 级别的轻量回收可能各只花不到 1ms而一次 full GC 才是帧预算杀手。看指标要看暂停分布不能只看次数。八、分配路径与运行时协作8.1 快路径与慢路径的分界分配的快路径是线程本地的每个托管线程持有一段线程本地分配缓冲TLAB分配就是比对上限、移动指针、返回地址两三条指令无锁无原子操作。慢路径才进入共享区申请新缓冲、从堆段划拨、必要时触发 GC。把 99% 的流量挡在快路径上是分配器吞吐量的根本来源。8.2 预算检查在生成代码里的位置关键问题在于检查预算、触发 GC这段逻辑放在哪答案是生成代码内部。JIT/AOT 编译new T()时不是无条件调用一个重量级分配函数而是先内联快路径的指针比对比对失败才跳到慢路径入口。慢路径入口才承担查预算、找段、请求 GC的完整流程。这种内联快路径 慢路径兜底的结构在三个运行时里高度一致差别只在慢路径落在哪个函数Mono 走 GC 接口层的 allocIL2CPP 走il2cppGC API 包装CLR 走 JIT 辅助的分配入口。8.3 谁清零、谁写对象头分配返回后还有两件收尾工作归属要分清清零由分配器负责。alloc0一类的入口或分配后紧跟的 memset保证对象字段为默认值。不支持自动清零的 GC如 Boehm 的GC_malloc默认不清零就必须由上层补这一步否则读到上一代残留数据。IL2CPP 对含指针与不含指针的分配走不同入口清零策略也随之分化。对象头由双方分工写入。分配器写入类型信息方法表指针、vtable让 GC 一拿到裸指针就知道对象多大、是否含指针GC 在标记阶段写入标记位、在压缩阶段更新转发指针。对象头是运行时与 GC 共享的数据结构格式必须在接口契约里固定下来。九、写屏障由谁插入9.1 编译器负责插入GC 负责定义语义写屏障write barrier不是 GC 运行时偷偷加上去的它由编译器在生成代码时插入。JIT 在编译引用写操作stfld、stelem、静态字段写时按当前 GC 模式决定是否追加屏障序列IL2CPP 则在生成 C 代码时把屏障调用一并写出由平台编译器编译进原生代码。GC 定义屏障的语义记卡表、记 SATB、增量更新编译器负责把它放到每一次引用写的后面。这个分工解释了为什么换 GC 模式要重新编译屏障是否存在、是哪种形态是编译期决定的代码形态不是运行时开关能凭空补出来的。9.2 模式差异Unity 非增量 vs 增量Unity 侧的对比最直观非增量模式Boehm 做全量 STW 收集不存在需要维护的增量标记不变式引用写入几乎不带屏障开销。这是关闭增量 GC 后写入变快的直接原因。增量模式标记过程跨帧推进标记期间发生的引用修改必须被记录否则会漏标。此时每次引用写都需要屏障配合记卡表或 SATBIL2CPP 尤其需要在增量模式下启用写屏障来保证正确性。分代 GC 也是同理只要存在老年代对象引用新生代对象的可能Minor GC 就必须靠卡表知道去哪找跨代引用屏障因此不可省。屏障的存在性由收集算法决定编译器只是执行者。屏障的具体语义与性能代价见 02-02 与 06-04。十、Finalizer 与 GC 的协作10.1 终结队列由谁跑带终结器Finalizer的对象被判定不可达时GC 不会直接回收它而是把它放进终结队列并保证本轮至少再活一次。队列由运行时的 GC 线程在收集完成后消费逐个调用终结器方法执行完毕后对象才在下一轮真正可回收。这个设计把回收和执行用户代码分在两个阶段GC 线程只负责搬运队列终结器在受控时机执行避免在标记/清扫的临界区里调用任意托管代码。顺序、复活resurrection、WaitForPendingFinalizers的语义等完整生命周期已在 02-05 展开本文不重复只点出嵌入关系这一层。10.2 运行时必须注册什么从嵌入视角看运行时要为 Finalizer 准备三样东西可终结对象列表GC 判定不可达时写入、终结队列与消费线程运行时提供GC 只投递、终结器方法的调用元数据通过方法表找到终结器入口。三者都属于运行时向 GC 提供的基础设施GC 不知道终结器长什么样只负责把这类对象从直接回收改道到先入队。IL2CPP 这一侧还需要WaitForPendingFinalizers一类的接口透传到 Boehm 的对应机制这正是 GC API 包装层存在的意义。十一、GC 接口层设计11.1 策略模式解耦三个运行时都用同一种手法把 GC 与运行时代码隔开定义一组接口让具体 GC 实现它们。Mono 把这组接口定义在mono/metadata/gc.h形态上是一个MonoGCManager风格的函数指针表alloc / alloc0 / collect / register_root / deregister_root / 挂起 / 恢复 / 统计SGen 与 Boehm 分别填充这张表结构细节见 03-01本文不重复代码。运行时其余代码只认这张表不认具体 GC。/* 简化的 GC 接口契约示意运行时只依赖这张表 */ typedef struct { void* (*alloc) (size_t size); /* 分配含指针对象 */ void* (*alloc0) (size_t size); /* 分配并清零 */ void (*collect) (int generation); /* 触发指定代回收 */ int (*register_root) (void* lo, void* hi); /* 注册根区间 */ void (*deregister_root) (void* lo, void* hi); /* 注销根区间 */ void (*suspend_all) (void); /* 挂起托管线程 */ void (*resume_all) (void); /* 恢复托管线程 */ } gc_ops_t;11.2 三个运行时各自的接口层MonoMonoGCManager风格的函数指针表最典型的策略模式。接口层同时承担参数转发比如把运行时的配置翻译成 SGen 或 Boehm 各自认的初始化参数。IL2CPPil2cppGC API 包装层把 Boehm 的GC_malloc/GC_gcollect等原生调用包成统一入口同时补齐 Boehm 不提供的能力可终结对象管理、精确/保守的分配类型区分、线程注册、增量模式的写屏障转发。CLRGC 与 EE/VM 之间的双向接口。EE 为 GC 提供挂起线程、走帧、读 GCInfo 的服务GC 为 EE 提供分配与回收Workstation 与 Server GC 都实现同一套协议进程启动时按配置选定。11.3 为什么这层抽象如此重要接口层带来三个直接收益后端可换Mono 在 SGen 与 Boehm 之间切换IL2CPP 未来迁移 GC 后端时运行时代码不动、测试可插可以插入一个记录型 GC 做契约测试、能力可探测运行时能查询 GC 是否支持增量、是否支持压缩从而决定要不要插入某种屏障。抽象层的代价是每次分配多一次间接调用但在慢路径上这点开销可以忽略快路径的内联比对则完全绕过接口层。十二、GC 与线程的交互对比12.1 三种交互模型维度Mono SGenIL2CPP Boehm.NET Core CLR暂停模型全量 STW等所有线程到安全点STW由运行时实现 suspend/resume短 STW初始标记 后台并发GC 线程运行时 GC 线程执行标记清扫Boehm 内部线程 运行时挂起逻辑专用后台 GC 线程持续工作挂起方式协作式安全点轮询运行时挂起原生线程以补 Boehm 空缺运行时挂起 GCInfo 精确走帧增量能力单次 STW 内完成增量模式分片推进UnityBackground GC 跨越多次 GC 周期长原生调用风险高必须等到安全点中依赖 suspend 实现质量中原生边界有协调点12.2 模型差异的来源Mono SGen 的世界里只有全停和全跑两种状态简单直接代价是暂停时间随堆大小线性增长。IL2CPP 面对的 Boehm 本身是 STW 导向的IL2CPP 必须自己实现线程挂起与恢复来满足 STW 的前置条件这是静态链接一个原生 GC 的隐性成本。.NET Core 则把暂停这件事做成了精细协议初始标记只停很短时间标记主体交给后台 GC 线程与应用并发应用线程几乎无感。对游戏开发者的实际含义是Mono/IL2CPP 更依赖分配少来避开 STW.NET Core 更依赖后台 GC来摊平暂停。Unity 的增量式 GC 则是把 Mono/IL2CPP 的 STW 拆成跨帧时间片试图同时拿到两边的好处。这条取舍链正是 03-04 与本章的衔接点。十三、GC 钩子和事件13.1 .NETETW 与 LTTng.NET Core GC 在生命周期的关键节点发出事件GC 开始标记之前、GC 结束清扫之后、对象分配、标记进度等。这些事件通过ETWWindows或LTTngLinux暴露属于低开销的追加式日志平时不产生可观测成本。捕获方式很成熟Windows 上用PerfView跨平台用dotnet-tracedotnet-trace collect --providers Microsoft-Windows-DotNETRuntime示例见 02-07。拿到的事件流可以还原每次 GC 的触发原因、代数、回收量与耗时是定位哪次 GC 拖了帧的第一手证据。13.2 UnityProfilerRecorder 与 GarbageCollector APIUnity 侧有两个出口ProfilerRecorderUnity 2020.1以极低开销读取 Profiler 内部计数器包括每帧 GC 分配量、堆大小、回收次数适合放进生产环境做持续监控用法见 12-02。UnityEngine.Scripting.GarbageCollectorAPI查询与控制增量收集状态例如读取增量时间片配置incrementalTimeSliceNanoseconds、切换 GC 模式用于在运行时探测当前是否处于增量模式。13.3 Mono 的日志出口Mono 提供 GC 的详细日志开关通过环境变量打开后可以看到每次回收的代数、耗时与堆变化适合在无法接入 Profiler 的环境里做粗粒度排查。三条出口的共同点是事件由 GC 产生通道由运行时提供这正是嵌入关系在可观测性上的延伸。总结回到那份双向契约。运行时向 GC 供给根、线程、GCInfo 和安全点GC 向运行时供给分配与回收两边通过接口层握手。这份契约的每一处细节都在决定 GC 的最终表现初始化顺序是依赖拓扑先有线程与根再有堆与编译产物颠倒任何一步都会让第一次 GC 直接失效。根注册必须成对且完整静态字段、线程栈、域表各自在生命周期的准确节点进出根表漏注册等于把活对象判死。GCInfo是精确扫描的说明书缺失就只能保守回退代价是浮动垃圾与不可移动。安全点是协作式挂起的落脚点放置策略覆盖调用边界、回跳与分配而长原生调用绕过安全点正是GC 卡住整个游戏的根因。触发链路是一条回退阶梯预算先行让 99.9% 的分配停在 bump pointer 的快速路径上。接口层用策略模式把具体 GC 藏在表后面换后端才不必重写运行时。三个运行时把同一份契约写出了三种风格Mono 用函数指针表做到最彻底的解耦IL2CPP 用静态链接加 API 包装换取零开销CLR 用双向组件协议换取 GC 算法的独立演进。理解了嵌入关系后续阅读任何一篇源码剖析都能立刻知道这段代码站在契约的哪一侧。代码示例GC 接口注册与事件观测下面两段代码分别对应本章的两条主线。第一段是嵌入式运行时注册 GC 接口的伪代码展示策略模式如何落地第二段是可直接运行的 C# 片段观测触发链路与事件。/* 运行时侧注册 GC 后端示意可编译风格的伪代码 */ #include stdio.h #include stdlib.h typedef struct { void* (*alloc) (size_t size); void (*collect)(void); int (*register_root)(void* lo, void* hi); } gc_ops_t; /* 后端 A一个最简的 bump 分配器 */ static char heap[1 20]; static char* bump heap; static void* bump_alloc(size_t n) { if (bump n heap sizeof(heap)) return NULL; void* p bump; bump n; return p; } static void noop_collect(void) {} static int noop_root(void* lo, void* hi) { (void)lo; (void)hi; return 1; } static gc_ops_t g_gc; /* 运行时只认这张表 */ /* 初始化期调用切换后端只需换一张表运行时代码零改动 */ int runtime_init_gc(const gc_ops_t* backend) { g_gc *backend; return g_gc.alloc g_gc.collect g_gc.register_root; } void* runtime_new(size_t size) { void* p g_gc.alloc(size); /* 分配快路径最终汇入接口层 */ if (!p) { g_gc.collect(); p g_gc.alloc(size); } /* 阶梯回退 */ return p; } int main(void) { gc_ops_t boehm_like { bump_alloc, noop_collect, noop_root }; if (!runtime_init_gc(boehm_like)) return 1; void* obj runtime_new(64); printf(allocated at %p, bump %p\n, obj, (void*)bump); return 0; }// 观测侧读取触发链路与回收事件Unity / .NET 通用思路 using System; using System.Runtime; public static class GcEmbeddingProbe { public static void Report() { // 堆与各代回收次数对应第七章的触发阶梯 long heap GC.GetTotalMemory(false); Console.WriteLine($Heap {heap / 1024 / 1024} MB); for (int gen 0; gen 2; gen) Console.WriteLine($ Gen{gen} collections {GC.CollectionCount(gen)}); // 运行时组件视角当前是 Server 还是 Workstation GC Console.WriteLine($Server GC {GCSettings.IsServerGC}); Console.WriteLine($Latency {GCSettings.LatencyMode}); // 倒逼一次完整回收观察回退阶梯的最后一级 GC.Collect(2, GCCollectionMode.Forced, blocking: true); GC.WaitForPendingFinalizers(); // 第十章等终结队列消费完 Console.WriteLine($After full GC {GC.GetTotalMemory(false) / 1024 / 1024} MB); } }第一段代码的要点在runtime_init_gc运行时全程只持有g_gc这一张表后端 A 换成后端 B 时runtime_new一行都不用改。这正是 Mono 接口层、IL2CPP 包装层、CLR 组件协议共同的底层思路。第二段代码则把第七章的回退阶梯和第十章的终结队列变成了可以直接打印出来的观测数据。
延伸阅读

更多相关文章

2026/10/16 16:02:16

.NET 11 - 体验一下OpenAPI关于File stream result新特性

1. 简单介绍 时光匆匆,.NET Conf 2026大会将会在2026年11月份举办(11/10-11/13),到时微软将会发布.NET11,这是一个STS版本(Standard Term Support), 微软提供两年支持。自.NET9以来,微软对OpenAPI做了很多变更。 在.NET11中&…

2026/10/16 17:18:13

OpenCV 2.4.13使用指南:老版本兼容、编译配置与避坑实战

简介:OpenCV 2.4.13.3 是计算机视觉领域经典库 OpenCV 的一个长期维护版本,在传统图像处理、特征提取与旧项目迁移中仍被频繁使用。这份 zip 压缩包提供该版本的核心运行与开发文件,整体大小约 90.16MB,适合需要离线获取库文件的开…

2026/10/16 17:18:13

Unity性能优化:彻底解决C#装箱拆箱导致的GC卡顿

1. 从一个诡异的帧率波动说起如果你在Unity项目里做过稍微复杂一点的UI系统、背包系统或者战斗结算面板,大概率遇到过一种很邪门的情况:明明DrawCall不高,Profiler里GC Alloc那一栏却每帧都在跳,数值不大,几十B到几百B…

2026/10/16 17:18:13

Axure验证码交互设计:状态机驱动的高保真原型实现

1. 为什么一个“验证码交互”值得专门做一篇 Axure 教程?很多人第一次在 Axure 里拖出一个文本框、加个按钮、写句“验证码已发送”,就以为交互完成了。我见过太多原型评审会上,产品经理指着高保真稿子问:“用户输错三次&#xff…

2026/10/16 17:18:13

多媒体应用39-922(超文本与Web技术)

超文本三要素:节点、链、____。超文本三层模型中间层是____。建立节点一般顺序的链是____链。HTML 超链接标签是____。超媒体=超文本+____。HTML 的中文全称是____。HTML5 新增音视频标签是____。XML 与 HTML 的区别:XML 是____数…

2026/10/16 17:18:13

MMD模型修改指南:PmxEditor与PmdEditor的骨骼权重与材质修复

简介:面向MikuMikuDance(MMD)模型制作与修改爱好者,该压缩包集中提供PmxEditor v254中文版与PmdEditor v139两款经典编辑器,全面覆盖.pmd与.pmx格式模型的顶点位置调整、骨骼结构修改、纹理导入导出、材质与表情管理等…

2026/10/16 5:33:43

Python调用Gemini Structured Outputs实现工单路由门禁

客服工单最怕的不是模型“答错一句话”,而是它给出一段看起来合理的说明,程序却从中猜错优先级。通俗做法是:要求模型只交 JSON(JavaScript Object Notation,轻量数据格式),再让代码验证它。Gem…

2026/10/16 4:03:48

Spring Boot超市进销存系统毕设实战:从需求拆解到答辩通关

最近带的一个学生项目组里,有A同学跑来问我:选什么毕设题目最稳妥,既能让评审老师觉得工作量够,又不会在答辩时被问到语无伦次。我第一反应就是推荐基于Spring Boot的超市仓库管理系统——也就是超市进销存系统。这个题目乍一看平…

2026/10/15 13:42:16

Flutter StatefulWidget 生命周期核心解析

很多刚开始接触 Flutter 的朋友,在看完一堆“Hello World”和基础组件之后,大概率都会撞上同一堵墙:StatefulWidget 里那堆 initState、build、dispose 方法,到底什么时候被调用?为什么顺序是那样?在里面到…

2026/10/16 0:01:11

Cephadm实战指南:Linux系统工程师的生产级Ceph集群部署与排障

简介:本资源是一份完整的Ceph分布式存储系统中文技术文档合集,面向Linux系统运维工程师、云平台架构师及存储方向开发者,帮助读者系统掌握Ceph集群的部署、配置与运维核心能力。文档覆盖对象存储(RGW)、块设备&#xf…

2026/10/16 0:01:11

ITK中SpatialObject几何体系解析:坐标系、变换与管道集成

1. 空间对象的定位:为什么 ITK 需要一套几何描述体系很多刚开始接触 ITK 的朋友,包括我自己,最初都是被它的图像滤波、配准、分割这些“正统”功能吸引进来的。等真正上手做点实际项目,比如把分割出来的器官边界做量化分析、给血管…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
☎咨询二维码 ☎ ↑