搜索 Unity

让CoreCLR垃圾收集器能安全处理字符串封送(marshaling)

2023年2月14日 类别 Engine & platform | 10 分 阅读
Making AnimationEvent safe for the CoreCLR garbage collector | Hero image
Making AnimationEvent safe for the CoreCLR garbage collector | Hero image
分享

Is this article helpful for you?

Thank you for your feedback!

在上一篇博文里我提到,我们正努力为您带来最新.NET技术。包括让Unity代码兼容微软的.NET CoreCLR JIT运行时,该运行时带有一款更为高级、高效的垃圾收集器(GC)。

这里,我将介绍我们怎样让托管/本地的数据封送对GC更安全。关于这些改进的背景,您可在上一篇AnimationEvent封送博文里了解。

从托管到原生(再回到托管)

快速回顾:Unity引擎代码是用C#(托管代码)和C++(原生代码)写成的。跨越托管到本地的界限即复杂又耗性能,但我们也可以从中发掘出提高性能的机会。

比如,以C# String(字符串)类型为例。就这么一串字符处理起来应该很简单吧?其实并不简单:字符串封送有许多有意思的潜藏问题。

Unity的“tooltip”属性会调用原生代码来读取或储存数据:

public static string tooltip { get { return Internal_GetTooltip();} set { Internal_SetTooltip(value); } }

Internal_GetTooltipInternal_SetTooltip在C++里的定义如下:

ScriptingStringPtr Internal_GetTooltip(); void Internal_SetTooltip(const core::string& value);

这里,ScriptingStringPtr指向了字符串对象,它的内存受.NET垃圾收集器管理,而core::string是Unity内部的C++字符串表达式(类似于std::string)。给已经看出问题的同学加分!

我的字符串不一定和你的一样

尽管听起来很怪,但现代编程语言有许多种字符串表达。在C#里,字符串以32位的整数表示,保存着串上的字符数,以及一列双字节、UTF16编码的字符。Unity的core::string则用机器位数(如当下常见的64位)的整数来记录字符数,并将字符保存为一个单字节、UTF8编码的数列。

不同的表达使得字符串无法相互“位转移”(blittable),因此须借助封送操作来在托管/原生界限来回输送数据。封送涉及到分配大小合适的数据缓冲字符信息的转换,保证所有位置的数值都能正确工作。

通常,C#开发者能依靠.NET运行时内置的p/invoke封送来处理这些细节信息。不过,Unity有一个定制的封送工具,称为“bindings generator”(绑定生成器)。

绑定连接

为了使用这个定制工具,Unity从托管到本地的函数调用会采用.NET运行时的一个特殊功能:internal call(内部调用,或icall)。icall是一种对函数指针的调用,它不会封送返回值或函数参数。原生的icall必须密切掌握托管处的参数布局和位置。尽管icall本质上并不安全,但它可以压榨出比默认p/invoke更多的性能。

绑定生成器会解析C#代码,查找extern方法作为icall发出。每次icall,它都会生成直接释放到托管代码的.NET Intermediate Language(IL),以及随后用于Unity构建的C++代码。Unity的代码库包含有约10000种icall,让绑定生成器在自动化和优化方面能提供不少价值。

更好的“tooltip”

没看出上方“tooltip”有什么问题?也没关系,这些问题确实有些不明显。首先,指向GC托管内存的原始指针(ScriptingStringPtr)并不兼容动态的CoreCLR GC。其次,“Internal_SetTooltip”接受的是UTF8字符串,但C#用的是UTF16字符串。那我们能否避开两种编码间的转换来提高效率呢?可以。

甩掉多余的工作

core::string并非Unity原生代码字符串的唯一表达方式。Unity还使用着UTF16String类型,这个32位的整数储存了字符数以及一些占两字节、UTF16编码的数值(就像C#的字符串表达式)。

当我们用上方的公开C# API设置tooltip时,会产生类似下方的原生代码:

static void Internal_SetTooltip(const core::string& value) { UTF16String str(value.c_str()); GUIState &cState = GetGUIState(); cState.m_OnGUIState.SetMouseTooltip(str); cState.m_OnGUIState.SetKeyTooltip(str); }

在引擎内部,core::string里的UTEF8字符串会被复制成UTF16表达式。我们可以直接修改方法签名来接收UTF16String

void Internal_SetTooltip(const UTF16String& str);

绑定生成器所生成的Microsoft Intermediate Language(MSIL)代码将C#字符串当作ReadOnlySpan,并暂时固定住这段内存,不让GC在调用时改动。为此,生成器会使用内置的Span类型封送支持。最后,自动生成的C++代码能让Span当成UTF16String使用。

这里我们还得考虑一种特殊的情况——Span只有空白或非空白两个状态。然而字符串可以为null或没有内容的non-null。于是,我们新建了一个特殊的ManagedSpanWrapper类型来处理null的 Span。最后生成的C# MSIL代码应该像这样:

private unsafe static void Internal_SetTooltip(string value) { ManagedSpanWrapper managedSpanWrapper; if (!StringMarshaller.TryMarshalEmptyOrNullString(value, ref managedSpanWrapper)) { fixed (char* begin = value.AsSpan()){ managedSpanWrapper = new ManagedSpanWrapper(begin, value.Length); Internal_SetTooltip_Injected(in managedSpanWrapper); } } else { Internal_SetTooltip_Injected(in managedSpanWrapper); } }

请GC代劳

我们以前提到过,CoreCLR GC可以解除原生代码的部分限制,提供出色的性能。具体来说,原生代码将无法访问GC托管的内存。由于C#的字符是托管到GC的,我们不能从Internal_GetTooltip那返回一个原始指针。我们在上方看到,Unity会在内部以UTF16字符串表示tooltip的数值。这与C#的字符串表达式相似,因此签名可以改成这样:

UTF16String Internal_GetTooltip();

这下,绑定生成器就能允许GC在C#里管理字符串内存,不必另写代码来阻止GC或保留某段内存,让GC可以完全自主。

应用

这只是字符串封送的一种方式,常见于Unity的基础代码。绑定生成器不仅需要处理字符串,还需要处理其他所有从托管传输到原生代码的类型。它也让我们这支小团队的工作能运用到庞大的Unity代码库里。

我们可以通过这些全面性的改动让Unity能安全运行CoreCLR GC并提升性能。

性能还是安全性?两者兼得。

CoreCLR运行时和GC实现了全面提升性能的承诺。微软为了.NET在此方面大量投入,我们也很高兴能将这些改进带给各位Unity用户们。

我们期望着发掘出现代.NET应用的全部性能,同时保证已有代码的安全和稳定。我们团队会继续把学到的技术应用到其他托管/原生界限的代码传输。

若想了解更多字符串封送和CoreCLR的提示,请来论坛找我们。或者,您也能随时到TwitterMastodon上直接联系我。同时,也请您继续关注其他Unity开发者所编写的 Tech from the Trenche系列博文

2023年2月14日 类别 Engine & platform | 10 分 阅读

Is this article helpful for you?

Thank you for your feedback!

相关文章