运行时与 GC 的嵌入关系
篇章:03-架构篇 |阅读时间:约 45 分钟 |前置知识:了解运行时初始化
一、引言
1.1 一份双向契约
GC 从来不是一个可以独立启动的程序。它没有自己的 main 函数,没有权限直接遍历托管栈,也无法凭空判断"这个寄存器里存的是不是对象引用"。它依赖运行时供给的四样东西:根(Roots)、线程状态、GCInfo、安全点(Safepoint)。反过来,运行时也离不开 GC:new T()生成的分配代码最终要落到 GC 的分配入口,OutOfMemory 的判定来自 GC 的堆统计,连"这块内存要不要清零"也由 GC 说了算。
GC 向运行时回供两样核心服务:分配(allocation)与回收(reclamation)。运行时把托管堆的生杀大权交出去,换回一条"分配只需几条指令"的热路径。这份交换构成了所有托管运行时的地基。
1.2 耦合如何决定精确性与效率
这种双向依赖不是实现细节,它直接决定 GC 的两个核心指标。
精确性由元数据质量决定。JIT 或 AOT 生成的 GCInfo 精确到"每个安全点上哪些栈槽和寄存器持有引用",GC 就能只扫真引用,不误伤、不漏标。元数据缺失或过期,GC 就退化为保守扫描,把机器字一律当潜在引用,导致浮动垃圾和无法移动对象。类加载器漏注册一组静态字段,第一轮标记就会把活对象当垃圾回收,直接崩溃。
效率由接口厚度决定。分配热路径上多一次间接调用、安全点轮询多一条指令、写屏障多一个分支,乘以每秒数百万次的分配与回跳,就是可观的性能差。挂起线程的机制若低效,每次 STW 暂停都会被拉长。
1.3 本文视角
前面的章节分别解剖过 Mono(03-01)、Boehm(03-02)、SGen(03-03)、Unity 增量式 GC(03-04)和 .NET Core GC(03-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(执行引擎)接口后面,作为一个独立组件与执行引擎对话:执行引擎负责挂起线程、走栈、提供 GCInfo,GC 负责堆管理。GC 与 EE 之间的接口边界清晰,Workstation 与 Server 两种 GC 可以按进程配置切换。
三种形态的光谱很有意思:静态链接把耦合前移到构建期,换来零间接开销;接口抽象把耦合推迟到初始化期,换来后端可换;CLR 的组件化则把耦合做成双向协议,换来 GC 内部算法可以独立演进(从分代到 Background 到 Region 化,执行引擎几乎不用改)。
2.2 并排对照表
| 维度 | Mono 运行时 | IL2CPP(Unity) | .NET Core CLR |
|---|---|---|---|
| 嵌入形态 | 接口抽象层(策略模式) | 编译期静态链接 + API 包装 | 运行时可插拔组件 |
| 默认 GC | SGen(开源 Mono 自 2.8 起默认) | Boehm(静态链入) | Workstation / Server GC |
| Unity 中的实际情况 | Unity 后端默认 Boehm,支持增量模式 | Unity 默认 Boehm,支持增量模式 | 不适用(.NET 侧) |
| 接口位置 | GC 接口层:alloc / alloc0 / collect / register_root | il2cppGC API 包装 | GC ↔ EE/VM 接口 |
| GCInfo 来源 | JIT 发射的栈映射,精确扫描 | 编译期生成的元数据,混合精确/保守 | JIT 发射的 GCInfo,走帧依据 |
| 线程挂起 | 协作式安全点轮询 | 运行时实现 suspend / resume | 运行时挂起 + GCInfo 走帧 |
| 根注册 | 类加载时注册静态字段、线程创建注册栈 | 全局/静态根注册 + 原生区排除 | 静态根、句柄表、线程栈 |
| 写屏障 | 分代/增量时由编译器插入 | 增量模式下需要 | 分代 + 并发标记都需要 |
| 收集模式 | STW | STW / 增量(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 的前提是编译器为每个方法生成一张GCInfo(GC 图),回答一个问题:在方法内的每个安全点上,哪些栈槽和寄存器持有对象引用。
JIT 编译时,编译器在决定插入安全点的位置,记录此刻的引用分布:栈上哪些偏移是引用槽(root slot)、哪些是纯数据;哪些寄存器当前存引用、存的是哪个变量。这些信息被压成紧凑的位图或表项,附在方法的元数据里。方法越长、安全点越多,表越大,所以编译器会做合并与压缩,只记录差异。
5.2 收集器如何用 GCInfo 走帧
GC 挂起线程后,从栈顶当前帧开始,按帧指针或返回地址逐帧下钻。每到一帧,读出该帧的 GCInfo,按位图把栈槽里的引用逐个交给标记器;遇到被冻结的寄存器,从保存的寄存器上下文里取值。走完整条栈,再处理静态根、句柄表和堆内对象图。GCInfo 是走帧的说明书,没有它,收集器走到栈上只会看到一串裸机器字,分不清哪是引用哪是整数。
5.3 没有 GCInfo 会怎样
元数据缺失时只能保守回退:把栈槽里所有对齐的机器字都当潜在引用尝试标记。代价立刻显现:一个恰好等于堆地址的整数会把整棵子树钉住,造成浮动垃圾;保守扫描的对象无法移动,压缩式 GC 退化成非移动式;标记阶段要遍历的"引用"数量暴涨,暂停时间变长。保守扫描是保命手段,不是性能手段。
5.4 AOT(IL2CPP)的等价元数据
IL2CPP 走 AOT 路线,没有运行时 JIT,元数据的故事换了一种讲法:C# 代码先被编译成 C++,引用分布的信息要么在编译期固化成可扫描的布局,要么退化为保守扫描。IL2CPP 的策略是混合的:托管堆内的对象靠 Boehm 的描述符与分配类型区分是否含指针(GC_malloc与GC_malloc_atomic的分野,见 05-04),原生栈与原生区则通过显式的排除列表避免被误扫。没有运行时编译器,就没有运行时发射 GCInfo 的机会,精确性必须由构建期元数据和显式注册来赎回。
六、安全点与线程挂起
6.1 协作式轮询与抢占式挂起
GC 要扫描线程栈,前提是线程停在一个"栈状态已知"的位置。两种让线程停下来的方式:
- 协作式(cooperative safepoint poll):JIT 在生成代码里插入轮询指令,线程主动检查"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 GC,STW 短) │ ├─ 回收到内存 → 重试分配 │ └─ 仍不足 → 触发完整回收(major / full GC,STW 长) ├─ 回收到内存 → 重试分配 └─ 仍不足 → 扩堆失败 → 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 接口层的 alloc,IL2CPP 走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 后写入变快"的直接原因。
- 增量模式:标记过程跨帧推进,标记期间发生的引用修改必须被记录,否则会漏标。此时每次引用写都需要屏障配合(记卡表或 SATB),IL2CPP 尤其需要在增量模式下启用写屏障来保证正确性。
分代 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 三个运行时各自的接口层
- Mono:
MonoGCManager风格的函数指针表,最典型的策略模式。接口层同时承担参数转发,比如把运行时的配置翻译成 SGen 或 Boehm 各自认的初始化参数。 - IL2CPP:
il2cppGC API 包装层,把 Boehm 的GC_malloc/GC_gcollect等原生调用包成统一入口,同时补齐 Boehm 不提供的能力:可终结对象管理、精确/保守的分配类型区分、线程注册、增量模式的写屏障转发。 - CLR:GC 与 EE/VM 之间的双向接口。EE 为 GC 提供挂起线程、走帧、读 GCInfo 的服务,GC 为 EE 提供分配与回收;Workstation 与 Server GC 都实现同一套协议,进程启动时按配置选定。
11.3 为什么这层抽象如此重要
接口层带来三个直接收益:后端可换(Mono 在 SGen 与 Boehm 之间切换,IL2CPP 未来迁移 GC 后端时运行时代码不动)、测试可插(可以插入一个记录型 GC 做契约测试)、能力可探测(运行时能查询 GC 是否支持增量、是否支持压缩,从而决定要不要插入某种屏障)。抽象层的代价是每次分配多一次间接调用,但在慢路径上这点开销可以忽略,快路径的内联比对则完全绕过接口层。
十二、GC 与线程的交互对比
12.1 三种交互模型
| 维度 | Mono SGen | IL2CPP + Boehm | .NET Core CLR |
|---|---|---|---|
| 暂停模型 | 全量 STW,等所有线程到安全点 | STW,由运行时实现 suspend/resume | 短 STW(初始标记)+ 后台并发 |
| GC 线程 | 运行时 GC 线程执行标记清扫 | Boehm 内部线程 + 运行时挂起逻辑 | 专用后台 GC 线程持续工作 |
| 挂起方式 | 协作式安全点轮询 | 运行时挂起原生线程以补 Boehm 空缺 | 运行时挂起 + GCInfo 精确走帧 |
| 增量能力 | 单次 STW 内完成 | 增量模式分片推进(Unity) | Background 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 .NET:ETW 与 LTTng
.NET Core GC 在生命周期的关键节点发出事件:GC 开始(标记之前)、GC 结束(清扫之后)、对象分配、标记进度等。这些事件通过ETW(Windows)或LTTng(Linux)暴露,属于低开销的追加式日志,平时不产生可观测成本。
捕获方式很成熟:Windows 上用PerfView,跨平台用dotnet-trace(dotnet-trace collect --providers Microsoft-Windows-DotNETRuntime,示例见 02-07)。拿到的事件流可以还原每次 GC 的触发原因、代数、回收量与耗时,是定位"哪次 GC 拖了帧"的第一手证据。
13.2 Unity:ProfilerRecorder 与 GarbageCollector API
Unity 侧有两个出口:
- ProfilerRecorder(Unity 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 组件协议共同的底层思路。第二段代码则把第七章的"回退阶梯"和第十章的"终结队列"变成了可以直接打印出来的观测数据。