CoreCLR 之路(1):问题

原文:Unity Discussions
作者:Alex-Thibodeau(Unity Staff)
发布:2026 年 8 月 5 日
本文为原帖的中文翻译,技术名称保留英文。

序言

我现在就是“它真的发生了!”那个梗图里的状态。

不是开玩笑,真的如此。

CoreCLR 脚本后端的发布已经等待了很久,筹备了多年。终于将它交付出去,情绪十分复杂:既紧张又兴奋,中间还夹杂着如释重负。把整个引擎从 Mono 迁移到 CoreCLR,是一项规模庞大、范围广泛且技术上极其复杂的工作,几乎所有 Unity Engine 工程师都参与其中。此刻我知道,很多人和我有同样的感受:

终于,真的,发生了。

在首次发布与继续投入改进之间难得的喘息期,我们值得回头看看。有人建议,最好写一系列教育性文章,讨论我们解决过的一些有趣问题以及解决方法。出于教育目的,也因为开发者确实喜欢分享自己战胜难题的“战史”,这组深度技术文章就这样诞生了。

第一篇先讲问题本身:为什么必须放弃 Mono,CoreCLR 能带来什么,以及为什么更换运行时会变成持续数年的工作。作为系列的引言,我会先交代背景,不会深入太多细节;后续文章会由相关开发者分别进行更深入的技术剖析。

Mono 运行时

An image to describe post

众所周知,这项工作的核心就是让 Mono 运行时退出历史舞台。它已经老旧、脆弱,许多用户也已经超越了它曾经相当可观的能力。不过在讲 Mono 的弱点前,我想先回顾一下 Mono 对 Unity 有多么重要。

归根结底,我们写下的 C# 代码必须被转换成计算机可以执行的东西。如今 Unity 有四种方式:Mono、IL2CPP、Burst,以及现在的 CoreCLR。但在过去,我们只有 Mono,选择它有几个原因。

第一,它可以嵌入原生应用运行。虽然用户代码都用 C# 编写,但 Unity 本质上是用 C++ 写的原生应用。Mono 的原生嵌入 API 允许 Unity 加载、启动,更重要的是与一个运行在 Unity 自己内存分配体系中的 C# 运行时交互。这样,引擎开发者就能把昂贵的代码下沉到 C++ 中优化。

第二,Mono 是跨平台的。微软最初的 .NET Framework C# 运行时只支持 Windows,而 Mono 可以运行在 Unity 需要支持的各种操作系统上。

第三,Mono 支持 .NET Framework 的 AppDomain。这对 Unity 的核心体验至关重要:修改代码后无需反复构建并启动独立 Player。尽管进出 Play Mode 后来带来了不少痛苦,但多年间它为用户提供了巨大便利。Mono 能销毁 AppDomain,加载并重新 JIT 更新后的 AppDomain,而且仍在同一个运行时中运行,这是一项巨大优势。

最后,Mono 使用 Boehm-Demers-Weiser 垃圾回收器,通常简称 Boehm 或 BDWGC。它是保守式、非移动 GC。保守式 GC 假定任何指针大小的值,无论托管还是原生,都可能是对象的有效引用,因此 Unity 的 C++ 代码无需向 GC 注册所有对象,Boehm 扫描时仍能找到它们。非移动 GC 分配对象后不会在堆上移动它。这个特性非常有用,因为 Unity 大量原生代码可以假定对象在销毁前始终位于同一内存地址,底层所需的簿记工作也大幅减少。

Mono 的优雅老去

一切终究会结束。技术不断前进,游戏变得更复杂、更依赖引擎。多年来 Mono 一直是“虽小但能跑”的引擎,直到 IL2CPP 出现,为当时已经暴露的性能和平台支持问题提供了救生筏。

促使我们转向 CoreCLR 的“最后一根稻草”有很多,我个人会选这一件:微软在 2016 年收购 Xamarin,成为 Mono 的维护者。几年后,Mono 被重新定位为 .NET 5 的替代运行时并进入 dotnet/runtime 仓库,必须遵循 .NET Core 的 API 表面,而 .NET Core 不支持多 AppDomain。早在几年前,AppDomain 就已被排除在 .NET Core 之外。Legacy Mono 一直保留它,但趋势已经十分明显:Unity 建立 Play Mode 的那项功能,在所有人都转向的新运行时中没有未来。

An image to describe post

Boehm 的保守、非移动设计也带来了缺点:对于长期运行的进程,堆会逐渐碎片化,内存占用变大,其中相当一部分实际上无法使用。

Mono 的 JIT 虽然扩展性强、编译速度快,却跟不上 CoreCLR 的 RyuJIT。上游 Mono JIT 的任何修复,反向移植到 Unity 分支都需要大量工作。无论按哪种定义,Mono 都已经成为技术债务。

随着 Mono 官方支持结束,其 C# API 表面被限制在 .NET Framework 4.7 加 .NET Standard 2.1。后者极其重要,因为它提供了把现有 C# 代码从 Mono 迁移到 CoreCLR 所需的桥梁。

CoreCLR 能带来什么

An image to describe post

CoreCLR 是微软当前提供的现代、受支持的 C# 运行时,是一次巨大的时代跃迁。我们正在离开实际上已经被弃置的 2019 年技术,直接迈向 .NET 10。

现代 GC

最大的变化或许是 CoreCLR 的 GC:精确、分代、压缩。

精确 GC 知道所有托管分配的确切位置,因此可以移动并压缩 GC 堆。但它只了解托管分配,原生对象对它不可见。请记住这一点,后文会再次遇到这个问题。

分代 GC 大致会按对象年龄划分分配。对象存活越久,越可能成为长期对象,GC 就越不需要频繁检查它是否可以释放,因此每次标记时无需扫描整个托管堆。详见 .NET 垃圾回收基础

An image to describe post

压缩 GC 会移动内存中的存活对象,从而避免前面提到的内存碎片。

现代 JIT

CoreCLR 的 RyuJIT 为 Unity 带来了分层编译和 intrinsic 支持。与 Mono JIT 不同,RyuJIT 同时覆盖快速编译和优化输出,CoreCLR Player 已经展现的性能提升正来自这里。

现代 C# API 表面

在 Unity 使用 Mono 的这些年里,微软 .NET 团队一直在改进性能并加入新的高性能 API(例如 System.Text.Json)。迁移到 CoreCLR 后,我们将默认获得这些能力。

更好的诊断能力

CoreCLR 提供标准 .NET 调试器原生支持的混合模式调试。我们可以使用现代分析器,也可以使用完整的 dotnet-trace、dotnet-counters、dotnet-dump 工具组,替代 Mono 的定制工具。

持续的上游支持

微软仍在持续开发和改进 C# 实现。Unity 更新到上游最新版本时,就能获得这些改进,而无需投入大量内部资源。

好吧……直接换成 CoreCLR 不就行了?

这就是我把文章标题定为“问题”的原因。你可能以为我指的是 Mono,确实是,但不完全是。

从概念上说,把 Unity 迁移到新的脚本后端似乎很简单。我们引入 IL2CPP 时已经做过一次:加入 CoreCLR,改几个 DLL 加载名称,一切就绪。

并没有那么简单:

  • 嵌入 API 受限。 CoreCLR 可以托管,最初也源自 Silverlight 运行时,但它的托管 API 范围很窄。Mono 的嵌入 API 给了我们极大的控制权,而我们已经习惯了这种访问级别。因此必须自行构建嵌入层来恢复这些能力。
  • GC 现在会移动对象。 CoreCLR 带来了高效的 GC,这当然很好;但 Unity 大量引擎代码默认托管对象不会在内存中移动,这就成了问题。
  • Play Mode 失去了基础。 Unity 的 Play Mode 建立在 AppDomain 上,而 CoreCLR 不存在 AppDomain。移除编辑器中的 Play Mode 显然不可行,所以必须寻找替代方案。
  • 性能分析工具建立在 Mono 上。 Unity 优秀的内部分析工具高度依赖 Mono 原生嵌入 API,其中很多还是我们在 Mono 分支中自行添加的。所有这些都必须在 CoreCLR 上重新实现。
  • IL2CPP 也需要更新。 IL2CPP 是 Unity 支持众多平台的入口。CoreCLR 带来使用现代 API 的新类库实现,IL2CPP 需要进行大幅改造才能跟上。

默认性能

坦白说,Unity 正在全力冲刺。我们把一部分平时“正常工作时几乎不可见”的代码,变成 Unity 7 的支柱成就之一,这自然带来了更高期待。CoreCLR 在很多方面都显著优于 Mono,但它不是万能药。为了上线 CoreCLR,我们重写了大量曾经偏向稳定性而非速度的引擎代码。要交付令我们自豪、也符合大家期待的产品,就必须把“默认性能”放在优先位置。

代码变更后等待编辑器重新加载,不该让你有时间去冲一杯咖啡。

冷启动打开项目,不该变成一整夜的事情。

在深度调试时编辑器崩溃,不该成为常态。

不再需要依靠晦涩的“部落知识”技巧来手动管理内存。

还有更多目标。

未来几个月,上述每项挑战都会有一篇独立的深度技术文章。我希望你和我一样期待它们。

它真的发生了!