.NET 零开销抽象指南

背景2008 年前后的 Midori 项目试图构建一个以 .NET 为用户态基础的操作系统,在这个项目中有很多让 CLR 以及 C# 的类型系统向着适合系统编程的方向改进的探索,虽然项目最终没有面世,但是积累了很多的成果 。近些年由于 .NET 团队在高性能和零开销设施上的需要,从 2017 年开始,这些成果逐渐被加入 CLR 和 C# 中,从而能够让 .NET 团队将原先大量的 C++ 基础库函数用 C# 重写,不仅能减少互操作的开销,还允许 JIT 进行 inline 等优化 。
与常识可能不同,将原先 C++ 的函数重写成 C# 之后,带来的结果反而是大幅提升了运行效率 。例如 Visual Studio 2019 的 16.5 版本将原先 C++ 实现的查找与替换功能用 C# 重写之后,更是带来了超过 10 倍的性能提升,在十万多个文件中利用正则表达式查找字符串从原来的 4 分多钟减少只需要 20 多秒 。
目前已经到了 .NET 7 和 C# 11,我们已经能找到大量的相关设施,不过我们仍处在改进进程的中途 。
本文则利用目前为止已有的设施,讲讲如何在 .NET 中进行零开销的抽象 。
基础设施首先我们来通过以下的不完全介绍来熟悉一下部分基础设施 。
refoutinref readonly谈到 refout,相信大多数人都不会陌生,毕竟这是从 C# 1 开始就存在的东西 。这其实就是内存安全的指针,允许我们在内存安全的前提之下,享受到指针的功能:
void Foo(ref int x){x++;}int x = 3;ref int y = ref x;y = 4;Console.WriteLine(x); // 4Foo(ref y);Console.WriteLine(x); // 5out 则多用于传递函数的结果,非常类似 C/C++ 以及 COM 中返回调用是否成功,而实际数据则通过参数里的指针传出的方法:
bool TryGetValue(out int x){if (...){x = default;return false;}x = 42;return true;}if (TryGetValue(out int x)){Console.WriteLine(x);}in 则是在 C# 7 才引入的,相对于 ref 而言,in 提供了只读引用的功能 。通过 in 传入的参数会通过引用方式进行只读传递,类似 C++ 中的 const T*
为了提升 in 的易用性,C# 为其加入了隐式引用传递的功能,即调用时不需要在调用处写一个 in,编译器会自动为你创建局部变量并传递对该变量的引用:
void Foo(in Mat3x3 mat){mat.X13 = 4.2f; // 错误,因为只读引用不能修改}// 编译后会自动创建一个局部变量保存这个 new 出来的 Mat3x3// 然后调用函数时会传递对该局部变量的引用Foo(new() {}); struct Mat3x3{public float X11, X12, X13, X21, X22, X23, X31, X32, X33;}当然,我们也可以像 ref 那样使用 in,明确指出我们引用的是什么东西:
Mat3x3 x = ...;Foo(in x);struct 默认的参数传递行为是传递值的拷贝,当传递的对象较大时(一般指多于 4 个字段的对象),就会发生比较大的拷贝开销,此时只需要利用只读引用的方法传递参数即可避免,提升程序的性能 。
从 C# 7 开始,我们可以在方法中返回引用,例如:
ref int Foo(int[] array){return ref array[3];}调用该函数时,如果通过 ref 方式调用,则会接收到返回的引用:
int[] array = new[] { 1, 2, 3, 4, 5 };ref int x = ref Foo(array);Console.WriteLine(x); // 4x = 5;Console.WriteLine(array[3]); // 5否则表示接收值,与返回非引用没有区别:
int[] array = new[] { 1, 2, 3, 4, 5 };int x = Foo(array);Console.WriteLine(x); // 4x = 5;Console.WriteLine(array[3]); // 4与 C/C++ 的指针不同的是,C# 中通过 ref 显式标记一个东西是否是引用,如果没有标记

经验总结扩展阅读