.NET 零开销抽象指南

打印 上一主题 下一主题

主题 896|帖子 896|积分 2688

背景

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 开始就存在的东西。这其实就是内存安全的指针,允许我们在内存安全的前提之下,享受到指针的功能:
  1. void Foo(ref int x)
  2. {
  3.     x++;
  4. }
  5. int x = 3;
  6. ref int y = ref x;
  7. y = 4;
  8. Console.WriteLine(x); // 4
  9. Foo(ref y);
  10. Console.WriteLine(x); // 5
复制代码
而 out 则多用于传递函数的结果,非常类似 C/C++ 以及 COM 中返回调用是否成功,而实际数据则通过参数里的指针传出的方法:
  1. bool TryGetValue(out int x)
  2. {
  3.     if (...)
  4.     {
  5.         x = default;
  6.         return false;
  7.     }
  8.    
  9.     x = 42;
  10.     return true;
  11. }
  12. if (TryGetValue(out int x))
  13. {
  14.     Console.WriteLine(x);
  15. }
复制代码
in 则是在 C# 7 才引入的,相对于 ref 而言,in 提供了只读引用的功能。通过 in 传入的参数会通过引用方式进行只读传递,类似 C++ 中的 const T*。
为了提升 in 的易用性,C# 为其加入了隐式引用传递的功能,即调用时不需要在调用处写一个 in,编译器会自动为你创建局部变量并传递对该变量的引用:
  1. void Foo(in Mat3x3 mat)
  2. {
  3.     mat.X13 = 4.2f; // 错误,因为只读引用不能修改
  4. }
  5. // 编译后会自动创建一个局部变量保存这个 new 出来的 Mat3x3
  6. // 然后调用函数时会传递对该局部变量的引用
  7. Foo(new() {  });
  8. struct Mat3x3
  9. {
  10.     public float X11, X12, X13, X21, X22, X23, X31, X32, X33;
  11. }
复制代码
当然,我们也可以像 ref 那样使用 in,明确指出我们引用的是什么东西:
  1. Mat3x3 x = ...;
  2. Foo(in x);
复制代码
struct 默认的参数传递行为是传递值的拷贝,当传递的对象较大时(一般指多于 4 个字段的对象),就会发生比较大的拷贝开销,此时只需要利用只读引用的方法传递参数即可避免,提升程序的性能。
从 C# 7 开始,我们可以在方法中返回引用,例如:
  1. ref int Foo(int[] array)
  2. {
  3.     return ref array[3];
  4. }
复制代码
调用该函数时,如果通过 ref 方式调用,则会接收到返回的引用:
  1. int[] array = new[] { 1, 2, 3, 4, 5 };
  2. ref int x = ref Foo(array);
  3. Console.WriteLine(x); // 4
  4. x = 5;
  5. Console.WriteLine(array[3]); // 5
复制代码
否则表示接收值,与返回非引用没有区别:
  1. int[] array = new[] { 1, 2, 3, 4, 5 };
  2. int x = Foo(array);
  3. Console.WriteLine(x); // 4
  4. x = 5;
  5. Console.WriteLine(array[3]); // 4
复制代码
与 C/C++ 的指针不同的是,C# 中通过 ref 显式标记一个东西是否是引用,如果没有标记 ref,则一定不会是引用。
当然,配套而来的便是返回只读引用,确保返回的引用是不可修改的。与 ref 一样,ref readonly 也是可以作为变量来使用的:
  1. ref readonly int Foo(int[] array)
  2. {
  3.     return ref array[3];
  4. }
  5. int[] array = new[] { 1, 2, 3, 4, 5 };
  6. ref readonly int x = ref Foo(array);
  7. x = 5; // 错误
  8. ref readonly int y = ref array[1];
  9. y = 3; // 错误
复制代码
ref struct

C# 7.2 引入了一种新的类型:ref struct。这种类型由编译器和运行时同时确保绝对不会被装箱,因此这种类型的实例的生命周期非常明确,它只可能在栈内存中,而不可能出现在堆内存中:
  1. Foo[] foos = new Foo[] { new(), new() }; // 错误
  2. ref struct Foo
  3. {
  4.     public int X;
  5.     public int Y;
  6. }
复制代码
借助 ref struct,我们便能在 ref struct 中保存引用,而无需担心 ref struct 的实例因为生命周期被意外延长而导致出现无效引用。
Span、ReadOnlySpan

从 .NET Core 2.1 开始,.NET 引入了 Span 和 ReadOnlySpan 这两个类型来表示对一段连续内存的引用和只读引用。
Span 和 ReadOnlySpan 都是 ref struct,因此他们绝对不可能被装箱,这确保了只要在他们自身的生命周期内,他们所引用的内存绝对都是有效的,因此借助这两个类型,我们可以代替指针来安全地操作任何连续内存。
  1. Span<int> x = new[] { 1, 2, 3, 4, 5 };
  2. x[2] = 0;
  3. void* ptr = NativeMemory.Alloc(1024);
  4. Span<int> y = new Span<int>(ptr, 1024 / sizeof(int));
  5. y[4] = 42;
  6. NativeMemory.Free(ptr);
复制代码
我们还可以在 foreach 中使用 ref 和 ref readonly 来以引用的方式访问各成员:
  1. Span<int> x = new[] { 1, 2, 3, 4, 5 };
  2. foreach (ref int i in x) i++;
  3. foreach (int i in x) Console.WriteLine(i); // 2 3 4 5 6
复制代码
stackalloc

在 C# 中,除了 new 之外,我们还有一个关键字 stackalloc,允许我们在栈内存上分配数组:
  1. Span<int> array = stackalloc[] { 1, 2, 3, 4, 5 };
复制代码
这样我们就成功在栈上分配出了一个数组,这个数组的生命周期就是所在代码块的生命周期。
ref field

我们已经能够在局部变量中使用 ref 和 ref readonly 了,自然,我们就想要在字段中也使用这些东西。因此我们在 C# 11 中迎来了 ref 和 ref readonly 字段。
字段的生命周期与包含该字段的类型的实例相同,因此,为了确保安全,ref 和 ref readonly 必须在 ref struct 中定义,这样才能确保这些字段引用的东西一定是有效的:
  1. int x = 1;
  2. Foo foo = new Foo(ref x);
  3. foo.X = 2;
  4. Console.WriteLine(x); // 2
  5. Bar bar = new Bar { X = ref foo.X };
  6. x = 3;
  7. Console.WriteLine(bar.X); // 3
  8. bar.X = 4; // 错误
  9. ref struct Foo
  10. {
  11.     public ref int X;
  12.    
  13.     public Foo(ref int x)
  14.     {
  15.         X = ref x;
  16.     }
  17. }
  18. ref struct Bar
  19. {
  20.     public ref readonly int X;
  21. }
复制代码
当然,上面的 Bar 里我们展示了对只读内容的引用,但是字段本身也可以是只读的,于是我们就还有:
  1. ref struct Bar
  2. {
  3.     public ref int X; // 引用可变内容的可变字段
  4.     public ref readonly int Y; // 引用只读内容的可变字段
  5.     public readonly ref int Z; // 引用可变内容的只读字段
  6.     public readonly ref readonly int W; // 引用只读内容的只读字段
  7. }
复制代码
scoped 和 UnscopedRef

我们再看看上面这个例子的 Foo,这个 ref struct 中有接收引用作为参数的构造函数,这次我们不再在字段中保存引用:
  1. Foo Test()
  2. {
  3.     Span<int> x = stackalloc[] { 1, 2, 3, 4, 5 };
  4.     Foo foo = new Foo(ref x[0]); // 错误
  5.     return foo;
  6. }
  7. ref struct Foo
  8. {
  9.     public Foo(ref int x)
  10.     {
  11.         x++;
  12.     }
  13. }
复制代码
你会发现这时代码无法编译了。
因为 stackalloc 出来的东西仅在 Test 函数的生命周期内有效,但是我们有可能在 Foo 的构造函数中将 ref int x 这一引用存储到 Foo 的字段中,然后由于 Test 方法返回了 foo,这使得 foo 的生命周期被扩展到了调用 Test 函数的函数上,有可能导致本身应该在 Test 结束时就释放的 x[0] 的生命周期被延长,从而出现无效引用。因此编译器拒绝编译了。
你可能会好奇,编译器在理论上明明可以检测到底有没有实际的代码在字段中保存了引用,为什么还是直接报错了?这是因为,如果需要检测则需要实现复杂度极其高的过程分析,不仅会大幅拖慢编译速度,而且还存在很多无法静态处理的边缘情况。
那要怎么处理呢?这个时候 scoped 就出场了:
  1. Foo Test()
  2. {
  3.     Span<int> x = stackalloc[] { 1, 2, 3, 4, 5 };
  4.     Foo foo = new Foo(ref x[0]);
  5.     return foo;
  6. }
  7. ref struct Foo
  8. {
  9.     public Foo(scoped ref int x)
  10.     {
  11.         x++;
  12.     }
  13. }
复制代码
我们只需要在 ref 前加一个 scoped,显式标注出 ref int x 的生命周期不会超出该函数,这样我们就能通过编译了。
此时,如果我们试图在字段中保存这个引用的话,编译器则会有效的指出错误:
  1. ref struct Foo
  2. {
  3.     public ref int X;
  4.     public Foo(scoped ref int x)
  5.     {
  6.         X = ref x; // 错误
  7.     }
  8. }
复制代码
同样的,我们还可以在局部变量中配合 ref 或者 ref readonly 使用 scoped:
  1. Span<int> a = stackalloc[] { 1, 2, 3, 4, 5 };
  2. scoped ref int x = ref a[0];
  3. scoped ref readonly int y = ref a[1];
  4. foreach (scoped ref int i in a) i++;
  5. foreach (scoped ref readonly int i in a) Console.WriteLine(i); // 2 3 4 5 6
  6. x++;
  7. Console.WriteLine(a[0]); // 3
  8. a[1]++;
  9. Console.WriteLine(y); // 4
复制代码
当然,上面这个例子中即使不加 scoped,也是默认 scoped 的,这里标出来只是为了演示,实际上与下面的代码等价:
  1. Span<int> a = stackalloc[] { 1, 2, 3, 4, 5 };
  2. ref int x = ref a[0];
  3. ref readonly int y = ref a[1];
  4. foreach (ref int i in a) i++;
  5. foreach (ref readonly int i in a) Console.WriteLine(i); // 2 3 4 5 6
  6. x++;
  7. Console.WriteLine(a[0]); // 3
  8. a[1]++;
  9. Console.WriteLine(y); // 4
复制代码
对于 ref struct 而言,由于其自身就是一种可以保存引用的“类引用”类型,因此我们的 scoped 也可以用于 ref struct,表明该 ref struct 的生命周期就是当前函数:
  1. Span<int> Foo(Span<int> s)
  2. {
  3.     return s;
  4. }
  5. Span<int> Bar(scoped Span<int> s)
  6. {
  7.     return s; // 错误
  8. }
复制代码
有时候我们希望在 struct 中返回 this 上成员的引用,但是由于 struct 的 this 有着默认的 scoped 生命周期,因此此时无法通过编译。这个时候我们可以借助 [UnscopedRef] 来将 this 的生命周期从当前函数延长到调用函数上:
  1. Foo foo = new Foo();
  2. foo.RefX = 42;
  3. Console.WriteLine(foo.X); // 42
  4. struct Foo
  5. {
  6.     public int X;
  7.     [UnscopedRef]
  8.     public ref int RefX => ref X;
  9. }
复制代码
这对 out 也是同理的,因为 out 也是默认有 scoped 生命周期:
  1. ref int Foo(out int i)
  2. {
  3.     i = 42;
  4.     return ref i; // 错误
  5. }
复制代码
但是我们同样可以添加 [UnscopedRef] 来扩展生命周期:
  1. ref int Foo([UnscopedRef] out int i)
  2. {
  3.     i = 42;
  4.     return ref i;
  5. }
复制代码
Unsafe、Marshal、MemoryMarshal、CollectionsMarshal、NativeMemory 和 Buffer

在 .NET 中,我们有着非常多的工具函数,分布在 Unsafe.*、Marshal.*、MemoryMarshal.*、CollectionsMarshal.*、NativeMemory.* 和 Buffer.* 中。利用这些工具函数,我们可以非常高效地在几乎不直接使用指针的情况下,操作各类内存、引用和数组、集合等等。当然,使用的前提是你有相关的知识并且明确知道你在干什么,不然很容易写出不安全的代码,毕竟这里面大多数 API 就是 unsafe 的。
例如消除掉边界检查的访问:
  1. void Foo(Span<int> s)
  2. {
  3.     Console.WriteLine(Unsafe.Add(ref MemoryMarshal.GetReference(s), 3));
  4. }
  5. Span<int> s = new[] { 1, 2, 3, 4, 5, 6 };
  6. Foo(s); // 4
复制代码
查看生成的代码验证:
  1. G_M000_IG02:                ;; offset=0004H
  2.        mov      rcx, bword ptr [rcx]
  3.        mov      ecx, dword ptr [rcx+0CH]
  4.        call     [System.Console:WriteLine(int)]
复制代码
可以看到,边界检查确实被消灭了,对比直接访问的情况:
  1. void Foo(Span<int> s)
  2. {
  3.     Console.WriteLine(s[3]);
  4. }
复制代码
  1. G_M000_IG02:                ;; offset=0004H
  2.        cmp      dword ptr [rcx+08H], 3 ; <-- range check
  3.        jbe      SHORT G_M000_IG04
  4.        mov      rcx, bword ptr [rcx]
  5.        mov      ecx, dword ptr [rcx+0CH]
  6.        call     [System.Console:WriteLine(int)]
  7.        nop
  8. G_M000_IG04:                ;; offset=001CH
  9.        call     CORINFO_HELP_RNGCHKFAIL
  10.        int3
复制代码
Dispose 和 IDisposable

我们有时需要显式地手动控制资源释放,而不是一味地交给 GC 来进行处理,那么此时我们的老朋友 Dispose 就派上用场了。
对于 class、struct 和 record 而言,我们需要为其实现 IDisposable 接口,而对于 ref struct 而言,我们只需要暴露一个 public void Dispose()。这样一来,我们便可以用 using 来自动进行资源释放。
例如:
  1. Dictionary<int, int> dict = new()
  2. {
  3.     [1] = 7,
  4.     [2] = 42
  5. };
  6. // 如果存在则获取引用,否则添加一个 default 进去然后再返回引用
  7. ref int value = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, 3, out bool exists);
  8. value++;
  9. Console.WriteLine(exists); // false
  10. Console.WriteLine(dict[3]); // 1
复制代码
异常处理的编译优化

异常是个好东西,但是也会对效率造成影响。因为异常在代码中通常是不常见的,因为 JIT 在编译代码时,会将包含抛出异常的代码认定为冷块(即不会被怎么执行的代码块),这么一来会影响 inline 的决策:
  1. unsafe
  2. {
  3.     Console.WriteLine(sizeof(Foo)); // 10
  4. }
  5. [StructLayout(LayoutKind.Explicit, Pack = 1)]
  6. struct Foo
  7. {
  8.     [FieldOffset(0)] public int X;
  9.     [FieldOffset(4)] public float Y;
  10.     [FieldOffset(0)] public long XY;
  11.     [FieldOffset(8)] public byte Z;
  12.     [FieldOffset(9)] public byte W;
  13. }
复制代码
例如上面这个 Foo 方法,就很难被 inline 掉。
但是,我们可以将异常拿走放到单独的方法中抛出,这么一来,抛异常的行为就被我们转换成了普通的函数调用行为,于是就不会影响对 Foo 的 inline 优化,将冷块从 Foo 转移到了 Throw 中:
  1. Foo foo = new Foo();
  2. foo.Color[1] = 42;
  3. struct Foo
  4. {
  5.     public unsafe fixed int Array[4];
  6. }
复制代码
考虑到目前 .NET 还没有 bottom types 和 union types,当我们的 Foo 需要返回东西的时候,很显然上面的代码会因为不是所有路径都返回了东西而报错,此时我们只需要将 Throw 的返回值类型改成我们想返回的类型,或者干脆封装成泛型方法然后传入类型参数即可。因为 throw 在 C# 中隐含了不会返回的含义,编译器遇到 throw 时知道这个是不会返回的,也就不会因为 Throw 没有返回东西而报错:
  1. int ParseInt(string str);
  2. long ParseLong(string str);
  3. float ParseFloat(string str);
  4. // ...
复制代码
指针和函数指针

指针相信大家都不陌生,像 C/C++ 中的指针那样,C# 中套一个 unsafe 就能直接用。唯一需要注意的地方是,由于 GC 可能会移动堆内存上的对象,所以在使用指针操作 GC 堆内存中的对象前,需要先使用 fixed 将其固定:
  1. T Parse<T>(string str)
  2. {
  3.     if (typeof(T) == typeof(int)) return int.Parse(str);
  4.     if (typeof(T) == typeof(long)) return long.Parse(str);
  5.     if (typeof(T) == typeof(float)) return float.Parse(str);
  6.     // ...
  7. }
复制代码
当然,指针不仅仅局限于对象,函数也可以有函数指针:
  1. public interface IParsable<TSelf> where TSelf : IParsable<TSelf>?
  2. {
  3.     abstract static TSelf Parse(string s, IFormatProvider? provider);
  4.     abstract static bool TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, [MaybeNullWhen(false)] out TSelf result);
  5. }
复制代码
函数指针也可以指向非托管方法,例如来自 C++ 库中、有着 cdecl 调用约定的函数:
  1. T Parse<T>(string str) where T : IParsable<T>
  2. {
  3.     return T.Parse(str, null);
  4. }
复制代码
进一步我们还可以指定 SuppressGCTransition 来取消做互操作时 GC 上下文的切换来提高性能。当然这是危险的,只有当被调用的函数能够非常快完成时才能使用:
  1. struct Point : IParsable<Point>
  2. {
  3.     public int X, Y;
  4.    
  5.     public static Point Parse(string s, IFormatProvider? provider) { ... }
  6.     public static bool TryParse(string? s, IFormatProvider? provider, out Point result) { ... }
  7. }
复制代码
SuppressGCTransition 同样可以用于 P/Invoke:
  1. interface IFoo
  2. {
  3.     virtual static void Hello() => Console.WriteLine("hello");
  4. }
复制代码
IntPtr、UIntPtr、nint 和 nuint

C# 中有两个通过数值方式表示的指针类型:IntPtr 和 UIntPtr,分别是有符号和无符号的,并且长度等于当前进程的指针类型长度。由于长度与平台相关的特性,它也可以用来表示 native 数值,因此诞生了 nint 和 nuint,底下分别是 IntPtr 和 UIntPtr,类似 C++ 中的 ptrdiff_t 和 size_t 类型。
这么一来我们就可以方便地像使用其他的整数类型那样对 native 数值类型运算:
  1. // 在 foo 的作用域结束时自动调用 foo.Dispose()
  2. using Foo foo = new Foo();
  3. // ...
  4. // 显式指定 foo 的作用域
  5. using (Foo foo = new Foo())
  6. {
  7.     // ...
  8. }
  9. struct Foo : IDisposable
  10. {
  11.     private void* memory;
  12.     private bool disposed;
  13.    
  14.     public void Dispose()
  15.     {
  16.         if (disposed) return;
  17.         disposed = true;
  18.         NativeMemory.Free(memory);
  19.     }
  20. }
复制代码
当然,写成 IntPtr 和 UIntPtr 也是没问题的:
  1. void Foo()
  2. {
  3.     // ...
  4.     throw new Exception();
  5. }
复制代码
SkipLocalsInit

SkipLocalsInit 可以跳过 .NET 默认的分配时自动清零行为,当我们知道自己要干什么的时候,使用 SkipLocalsInit 可以节省掉内存清零的开销:
  1. [DoesNotReturn] void Throw() => throw new Exception();
  2. void Foo()
  3. {
  4.     // ...
  5.     Throw();
  6. }
复制代码
实际例子

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

在大型应用中,我们偶尔会用到超出 GC 管理能力范围的超大数组(> 4G),当然我们可以选择类似链表那样拼接多个数组,但除了这个方法外,我们还可以自行封装出一个处理非托管内存的结构来使用。另外,这种需求在游戏开发中也较为常见,例如需要将一段内存作为顶点缓冲区然后送到 GPU 进行处理,此时要求这段内存不能被移动。
那此时我们可以怎么做呢?
首先我们可以实现基本的存储、释放和访问功能:
  1. [DoesNotReturn] int Throw1() => throw new Exception();
  2. [DoesNotReturn] T Throw2<T>() => throw new Exception();
  3. int Foo1()
  4. {
  5.     // ...
  6.     return Throw1();
  7. }
  8. int Foo2()
  9. {
  10.     // ...
  11.     return Throw2<int>();
  12. }
复制代码
如此一来,使用时只需要简单的:
  1. int[] array = new[] { 1, 2, 3, 4, 5 };
  2. fixed (int* p = array)
  3. {
  4.     Console.WriteLine(*(p + 3)); // 4
  5. }
复制代码
或者让它在作用域结束时自动释放:
  1. delegate* managed<int, int, int> f = &Add;
  2. Console.WriteLine(f(3, 4)); // 7
  3. static int Add(int x, int y) => x + y;
复制代码
或者干脆不管了,等待 GC 回收时自动调用我们的编写的析构函数,这个时候就会从 ~NativeBuffer 调用 Dispose 方法。
紧接着,为了能够使用 foreach 进行迭代,我们还需要实现一个 Enumerator,但是为了提升效率并且支持引用,此时我们选择实现自己的 GetEnumerator。
首先我们实现一个 NativeBufferEnumerator:
  1. delegate* unmanaged[Cdecl]<int, int, int> f = ...;
复制代码
然后只需要让 NativeBuffer.GetEnumerator 方法返回我们的实现好的迭代器即可:
  1. delegate* unmanaged[Cdecl, SuppressGCTransition]<int, int, int> f = ...;
复制代码
从此,我们便可以轻松零分配地迭代我们的 NativeBuffer 了:
  1. [DllImport(...), SuppressGCTransition]
  2. static extern void Foo();
  3. [LibraryImport(...), SuppressGCTransition]
  4. static partial void Foo();
复制代码
并且由于我们的迭代器中保存着对 NativeBuffer.pointer 的引用,如果 NativeBuffer 被释放了,运行了一半的迭代器也能及时发现并终止迭代:
  1. nint x = -100;
  2. nuint y = 200;
  3. Console.WriteLine(x + (nint)y); //100
复制代码
结构化数据

我们经常会需要存储结构化数据,例如在进行图片处理时,我们经常需要保存颜色信息。这个颜色可能是直接从文件数据中读取得到的。那么此时我们便可以封装一个 Color 来代表颜色数据 RGBA:
  1. IntPtr x = -100;
  2. UIntPtr y = 200;
  3. Console.WriteLine(x + (IntPtr)y); //100
复制代码
这么一来我们就有能表示颜色数据的类型了。但是这么做还不够,我们需要能够和二进制数据或者字符串编写的颜色值相互转换,因此我们编写 Serialize、Deserialize 和 Parse 方法来进行这样的事情:
  1. [SkipLocalsInit]
  2. void Foo1()
  3. {
  4.     Guid guid;
  5.     unsafe
  6.     {
  7.         Console.WriteLine(*(Guid*)&guid);
  8.     }
  9. }
  10. void Foo2()
  11. {
  12.     Guid guid;
  13.     unsafe
  14.     {
  15.         Console.WriteLine(*(Guid*)&guid);
  16.     }
  17. }
  18. Foo1(); // 一个不确定的 Guid
  19. Foo2(); // 00000000-0000-0000-0000-000000000000
复制代码
接下来,我们再实现一个 ColorView,允许以多种方式对 Color 进行访问和修改:
  1. public sealed class NativeBuffer<T> : IDisposable where T : unmanaged
  2. {
  3.     private unsafe T* pointer;
  4.     public nuint Length { get; }
  5.     public NativeBuffer(nuint length)
  6.     {
  7.         Length = length;
  8.         unsafe
  9.         {
  10.             pointer = (T*)NativeMemory.Alloc(length);
  11.         }
  12.     }
  13.     public NativeBuffer(Span<T> span) : this((nuint)span.Length)
  14.     {
  15.         unsafe
  16.         {
  17.             fixed (T* ptr = span)
  18.             {
  19.                 Buffer.MemoryCopy(ptr, pointer, sizeof(T) * span.Length, sizeof(T) * span.Length);
  20.             }
  21.         }
  22.     }
  23.     [DoesNotReturn] private ref T ThrowOutOfRange() => throw new IndexOutOfRangeException();
  24.     public ref T this[nuint index]
  25.     {
  26.         get
  27.         {
  28.             unsafe
  29.             {
  30.                 return ref (index >= Length ? ref ThrowOutOfRange() : ref (*(pointer + index)));
  31.             }
  32.         }
  33.     }
  34.     public void Dispose()
  35.     {
  36.         unsafe
  37.         {
  38.             // 判断内存是否有效
  39.             if (pointer != (T*)0)
  40.             {
  41.                 NativeMemory.Free(pointer);
  42.                 pointer = (T*)0;
  43.             }
  44.         }
  45.     }
  46.     // 即使没有调用 Dispose 也可以在 GC 回收时释放资源
  47.     ~NativeBuffer()
  48.     {
  49.         Dispose();
  50.     }
  51. }
复制代码
然后我们给 Color 添加一个 CreateView() 方法即可:
  1. NativeBuffer<int> buf = new(new[] { 1, 2, 3, 4, 5 });
  2. Console.WriteLine(buf[3]); // 4
  3. buf[2] = 9;
  4. Console.WriteLine(buf[2]); // 9
  5. // ...
  6. buf.Dispose();
复制代码
如此一来,我们便能够轻松地通过不同视图来操作 Color 数据,并且一切抽象都是零开销的:
  1. using NativeBuffer<int> buf = new(new[] { 1, 2, 3, 4, 5 });
复制代码
后记

C# 是一门自动挡手动挡同时具备的语言,上限极高的同时下限也极低。可以看到上面的几个例子中,尽管封装所需要的代码较为复杂,但是到了使用的时候就如同一切的底层代码全都消失了一样,各种语法糖加持之下,不仅仅用起来非常的方便快捷,而且借助零开销抽象,代码的内存效率和运行效率都能达到 C++、Rust 的水平。此外,现在的 .NET 7 有了 NativeAOT 之后更是能直接编译到本机代码,运行时无依赖也完全不需要虚拟机,实现了与 C++、Rust 相同的应用形态。这些年来 .NET 在不同的平台、不同工作负载上均有着数一数二的运行效率表现的理由也是显而易见的。
而代码封装的脏活则是由各库的作者来完成的,大多数人在进行业务开发时,无需接触和关系这些底层的东西,甚至哪怕什么都不懂都可以轻松使用封装好的库,站在这些低开销甚至零开销的抽象基础之上来进行应用的构建。
以上便是对 .NET 中进行零开销抽象的一些简单介绍,在开发中的局部热点利用这些技巧能够大幅度提升运行效率和内存效率。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
回复

使用道具 举报

0 个回复

正序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

缠丝猫

金牌会员
这个人很懒什么都没写!
快速回复 返回顶部 返回列表