为 Unity 用户带来最新的 .NET 技术一直是我们不断努力的一环。当前工作重点包括让现有 Unity 代码兼容 .NET CoreCLR JIT 运行时,以及一种高性能、更先进、更高效的垃圾回收器(Garbage Collector,简称:GC)。

本文介绍了最近为使 CoreCLR GC 与 Unity 引擎原生代码协同工作所做的改动。先从抽象概念讲起,再深挖技术上的更多细节。

 

CoreCLR GC

C# 语言里的内存分配是由 GC 来管理的。每当有内存需要分配,代码可以忽略那段内存的管理,即使那段内存不再使用。GC 稍后会帮助回收内存以供其他代码使用。

Unity 目前用的是 Boehm GC,一种不会挪动对象的保守型 GC。它会扫描所有线程堆栈(包括托管与原生代码),寻找要分配的托管对象,一旦分配了托管对象,该对象的位置将永远不会在内存中移动。

.NET 使用的 CoreCLR GC 则更为精确,可以挪动对象位置。它只会在托管代码中跟踪已分配的对象,并在内存中移动它们来提高性能。这使得 CoreCLR GC 能够以更少的开销工作,为游戏提供更好的性能特性。

两种 GC 对代码有不同要求。Unity 引擎和编辑器代码迄今都按照 Boehm GC 的要求开发,所以为了使用 CoreCLR GC,就需要对 Unity 代码进行一些更改,包括 Unity 编写的自定义的 marshaling 工具——绑定生成器(bindings generator)和代理生成器(proxy generator)。

 

什么是 GC?

你可以把托管代码看作一片住宅区,街的拐角处有家咖啡厅,街上还有家杂货店。我们可以把这片区域叫做“托管代码景苑”。这里是开发者们的理想住宅,不过有时候我们需要去“原生代码郊外”看一看自然栖息地里的C++代码。

在两地间往来时,你可以带一些托管内存,因为“marshaling 轨道” 允许自带行李。到了郊外,你可能想要带些纪念品回家。

为了提供方便,GC会尽职地跟踪并回收你不再使用的内存,不论它落在了哪儿。于是,线程和调用堆栈马上会越来越多。来回游览了许多次“原生代码郊外”后,GC大部分时间就只能追着你到处跑。

 

它们能协调工作吗?

把 Unity 引擎移植到 CoreCLR 最重的工作在于让引擎代码与 GC 协调运作。

GC 和 marshaling 轨道达成过协议,不让任何托管内存闯入“原生代码郊外”。以此为前提,GC 的工作量就能少很多,它的效率也会更高。CoreCLR GC 便是按这个模式运转的,它明确知道哪些对象存在,并且只处理托管代码,这也允许它在内存中移动对象以提高效率。

 

那我们如何划定边界线呢?

卡通图标和表情固然可爱,但把这些改动应用到一个演变了十多年的产品代码库里就不那么简单了,其中托管与原生代码间的来回有时可达上千次。

从系统设计的角度来看,我们必须划出分界线。Unity 自己有两条重要的内部边界线:

  1. 从托管代码调用到原生代码(类似于p/invoke),通过绑定生成器(Bindings Generator)

  2. 从原生代码到托管代码的调用(类似于 Mono 的运行时调用),通过代理生成器(Proxy Generator)

两样工具都会生成 C++ 与 IL(中间语言)代码充当轨道,在过去的一年里,Unity 的开发人员一直在修改这两个代码生成器,以保证 GC 分配好的对象不会发生越界泄露,并在越界发生时提供有效的诊断信息。我们还找到了很多试着独自跨越托管/原生边界线的代码,把它们重新安置在了相应的代码生成器里。

当然与此同时,Unity 的数百名其他开发者也在活跃地修改引擎代码,向用户交付新特性与 bug 修复。可以说我们是在改装半空中的火箭。为了更好地理解这段逐渐积累的转变,我们先深入了解下托管/原生边界的一个方面:System.Object。

 

ScriptingObjectPtr

任何由 GC 在 .NET 里分配的内存必须绑定到一个类型为 System.Object 的对象上。这是所有 .NET 类型的基类,也是内存突破到原生代码的重灾区。Unity 引擎的 C++ 代码用 ScriptingObjectPtr 抽象基类来表示System.Object

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 能采用以下三种形式之一:

1.GC 分配指针(目前的表达式)

2.托管堆栈引用

3.System.Runtime.InteropServices.GCHandle

GC 分配指针是消除不安全 GC 用法的临时措施,它让 ScriptingObjectPtr 仍能像原本那样运作。一旦所有 Unity 代码都能安全兼容 CoreCLR GC 后,我们打算把此类用例全部移除。

假如值是从托管传递至原生代码的,托管堆栈引用就能很高效地表示对一个托管对象的间接引用。GC 分配指针变量的地址会被传至原生代码(而不是 GC 分配的指针本身)。这对于 GC 是安全的,因为本地地址本身不会被 GC 移动,并且托管对象会一直存在于托管代码的调用堆栈上。该方法受 CoreCLR 运行时的类似技术启发而来。

GCHandle 的作用是对托管对象的强间接引用,保证对象不会被 GC 回收。倘若你在“郊外”度假时不巧落下了一部分内存在“托管代码景苑”,GC 就会在你回来前一直保留它。这种方法类似于托管堆栈引用,不过需要你亲力亲为地管理内存寿命。GCHandle 的创建和摧毁也会产生额外的开销。因此,我们只会在绝对必要的时候采用此种表达式。

这个 Handle 在实现时采用了新的 ScriptingReferenceWrapper 类型来替代 ScriptingBackendNativeObjectPtr。

struct ScriptingReferenceWrapper
{
    // 为显精简省掉了许多构造函数
    void* GetGCUnsafePtr() const;
    static ScriptingReferenceWrapper FromRawPtr(void* ptr);
private:
    // 假设:所有指针都有4个对齐的字节。
    // 则只留下2个位能用于跟踪。
    // 其中一个被GCHandle所占用
    // 各个位
    // 0 - 为GC Handles保留。
    // 1 - 0 - 对象引用
    //   - 1 - gc handle
    // 2 - 0 - 此为托管对象指针
    //   - 1 - 此为GCHandle或对象引用
    // 0b00 - 对象指针
    // 0b01 - 对象引用
    // 0b1_ - gc handle;最小位数由具体实现方式决定
    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 知道其中的区别。

这里的关键点在于注释里提到的 4 字节对齐要求。C# 语言里的内存分配由垃圾回收器管理。在此前提下,我们可重复使用该值的两个位来暗示三种表达的哪一种被使用。在我们转移 Unity 代码期间,GetGCUnsafePtr 和FromRawPtr 方法则会为 GC 分配指针表达式提供临时的互操作权限。

 

越过终点线

理想情况下,ScriptingObjectPtr 抽象层其实没有必要存在——因为托管内存绝对不会出现在原生代码里。不过目前来看它还是有用处的,我们打算完成引擎的 GC 安全工作,保留托管堆栈引用和 GCHandle 的用例,并最终完全移除 GC 分配指针。

GC 和代码生成器之间的协议在此时就会发挥作用。所有三种子系统都了解可能的 ScriptingObjectPtr 表达式后,我们团队就能逐渐替换掉引擎代码里的实例。在不必要时移除 ScriptingObjectPtr,替换成最为高效的表达式。只要每处修改应用到代码的两端,不同的表达式就能和谐共处。

借助完全 GC-safe 的引擎,我们可以启用 CoreCLR GC,并确保它只需要在托管代码 Landia 中查找要回收的内存,这意味着它将减少工作量,并为每帧代码的执行留出更多时间。

Logo

分享前沿Unity技术干货和开发经验,精彩的Unity活动和社区相关信息

更多推荐