.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 中进行零开销的抽象。

基础设施

首先我们来通过以下的不完全介绍来熟悉一下部分基础设施。

ref、out、in 和 ref readonly

谈到 refout,相信大多数人都不会陌生,毕竟这是从 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 个字段的对象),就会发生比较大的拷贝开销,此时只需要利用只读引用的方法传递参数即可避免,提升程序的性能。

从 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); // 4
x = 5;
Console.WriteLine(array[3]); // 5

否则表示接收值,与返回非引用没有区别:

int[] array = new[] { 1, 2, 3, 4, 5 };
int x = Foo(array);
Console.WriteLine(x); // 4
x = 5;
Console.WriteLine(array[3]); // 4

与 C/C++ 的指针不同的是,C# 中通过 ref 显式标记一个东西是否是引用,如果没有标记 ref,则一定不会是引用。

当然,配套而来的便是返回只读引用,确保返回的引用是不可修改的。与 ref 一样,ref readonly 也是可以作为变量来使用的:

ref readonly int Foo(int[] array)
{
    return ref array[3];
}

int[] array = new[] { 1, 2, 3, 4, 5 };
ref readonly int x = ref Foo(array);
x = 5; // 错误
ref readonly int y = ref array[1];
y = 3; // 错误

对比有无ref时内存中都做了些什么?

// 这里要了解编一下编译原理,事实上所有的变量的地址都是在编译阶段确定下来了的(虚拟地址),包括局部变量和指针变量本身,编译期局部变量的地址实质上是保存的一个相对栈顶的偏移量,访问也是通过该偏移量;指针变量本身其实也是局部变量或者其它区域的变量,只是保存的数据是内存地址,因此指针变量的地址在编译期也是可以确定的。

// 当变量的地址都是确定了,通过变量名的访问就可以通过在编译时将变量名访问替换为内存地址访问,因为变量名较多,编译器会生成一个符号表来管理变量名与地址的映射关系。

// 变量名不会占用程序内存;那么多出来的变量名存储在何处? 变量名实际上是一个符号地址,在对程序编译和连接时由系统给每一个变量名分配一个内存地址或者偏移。在程序中从变量中取值,实际上是通过变量名找到相应的内存地址,从其存储单元中读取数据。编译后,变量名实际可以看成一个固定内存地址或偏移(abc.exe+0x0001),因此变量名不会占用内存,编译后就不存在了。

// 值类型的引用
func(int b){}
func(ref int c){}
int a = 10; // 栈空间分配a变量,地址是1111,中存储了10的二进制
func(a); // 新开辟栈帧,分配b变量,地址是2222,将变量a的值10复制给b(传值),这时2222地址中存储的是10的二进制,对b做修改不影响a的值
func(ref a); // 新开辟栈帧,分配c变量,由于其是引用传递(传址),其引用指向地址是1111,形参c直接使用了实参a的地址(c是a的别名,注意形参c不会额外开辟栈内存空间),这时对变量c的任何修改会影响变量a。如果a是一个复杂的struct,那么就不会发生值复制,如果还有返回a赋值给a,就可以减少两次值复制。

// 引用类型的引用
class abc{}
func(abc y){}
func(ref abc z){}
abc x = new abc(); // 在堆中分配托管内存,地址是1111存储abc实例,同时在栈空间中分配地址2222表示变量x,存储的是1111地址
func(x); // 新开辟栈帧,分配地址是3333表示y,将1111这个地址写入3333(传递的是对象引用的一个副本)(传值,只不过这个值是个地址),这样x,y两个变量的地址都指向1111地址,对y指向的对象的修改会影响到x指向的对象,但是如果执行 y = new abc() 对变量y本身修改,那么y指向的对象就和x不同了
func(ref x); // 新开辟栈帧,分配变量z,由于其是引用传递(传址),其引用指向地址2222,形参z直接使用了实参x的地址,这样z变量就是x变量的一个别名,对z的任何修改会影响到x,如果执行 z=new abc() 那么x也会指向新的实例,原来的实例就会被回收

ref struct

C# 7.2 引入了一种新的类型:ref struct。这种类型由编译器和运行时同时确保绝对不会被装箱,因此这种类型的实例的生命周期非常明确,它只可能在栈内存中,而不可能出现在堆内存中

Foo[] foos = new Foo[] { new(), new() }; // 错误

ref struct Foo
{
    public int X;
    public int Y;
}

借助 ref struct,我们便能在 ref struct 中保存引用,而无需担心 ref struct 的实例因为生命周期被意外延长而导致出现无效引用。

Span、ReadOnlySpan

从 .NET Core 2.1 开始,.NET 引入了 Span<T>ReadOnlySpan<T> 这两个类型来表示对一段连续内存的引用和只读引用。

Span<T>ReadOnlySpan<T> 都是 ref struct,因此他们绝对不可能被装箱,这确保了只要在他们自身的生命周期内,他们所引用的内存绝对都是有效的,因此借助这两个类型,我们可以代替指针来安全地操作任何连续内存

Span<int> x = new[] { 1, 2, 3, 4, 5 };
x[2] = 0;

void* ptr = NativeMemory.Alloc(1024);
Span<int> y = new Span<int>(ptr, 1024 / sizeof(int));
y[4] = 42;
NativeMemory.Free(ptr);

我们还可以在 foreach 中使用 refref readonly 来以引用的方式访问各成员:

Span<int> x = new[] { 1, 2, 3, 4, 5 };
foreach (ref int i in x) i++;
foreach (int i in x) Console.WriteLine(i); // 2 3 4 5 6

stackalloc

在 C# 中,除了 new 之外,我们还有一个关键字 stackalloc,允许我们在栈内存上分配数组:

Span<int> array = stackalloc[] { 1, 2, 3, 4, 5 };

这样我们就成功在栈上分配出了一个数组,这个数组的生命周期就是所在代码块的生命周期。

ref field

我们已经能够在局部变量中使用 refref readonly 了,自然,我们就想要在字段中也使用这些东西。因此我们在 C# 11 中迎来了 refref readonly 字段。

字段的生命周期与包含该字段的类型的实例相同,因此,为了确保安全,refref readonly 必须在 ref struct 中定义,这样才能确保这些字段引用的东西一定是有效的:

int x = 1;

Foo foo = new Foo(ref x);
foo.X = 2;
Console.WriteLine(x); // 2

Bar bar = new Bar { X = ref foo.X };
x = 3;
Console.WriteLine(bar.X); // 3
bar.X = 4; // 错误

ref struct Foo
{
    public ref int X;

    public Foo(ref int x)
    {
        X = ref x;
    }
}

ref struct Bar
{
    public ref readonly int X;
}

当然,上面的 Bar 里我们展示了对只读内容的引用,但是字段本身也可以是只读的,于是我们就还有:

ref struct Bar
{
    public ref int X; // 引用可变内容的可变字段
    public ref readonly int Y; // 引用只读内容的可变字段
    public readonly ref int Z; // 引用可变内容的只读字段
    public readonly ref readonly int W; // 引用只读内容的只读字段
}

scoped 和 UnscopedRef

我们再看看上面这个例子的 Foo,这个 ref struct 中有接收引用作为参数的构造函数,这次我们不再在字段中保存引用:

Foo Test()
{
    Span<int> x = stackalloc[] { 1, 2, 3, 4, 5 };
    Foo foo = new Foo(ref x[0]); // 错误
    return foo;
}

ref struct Foo
{
    public Foo(ref int x)
    {
        x++;
    }
}

你会发现这时代码无法编译了。

因为 stackalloc 出来的东西仅在 Test 函数的生命周期内有效,但是我们有可能在 Foo 的构造函数中将 ref int x 这一引用存储到 Foo 的字段中,然后由于 Test 方法返回了 foo,这使得 foo 的生命周期被扩展到了调用 Test 函数的函数上,有可能导致本身应该在 Test 结束时就释放的 x[0] 的生命周期被延长,从而出现无效引用。因此编译器拒绝编译了。

你可能会好奇,编译器在理论上明明可以检测到底有没有实际的代码在字段中保存了引用,为什么还是直接报错了?这是因为,如果需要检测则需要实现复杂度极其高的过程分析,不仅会大幅拖慢编译速度,而且还存在很多无法静态处理的边缘情况。

那要怎么处理呢?这个时候 scoped 就出场了:

Foo Test()
{
    Span<int> x = stackalloc[] { 1, 2, 3, 4, 5 };
    Foo foo = new Foo(ref x[0]);
    return foo;
}

ref struct Foo
{
    public Foo(scoped ref int x)
    {
        x++;
    }
}

我们只需要在 ref 前加一个 scoped,显式标注出 ref int x 的生命周期不会超出该函数,这样我们就能通过编译了。

此时,如果我们试图在字段中保存这个引用的话,编译器则会有效的指出错误:

ref struct Foo
{
    public ref int X;
    public Foo(scoped ref int x)
    {
        X = ref x; // 错误
    }
}

同样的,我们还可以在局部变量中配合 ref 或者 ref readonly 使用 scoped

Span<int> a = stackalloc[] { 1, 2, 3, 4, 5 };
scoped ref int x = ref a[0];
scoped ref readonly int y = ref a[1];
foreach (scoped ref int i in a) i++;
foreach (scoped ref readonly int i in a) Console.WriteLine(i); // 2 3 4 5 6
x++;
Console.WriteLine(a[0]); // 3
a[1]++;
Console.WriteLine(y); // 4

当然,上面这个例子中即使不加 scoped,也是默认 scoped 的,这里标出来只是为了演示,实际上与下面的代码等价:

Span<int> a = stackalloc[] { 1, 2, 3, 4, 5 };
ref int x = ref a[0];
ref readonly int y = ref a[1];
foreach (ref int i in a) i++;
foreach (ref readonly int i in a) Console.WriteLine(i); // 2 3 4 5 6
x++;
Console.WriteLine(a[0]); // 3
a[1]++;
Console.WriteLine(y); // 4

对于 ref struct 而言,由于其自身就是一种可以保存引用的“类引用”类型,因此我们的 scoped 也可以用于 ref struct,表明该 ref struct 的生命周期就是当前函数:

Span<int> Foo(Span<int> s)
{
    return s;
}

Span<int> Bar(scoped Span<int> s)
{
    return s; // 错误
}

有时候我们希望在 struct 中返回 this 上成员的引用,但是由于 structthis 有着默认的 scoped 生命周期,因此此时无法通过编译。这个时候我们可以借助 [UnscopedRef] 来将 this 的生命周期从当前函数延长到调用函数上:

Foo foo = new Foo();
foo.RefX = 42;
Console.WriteLine(foo.X); // 42

struct Foo
{
    public int X;

    [UnscopedRef]
    public ref int RefX => ref X;
}

这对 out 也是同理的,因为 out 也是默认有 scoped 生命周期:

ref int Foo(out int i) 
{
    i = 42;
    return ref i; // 错误
}

但是我们同样可以添加 [UnscopedRef] 来扩展生命周期:

ref int Foo([UnscopedRef] out int i) 
{
    i = 42;
    return ref i;
}

Unsafe、Marshal、MemoryMarshal、CollectionsMarshal、NativeMemory 和 Buffer

在 .NET 中,我们有着非常多的工具函数,分布在 Unsafe.*Marshal.*MemoryMarshal.*CollectionsMarshal.*NativeMemory.*Buffer.* 中。利用这些工具函数,我们可以非常高效地在几乎不直接使用指针的情况下,操作各类内存、引用和数组、集合等等。当然,使用的前提是你有相关的知识并且明确知道你在干什么,不然很容易写出不安全的代码,毕竟这里面大多数 API 就是 unsafe 的。

  • System.Runtime.CompilerServices.Unsafe.*: 静态类,包含用于操作托管和非托管指针的通用低级别功能。
  • System.Runtime.InteropServices.MemoryMarshal.*: 静态类,内存编排相关类。提供与 Memory<T>ReadOnlyMemory<T>Span<T>ReadOnlySpan<T>互操作的方法。
  • System.Runtime.InteropServices.Marshal.*: 静态类,提供了一个方法集合,这些方法用于分配非托管内存、复制非托管内存块、将托管类型转换为非托管类型,此外还提供了在与非托管代码交互时使用的其他杂项方法。
  • System.Runtime.InteropServices.CollectionsMarshal.*:静态类,集合编排类。一种不安全的类,它提供一组方法来访问集合的基础数据表示形式。
  • System.Runtime.InteropServices.NativeMemory.*:静态类,此类包含主要用于管理本机内存(非托管)的方法,分配、使用、释放非托管内存。
  • System.Buffer.*:静态类,操作基元类型的数组,例如高效内存拷贝等。
  • System.Buffers.*:包含用于多种创建和管理内存缓冲区的类型,非常实用。

例如消除掉边界检查的访问:

void Foo(Span<int> s)
{
    Console.WriteLine(Unsafe.Add(ref MemoryMarshal.GetReference(s), 3));
}

Span<int> s = new[] { 1, 2, 3, 4, 5, 6 };
Foo(s); // 4

查看生成的代码验证:

G_M000_IG02:                ;; offset=0004H
       mov      rcx, bword ptr [rcx]
       mov      ecx, dword ptr [rcx+0CH]
       call     [System.Console:WriteLine(int)]

可以看到,边界检查确实被消灭了,对比直接访问的情况:

void Foo(Span<int> s)
{
    Console.WriteLine(s[3]);
}
G_M000_IG02:                ;; offset=0004H
       cmp      dword ptr [rcx+08H], 3 ; <-- range check
       jbe      SHORT G_M000_IG04
       mov      rcx, bword ptr [rcx]
       mov      ecx, dword ptr [rcx+0CH]
       call     [System.Console:WriteLine(int)]
       nop

G_M000_IG04:                ;; offset=001CH
       call     CORINFO_HELP_RNGCHKFAIL
       int3

再比如,直接获取字典中成员的引用:

Dictionary<int, int> dict = new()
{
    [1] = 7,
    [2] = 42
};

// 如果存在则获取引用,否则添加一个 default 进去然后再返回引用
ref int value = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, 3, out bool exists);
value++;
Console.WriteLine(exists); // false
Console.WriteLine(dict[3]); // 1

如此一来,我们便不需要先调用 ContainsKey 再操作,只需要一次查找即可完成我们需要的操作,而不是 ContainsKey 查找一次,后续操作再查找一次。

我们还可以用 Buffer.CopyMemory 来实现与 memcpy 等价的高效率数组拷贝;再有就是前文中出现过的 NativeMemory,借助此 API,我们可以手动分配非托管内存,并指定对齐方式、是否清零等参数。

显式布局、字段重叠和定长数组

C# 的 struct 允许我们利用 [StructLayout] 按字节手动指定内存布局,例如:

unsafe
{
    Console.WriteLine(sizeof(Foo)); // 10
}

[StructLayout(LayoutKind.Explicit, Pack = 1)]
struct Foo
{
    [FieldOffset(0)] public int X;
    [FieldOffset(4)] public float Y;
    [FieldOffset(0)] public long XY;
    [FieldOffset(8)] public byte Z;
    [FieldOffset(9)] public byte W;
}

上面的例子中我们将 XYXY 的内存重叠,并且利用 Pack 指定了 padding 行为,使得 Foo 的长度为 10 字节,而不是 12 字节。

我们还有定长数组:

Foo foo = new Foo();
foo.Color[1] = 42;

struct Foo
{
    public unsafe fixed int Array[4];
}

此时,我们便有一个长度固定为 4 的数组存在于 Foo 的字段中,占据 16 个字节的长度。

接口的虚静态方法

.NET 7 中我们迎来了接口的虚静态方法,这一特性加强了 C# 泛型的表达能力,使得我们可以更好地利用参数化多态来更高效地对代码进行抽象。

此前当遇到字符串时,如果我们想要编写一个方法来对字符串进行解析,得到我们想要的类型的话,要么需要针对各种重载都编写一份,要么写成泛型方法,然后再在里面判断类型。两种方法编写起来都非常的麻烦:

int ParseInt(string str);
long ParseLong(string str);
float ParseFloat(string str);
// ...

或者:

T Parse<T>(string str)
{
    if (typeof(T) == typeof(int)) return int.Parse(str);
    if (typeof(T) == typeof(long)) return long.Parse(str);
    if (typeof(T) == typeof(float)) return float.Parse(str);
    // ...
}

尽管 JIT 有能力在编译时消除掉多余的分支(因为 T 在编译时已知),编写起来仍然非常费劲,并且无法处理没有覆盖到的情况。

但现在我们只需要利用接口的虚静态方法,即可高效的对所有实现了 IParsable<T> 的类型实现这个 Parse 方法。.NET 标准库中已经内置了不少相关类型,例如 System.IParsable<T> 的定义如下:

public interface IParsable<TSelf> where TSelf : IParsable<TSelf>?
{
    abstract static TSelf Parse(string s, IFormatProvider? provider);
    abstract static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result);
}

那么,我们只需要编写一个:

T Parse<T>(string str) where T : IParsable<T>
{
    return T.Parse(str, null);
}

即可。

这样,哪怕是其他地方定义的类型,只要实现了 IParsable<T>,就能够传到这个方法中:

struct Point : IParsable<Point>
{
    public int X, Y;

    public static Point Parse(string s, IFormatProvider? provider) { ... }
    public static bool TryParse(string? s, IFormatProvider? provider, out Point result) { ... }
}

当然,既然是虚静态方法,那就意味着不仅仅可以是 abstract,更可以是 virtual 的,如此一来我们还可以提供自己的默认实现:

interface IFoo
{
    virtual static void Hello() => Console.WriteLine("hello");
}

Dispose 和 IDisposable

我们有时需要显式地手动控制资源释放,而不是一味地交给 GC 来进行处理,那么此时我们的老朋友 Dispose 就派上用场了。

对于 classstructrecord 而言,我们需要为其实现 IDisposable 接口,而对于 ref struct 而言,我们只需要暴露一个 public void Dispose()。这样一来,我们便可以用 using 来自动进行资源释放。

例如:

// 在 foo 的作用域结束时自动调用 foo.Dispose()
using Foo foo = new Foo();
// ...

// 显式指定 foo 的作用域
using (Foo foo = new Foo())
{
    // ...
}

struct Foo : IDisposable
{
    private void* memory;
    private bool disposed;

    public void Dispose()
    {
        if (disposed) return;
        disposed = true;
        NativeMemory.Free(memory);
    }
}

异常处理的编译优化

异常是个好东西,但是也会对效率造成影响。因为异常在代码中通常是不常见的,因为 JIT 在编译代码时,会将包含抛出异常的代码认定为冷块(即不会被怎么执行的代码块),这么一来会影响 inline 的决策:

void Foo()
{
    // ...
    throw new Exception();
}

例如上面这个 Foo 方法,就很难被 inline 掉。

但是,我们可以将异常拿走放到单独的方法中抛出,这么一来,抛异常的行为就被我们转换成了普通的函数调用行为,于是就不会影响对 Fooinline 优化,将冷块从 Foo 转移到了 Throw 中:

[DoesNotReturn] void Throw() => throw new Exception();

void Foo()
{
    // ...
    Throw();
}

考虑到目前 .NET 还没有 bottom typesunion types,当我们的 Foo 需要返回东西的时候,很显然上面的代码会因为不是所有路径都返回了东西而报错,此时我们只需要将 Throw 的返回值类型改成我们想返回的类型,或者干脆封装成泛型方法然后传入类型参数即可。因为 throw 在 C# 中隐含了不会返回的含义,编译器遇到 throw 时知道这个是不会返回的,也就不会因为 Throw 没有返回东西而报错:

[DoesNotReturn] int Throw1() => throw new Exception();
[DoesNotReturn] T Throw2<T>() => throw new Exception();

int Foo1()
{
    // ...
    return Throw1();
}

int Foo2()
{
    // ...
    return Throw2<int>();
}

指针和函数指针

指针相信大家都不陌生,像 C/C++ 中的指针那样,C# 中套一个 unsafe 就能直接用。唯一需要注意的地方是,由于 GC 可能会移动堆内存上的对象,所以在使用指针操作 GC 堆内存中的对象前,需要先使用 fixed 将其固定:

int[] array = new[] { 1, 2, 3, 4, 5 };
fixed (int* p = array)
{
    Console.WriteLine(*(p + 3)); // 4
}

当然,指针不仅仅局限于对象,函数也可以有函数指针:

delegate* managed<int, int, int> f = &Add;
Console.WriteLine(f(3, 4)); // 7
static int Add(int x, int y) => x + y;

函数指针也可以指向非托管方法,例如来自 C++ 库中、有着 cdecl 调用约定的函数:

delegate* unmanaged[Cdecl]<int, int, int> f = ...;

进一步我们还可以指定 SuppressGCTransition 来取消做互操作时 GC 上下文的切换来提高性能。当然这是危险的,只有当被调用的函数能够非常快完成时才能使用:

delegate* unmanaged[Cdecl, SuppressGCTransition]<int, int, int> f = ...;
SuppressGCTransition 同样可以用于 P/Invoke:

[DllImport(...), SuppressGCTransition]
static extern void Foo();

[LibraryImport(...), SuppressGCTransition]
static partial void Foo();

IntPtr、UIntPtr、nint 和 nuint

C# 中有两个通过数值方式表示的指针类型:IntPtrUIntPtr,分别是有符号和无符号的,并且长度等于当前进程的指针类型长度。由于长度与平台相关的特性,它也可以用来表示 native 数值,因此诞生了 nintnuint,底下分别是 IntPtrUIntPtr,类似 C++ 中的 ptrdiff_tsize_t 类型。

这么一来我们就可以方便地像使用其他的整数类型那样对 native 数值类型运算:

nint x = -100;
nuint y = 200;
Console.WriteLine(x + (nint)y); //100

当然,写成 IntPtrUIntPtr 也是没问题的:

IntPtr x = -100;
UIntPtr y = 200;
Console.WriteLine(x + (IntPtr)y); //100

SkipLocalsInit

SkipLocalsInit 可以跳过 .NET 默认的分配时自动清零行为,当我们知道自己要干什么的时候,使用 SkipLocalsInit 可以节省掉内存清零的开销:

[SkipLocalsInit]
void Foo1()
{
    Guid guid;
    unsafe
    {
        Console.WriteLine(*(Guid*)&guid);
    }
}

void Foo2()
{
    Guid guid;
    unsafe
    {
        Console.WriteLine(*(Guid*)&guid);
    }
}

Foo1(); // 一个不确定的 Guid
Foo2(); // 00000000-0000-0000-0000-000000000000

实际例子

熟悉完 .NET 中的部分基础设施,我们便可以来实际编写一些代码了。

非托管内存

在大型应用中,我们偶尔会用到超出 GC 管理能力范围的超大数组(> 4G),当然我们可以选择类似链表那样拼接多个数组,但除了这个方法外,我们还可以自行封装出一个处理非托管内存的结构来使用。另外,这种需求在游戏开发中也较为常见,例如需要将一段内存作为顶点缓冲区然后送到 GPU 进行处理,此时要求这段内存不能被移动。

那此时我们可以怎么做呢?

首先我们可以实现基本的存储、释放和访问功能:

public sealed class NativeBuffer<T> : IDisposable where T : unmanaged
{
    private unsafe T* pointer;
    public nuint Length { get; }

    public NativeBuffer(nuint length)
    {
        Length = length;
        unsafe
        {
            pointer = (T*)NativeMemory.Alloc(length);
        }
    }

    public NativeBuffer(Span<T> span) : this((nuint)span.Length)
    {
        unsafe
        {
            fixed (T* ptr = span)
            {
                Buffer.MemoryCopy(ptr, pointer, sizeof(T) * span.Length, sizeof(T) * span.Length);
            }
        }
    }

    [DoesNotReturn] private ref T ThrowOutOfRange() => throw new IndexOutOfRangeException();

    public ref T this[nuint index]
    {
        get
        {
            unsafe
            {
                return ref (index >= Length ? ref ThrowOutOfRange() : ref (*(pointer + index)));
            }
        }
    }

    public void Dispose()
    {
        unsafe
        {
            // 判断内存是否有效
            if (pointer != (T*)0)
            {
                NativeMemory.Free(pointer);
                pointer = (T*)0;
            }
        }
    }

    // 即使没有调用 Dispose 也可以在 GC 回收时释放资源
    ~NativeBuffer()
    {
        Dispose();
    }
}

如此一来,使用时只需要简单的:

NativeBuffer<int> buf = new(new[] { 1, 2, 3, 4, 5 });
Console.WriteLine(buf[3]); // 4
buf[2] = 9;
Console.WriteLine(buf[2]); // 9
// ...
buf.Dispose();

或者让它在作用域结束时自动释放:

using NativeBuffer<int> buf = new(new[] { 1, 2, 3, 4, 5 });

或者干脆不管了,等待 GC 回收时自动调用我们的编写的析构函数,这个时候就会从 ~NativeBuffer 调用 Dispose 方法。

紧接着,为了能够使用 foreach 进行迭代,我们还需要实现一个 Enumerator,但是为了提升效率并且支持引用,此时我们选择实现自己的 GetEnumerator

首先我们实现一个 NativeBufferEnumerator

public ref struct NativeBufferEnumerator
{
    private unsafe readonly ref T* pointer;
    private readonly nuint length;
    private ref T current;
    private nuint index;

    public ref T Current
    {
        get
        {
            unsafe
            {
                // 确保指向的内存仍然有效
                if (pointer == (T*)0)
                {
                    return ref Unsafe.NullRef<T>();
                }
                else return ref current;
            }
        }
    }

    public unsafe NativeBufferEnumerator(ref T* pointer, nuint length)
    {
        this.pointer = ref pointer;
        this.length = length;
        this.index = 0;
        this.current = ref Unsafe.NullRef<T>();
    }

    public bool MoveNext()
    {
        unsafe
        {
            // 确保没有越界并且指向的内存仍然有效
            if (index >= length || pointer == (T*)0)
            {
                return false;
            }

            if (Unsafe.IsNullRef(ref current)) current = ref *pointer;
            else current = ref Unsafe.Add(ref current, 1);
        }
        index++;
        return true;
    }
}

然后只需要让 NativeBuffer.GetEnumerator 方法返回我们的实现好的迭代器即可:

public NativeBufferEnumerator GetEnumerator()
{
    unsafe
    {
        return new(ref pointer, Length);
    }
}

从此,我们便可以轻松零分配地迭代我们的 NativeBuffer 了:

int[] buffer = new[] { 1, 2, 3, 4, 5 };
using NativeBuffer<int> nb = new(buffer);
foreach (int i in nb) Console.WriteLine(i); // 1 2 3 4 5
foreach (ref int i in nb) i++;
foreach (int i in nb) Console.WriteLine(i); // 2 3 4 5 6

并且由于我们的迭代器中保存着对 NativeBuffer.pointer 的引用,如果 NativeBuffer 被释放了,运行了一半的迭代器也能及时发现并终止迭代:

int[] buffer = new[] { 1, 2, 3, 4, 5 };
NativeBuffer<int> nb = new(buffer);
foreach (int i in nb)
{
    Console.WriteLine(i); // 1
    nb.Dispose();
}

结构化数据

我们经常会需要存储结构化数据,例如在进行图片处理时,我们经常需要保存颜色信息。这个颜色可能是直接从文件数据中读取得到的。那么此时我们便可以封装一个 Color 来代表颜色数据 RGBA:

[StructLayout(LayoutKind.Sequential)]
public struct Color : IEquatable<Color>
{
    public byte R, G, B, A;

    public Color(byte r, byte g, byte b, byte a = 0)
    {
        R = r;
        G = g;
        B = b;
        A = a;
    }

    public override int GetHashCode() => HashCode.Combine(R, G, B, A);
    public override string ToString() => $"Color {{ R = {R}, G = {G}, B = {B}, A = {A} }}";
    public override bool Equals(object? other) => other is Color color ? Equals(color) : false;
    public bool Equals(Color other) => (R, G, B, A) == (other.R, other.G, other.B, other.A);
}

这么一来我们就有能表示颜色数据的类型了。但是这么做还不够,我们需要能够和二进制数据或者字符串编写的颜色值相互转换,因此我们编写 SerializeDeserializeParse 方法来进行这样的事情:

[StructLayout(LayoutKind.Sequential)]
public struct Color : IParsable<Color>, IEquatable<Color>
{
    public static byte[] Serialize(Color color)
    {
        unsafe
        {
            byte[] buffer = new byte[sizeof(Color)];
            MemoryMarshal.Write(buffer, ref color);
            return buffer;
        }
    }

    public static Color Deserialize(ReadOnlySpan<byte> data)
    {
        return MemoryMarshal.Read<Color>(data);
    }

    [DoesNotReturn] private static void ThrowInvalid() => throw new InvalidDataException("Invalid color string.");

    public static Color Parse(string s, IFormatProvider? provider = null)
    {
        if (s.Length is not 7 and not 9 || (s.Length > 0 && s[0] != '#'))
        {
            ThrowInvalid();
        }

        return new()
        {
            R = byte.Parse(s[1..3], NumberStyles.HexNumber, provider),
            G = byte.Parse(s[3..5], NumberStyles.HexNumber, provider),
            B = byte.Parse(s[5..7], NumberStyles.HexNumber, provider),
            A = s.Length is 9 ? byte.Parse(s[7..9], NumberStyles.HexNumber, provider) : default
        };
    }

    public static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out Color result)
    {
        result = default;
        if (s?.Length is not 7 and not 9 || (s.Length > 0 && s[0] != '#'))
        {
            return false;
        }

        Color color = new Color();
        return byte.TryParse(s[1..3], NumberStyles.HexNumber, provider, out color.R)
            && byte.TryParse(s[3..5], NumberStyles.HexNumber, provider, out color.G)
            && byte.TryParse(s[5..7], NumberStyles.HexNumber, provider, out color.B)
            && (s.Length is 9 ? byte.TryParse(s[7..9], NumberStyles.HexNumber, provider, out color.A) : true);
    }
}

接下来,我们再实现一个 ColorView,允许以多种方式对 Color 进行访问和修改:

public ref struct ColorView
{
    private readonly ref Color color;

    public ColorView(ref Color color)
    {
        this.color = ref color;
    }

    [DoesNotReturn] private static ref byte ThrowOutOfRange() => throw new IndexOutOfRangeException();

    public ref byte R => ref color.R;
    public ref byte G => ref color.G;
    public ref byte B => ref color.B;
    public ref byte A => ref color.A;
    public ref uint Rgba => ref Unsafe.As<Color, uint>(ref color);
    public ref byte this[int index]
    {
        get
        {
            switch (index)
            {
                case 0:
                    return ref color.R;
                case 1:
                    return ref color.G;
                case 2:
                    return ref color.B;
                case 3:
                    return ref color.A;
                default:
                    return ref ThrowOutOfRange();
            }
        }
    }

    public ColorViewEnumerator GetEnumerator()
    {
        return new(this);
    }

    public ref struct ColorViewEnumerator
    {
        private readonly ColorView view;
        private int index;

        public ref byte Current => ref view[index];

        public ColorViewEnumerator(ColorView view)
        {
            this.index = -1;
            this.view = view;
        }

        public bool MoveNext()
        {
            if (index >= 3) return false;
            index++;
            return true;
        }
    }
}

然后我们给 Color 添加一个 CreateView() 方法即可:

public ColorView CreateView() => new(ref this);

如此一来,我们便能够轻松地通过不同视图来操作 Color 数据,并且一切抽象都是零开销的:

Console.WriteLine(Color.Parse("#FFEA23")); // Color { R = 255, G = 234, B = 35, A = 0 }

Color color = new(255, 128, 42, 137);
ColorView view = color.CreateView();

Console.WriteLine(color); // Color { R = 255, G = 128, B = 42, A = 137 }

view.R = 7;
view[3] = 28;
Console.WriteLine(color); // Color { R = 7, G = 128, B = 42, A = 28 }

view.Rgba = 3072;
Console.WriteLine(color); // Color { R = 0, G = 12, B = 0, A = 0 }

foreach (ref byte i in view) i++;
Console.WriteLine(color); // Color { R = 1, G = 13, B = 1, A = 1 }

后记

C# 是一门自动挡手动挡同时具备的语言,上限极高的同时下限也极低。可以看到上面的几个例子中,尽管封装所需要的代码较为复杂,但是到了使用的时候就如同一切的底层代码全都消失了一样,各种语法糖加持之下,不仅仅用起来非常的方便快捷,而且借助零开销抽象,代码的内存效率和运行效率都能达到 C++、Rust 的水平。此外,现在的 .NET 7 有了 NativeAOT 之后更是能直接编译到本机代码,运行时无依赖也完全不需要虚拟机,实现了与 C++、Rust 相同的应用形态。这些年来 .NET 在不同的平台、不同工作负载上均有着数一数二的运行效率表现的理由也是显而易见的。

而代码封装的脏活则是由各库的作者来完成的,大多数人在进行业务开发时,无需接触和关系这些底层的东西,甚至哪怕什么都不懂都可以轻松使用封装好的库,站在这些低开销甚至零开销的抽象基础之上来进行应用的构建。

以上便是对 .NET 中进行零开销抽象的一些简单介绍,在开发中的局部热点利用这些技巧能够大幅度提升运行效率和内存效率。

扩展阅读

.NET 零开销抽象指南
.NET高性能类及其优化技巧
编译原理 - 变量的内存地址和符号地址

此处评论已关闭