.NET 零开销抽象指南中,哪些能揭示最佳实践?
摘要:背景 2008 年前后的 Midori 项目试图构建一个以 .NET 为用户态基础的操作系统,在这个项目中有很多让 CLR 以及 C# 的类型系统向着适合系统编程的方向改进的探索,虽然项目最终没有面世,但是积累了很多的成果。近些年由于 .N
背景
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 中进行零开销的抽象。
基础设施
首先我们来通过以下的不完全介绍来熟悉一下部分基础设施。
ref、out、in 和 ref readonly
谈到 ref 和 out,相信大多数人都不会陌生,毕竟这是从 C# 1 开始就存在的东西。这其实就是内存安全的指针,允许我们在内存安全的前提之下,享受到指针的功能:
void Foo(ref int x)
{
x++;
}
int x = 3;
ref int y = ref x;
y = 4;
Console.WriteLine(x); // 4
Foo(ref y);
Console.WriteLine(x); // 5
而 out 则多用于传递函数的结果,非常类似 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 个字段的对象),就会发生比较大的拷贝开销,此时只需要利用只读引用的方法传递参数即可避免,提升程序的性能。
