您想找什么?
Engine & platform

将 Unity 移植到 CoreCLR

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Oct 27, 2023|10 Min
将 Unity 移植到 CoreCLR
为方便起见,此网页已进行机器翻译。我们无法保证翻译内容的准确性或可靠性。如果您对翻译内容的准确性有疑问,请参阅此网页的官方英文版本。

我们仍在努力将最新的 .NET 技术带给 Unity 用户。作为领导这项工作的团队成员之一,我很高兴能与大家分享进一步的进展。部分工作涉及使现有的 Unity 代码与 .NET CoreCLR JIT 运行时协同工作,包括一个高性能、更先进、更高效的垃圾收集器 (GC)。

本博文介绍了我们最近为使 CoreCLR GC 与 Unity 引擎原生代码协同工作而做出的更改。我们将从高层次入手,然后介绍更多技术细节。

关于垃圾收集器的一些情况

C# 语言的内存分配由垃圾回收器管理。在需要分配内存时,分配内存的代码可以在不再使用内存时忽略内存。GC 稍后会过来回收这些内存,供其他代码使用。

Unity 目前使用的是Boehm GC,这是一种保守的、不移动的 GC。它会扫描所有线程栈(包括托管代码和本地代码),寻找要收集的托管对象,一旦分配了托管对象,该对象在内存中的位置就永远不会移动。

.NET使用CoreCLR GC,它是一种精确的移动GC。它仅在托管代码中跟踪已分配的对象,并在内存中移动这些对象以提高性能。这样,CoreCLR GC 就能以更少的开销工作,为您的游戏提供更好的性能特性。

这两种 GC 都非常出色,但它们对使用它们的代码提出了不同的要求。Unity 引擎和编辑器代码是根据 Boehm GC 的要求开发的,因此要使用 CoreCLR GC,我们需要对 Unity 代码进行一些修改,包括修改 Unity 编写的自定义 marshaling 工具 - 绑定生成器和代理生成器。

垃圾收集器到底是做什么的?

您可以把托管代码想象成城市里的一个家,街角有一家咖啡店,街边有一家杂货店。就叫它 "管理代码兰迪亚 "吧。对于开发商来说,这里是一个非常适合居住的地方。但有时,我们也想去 "原生代码荒原",在那里,C++ 代码可以找到它的自然栖息地。

描绘隐喻式垃圾回收器如何在 "托管代码兰迪亚 "和 "本地代码荒地 "之间管理代码的图画。每个元素都被围在一个红色虚线矩形内,垃圾回收器在两个元素之间移动。

在两地之间旅行时,您可以携带一些管理存储器,因为铁路元帅允许随身携带一个行李箱。在荒原上,您可能会想买点纪念品带回家。

GC 会尽职尽责地跟踪并回收你可能不再使用的内存,不管它在哪里,这一点非常方便。但 GC 还有很多工作要做。所有这些线程和调用堆栈很快就会累加起来。多次前往原生密码荒地之后,GC 的大部分时间都在追着你跑。

我们能一起工作吗?

将 Unity 引擎移植到 CoreCLR 的大部分工作都是让引擎代码与 GC 携手工作。

描绘隐喻式垃圾回收器如何在 "托管代码兰迪亚 "和 "本地代码荒地 "之间管理代码的图画。其中,托管代码 Landia 位于绿色虚线方框内,垃圾收集器位于绿色方框内。

GC 和沼泽铁路公司已经达成协议,不让任何受管理的记忆越过原生密码荒地。有了这些,GC 的工作就少了很多,从而提高了效率。CoreCLR GC 在这种模式下运行,能准确知道存在哪些对象,并且只处理托管代码。这样还能在内存中移动对象,提高效率。

我们如何确定界限?

有趣的图表和表情符号固然可爱,但我们需要在生产代码库中实际实施,而这个代码库已经发展了十多年,从托管代码到本地代码往返的次数多达数千次。

从系统设计的角度考虑,我们需要找到边界。Unity 有两个重要的内部界限:

通过一个名为绑定生成器的工具,从托管代码调用到本地代码(类似于p/invoke)。

通过一个名为代理生成器的工具,从本地代码调用托管代码(类似于 Mono 的运行时调用)。

这两种工具都会生成 C++ 和 IL 代码,作为铁路,在我们的两个世界之间交换内存。在过去的一年里,Unity 的开发人员一直在修改这两个代码生成器,以确保它们不会允许 GC 分配的对象越界泄漏,并在这种情况发生时提供有用的诊断。我们还发现一些代码试图勇敢地跨越托管/本地边界,我们将其转移到这些代码生成器中。

当然,这一切发生的同时,Unity 的其他数百名开发人员正在积极修改引擎代码,为用户提供新功能和错误修复。我们希望在火箭飞行时对其进行改装。为了更好地理解我们是如何逐步实现这一转变的,让我们深入探讨一下托管/原生边界的一个方面:系统对象

任何其他名称的 System.Object

.NET中GC分配的任何内存都必须与System.Object.Object类型的对象绑定。它是所有 .NET 类型的基类,因此往往是跨入本地代码的内存的焦点。Unity Engine C++ 代码使用 ScriptingObjectPtr 抽象来表示一个 System.Object.ObjectPtr 对象:

typedef MonoObject* ScriptingBackendNativeObjectPtr;

class ScriptingObjectPtr {
    public:
        ScriptingObjectPtr(ScriptingBackendNativeObjectPtr target) : m_Target(target) {}
    protected:
        ScriptingBackendNativeObjectPtr m_Target;
};

这就是托管内存在本地代码中的最终结果:ScriptingBackendNativeObjectPtr 是指向 GC 分配内存的指针。Unity 当前的 GC 会遍历本地代码中的所有调用栈,保守地查找可能是 ScriptingObjectPtr 的内存。如果我们能改变这些实例,使其不再是指向 GC 分配的内存的指针,那么我们就能降低 GC 的负担,最终改用速度更快的 CoreCLR GC。

三人行

我们不需要 ScriptingObjectPtr 只有一种表示方法,而是需要它有三种可能的表示方法之一:

GC 分配的指针(当前表示形式)

托管堆栈参考

System.Runtime.InteropServices.GCHandle

GC 分配的指针是消除所有 GC 不安全使用的临时步骤。它允许 ScriptingObjectPtr 继续按当前方式运行。我们打算在所有 Unity 代码都能安全使用 CoreCLR GC 后,再移除这个用例。

在值从托管对象传递到本地对象的情况下,托管堆栈引用是表示间接指向托管对象的有效方法。GC 分配的指针变量地址会传递给本地代码(而不是 GC 分配的指针本身)。这对 GC 是安全的,因为 Localization 本身不会被 GC 移动,而且托管对象会在托管代码的调用堆栈中保持存活。这种方法受到 CoreCLR 运行时使用的类似技术的启发。

GCHandle 可作为托管对象的强间接,确保该对象不会被 GC Collections。如果您在野外度假时,碰巧在管理代码 Landia 中留下了一些记忆,GC 就会知道您想保留这些记忆,直到您回来。这与托管堆栈引用情况类似,但需要明确的生命周期管理。构建和销毁 GCHandle 会产生额外的管理费用。这种开销意味着我们只想在绝对必要的情况下使用这种表示法。

这是通过一种新类型 ScriptingReferenceWrapper 实现的,它取代了 ScriptingBackendNativeObjectPtr。

struct ScriptingReferenceWrapper
{
    // Various constructors elided for brevity
    void* GetGCUnsafePtr() const;
    static ScriptingReferenceWrapper FromRawPtr(void* ptr);
private:
    // Assumption: all pointers are 8 byte aligned.
    // This leaves 2 bits for tracking.
    // One bit is already in use by GCHandle
    // Bits
    // 0 - reserved for GC Handles.
    // 1 - 0 - object reference
    //   - 1 - gc handle
    // 2 - 0 - this is a managed object pointer
    //   - 1 - this is a GCHandle or object reference

    // 0b00 - object pointer
    // 0b01 - object reference
    // 0b1_ - gc handle; lowest bit is implementation specific

    bool IsPointer() const { 
return (((uintptr_t)value) & 0b11) == 0b00; }
    bool IsRef() const { 
return (((uintptr_t)value) & 0b11) == 0b01; }
    bool IsHandle() const { 
return (((uintptr_t)value) & 0b10) == 0b10; }

    uintptr_t value;
};

我在这里删除了许多构造函数或赋值操作符--它们是用来对内部资源执行适当的生命周期管理的。

请注意该类型的大小--它只包含一个 uintptr_t 值,其大小与指针相同,这意味着 ScriptingReferenceWrapper 的大小与 ScriptingBackendNativeObjectPtr 相同。这样,我们就可以在不使用代码的情况下进行 1:1 的替换,同时 ScriptingObjectPtr 也能知道两者的区别。

C# 语言的内存分配由垃圾回收器管理。有了这一点,我们就可以重复使用该值的两个比特来指示使用三种表示法中的哪一种。然后,在我们过渡 Unity 代码时,GetGCUnsafePtr 和 FromRawPtr 方法为 GC 分配的指针表示法提供临时互操作性。

冲过终点线

在理想情况下,ScriptingObjectPtr 抽象是不必要的--托管内存永远不会出现在本地代码中。但在有些地方,允许这样做是有用的,因此我们希望在引擎中完成 GC 安全工作,保留托管堆栈引用和 GCHandle 情况,并完全删除 GC 分配指针情况。

这就是 GC 和代码生成器之间协议的作用所在。现在,所有三个子系统都能理解 ScriptingObjectPtr 的可能表示形式,我们的团队正在逐步替换引擎代码中的用法。我们可以在不需要的地方移除 ScriptingObjectPtr,在需要的地方使用最有效的表示法。只要每种用法都是从头到尾改变的,不同的表现形式就可以并存,火箭就能继续飞行。

有了完全 GC 安全的引擎,我们就可以启用 CoreCLR GC,并确保它只需要在托管代码 Landia 中寻找要回收的内存,这意味着它的工作量会大大减少,每一帧都会留出更多时间让你的代码执行。

有关 Unity 向 CoreCLR 过渡的更多信息,请访问我们的论坛或收听 Unite 2023,我们将在那里详细讨论 Unity 的产品路线图。您也可以通过@petersonjm1 直接在 X 上与我联系。作为Tech from the Trenches 系列的一部分,请务必关注其他 Unity 开发人员的新技术博客