背景
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 個欄位的物件),就會發生比較大的拷貝開銷,此時只需要利用只讀參考的方法傳遞引數即可避免,提升程式的性能,
從 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 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<T>
、ReadOnlySpan<T>
從 .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
中使用 ref
和 ref 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
我們已經能夠在區域變數中使用 ref
和 ref readonly
了,自然,我們就想要在欄位中也使用這些東西,因此我們在 C# 11 中迎來了 ref
和 ref readonly
欄位,
欄位的生命周期與包含該欄位的型別的實體相同,因此,為了確保安全,ref
和 ref 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
上成員的參考,但是由于 struct
的 this
有著默認的 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
的,
例如消除掉邊界檢查的訪問:
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 = https://www.cnblogs.com/hez2010/archive/2022/11/01/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;
}
上面的例子中我們將 X
、Y
與 XY
的記憶體重疊,并且利用 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
就派上用場了,
對于 class
、struct
和 record
而言,我們需要為其實作 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 掉,
但是,我們可以將例外拿走放到單獨的方法中拋出,這么一來,拋例外的行為就被我們轉換成了普通的函式呼叫行為,于是就不會影響對 Foo
的 inline 優化,將冷塊從 Foo
轉移到了 Throw
中:
[DoesNotReturn] void Throw() => throw new Exception();
void Foo()
{
// ...
Throw();
}
考慮到目前 .NET 還沒有 bottom types 和 union 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# 中有兩個通過數值方式表示的指標型別:IntPtr
和 UIntPtr
,分別是有符號和無符號的,并且長度等于當前行程的指標型別長度,由于長度與平臺相關的特性,它也可以用來表示 native 數值,因此誕生了 nint
和 nuint
,底下分別是 IntPtr
和 UIntPtr
,類似 C++ 中的 ptrdiff_t
和 size_t
型別,
這么一來我們就可以方便地像使用其他的整數型別那樣對 native 數值型別運算:
nint x = -100;
nuint y = 200;
Console.WriteLine(x + (nint)y); //100
當然,寫成 IntPtr
和 UIntPtr
也是沒問題的:
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);
}
這么一來我們就有能表示顏色資料的型別了,但是這么做還不夠,我們需要能夠和二進制資料或者字串撰寫的顏色值相互轉換,因此我們撰寫 Serialize
、Deserialize
和 Parse
方法來進行這樣的事情:
[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 中進行零開銷抽象的一些簡單介紹,在開發中的區域熱點利用這些技巧能夠大幅度提升運行效率和記憶體效率,
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/524847.html
標籤:.NET技术
上一篇:C# 如何在一張大圖片中快速找到另外一張圖片(兩種方式)?
下一篇:CentOS6/7 配置守護行程