主頁 > .NET開發 > .NET 零開銷抽象指南

.NET 零開銷抽象指南

2022-11-02 06:06:08 .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); // 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 中使用 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; // 參考只讀內容的只讀欄位
}

scopedUnscopedRef

我們再看看上面這個例子的 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;
}

UnsafeMarshalMemoryMarshalCollectionsMarshalNativeMemoryBuffer

在 .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/p/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");
}

DisposeIDisposable

我們有時需要顯式地手動控制資源釋放,而不是一味地交給 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 掉,

但是,我們可以將例外拿走放到單獨的方法中拋出,這么一來,拋例外的行為就被我們轉換成了普通的函式呼叫行為,于是就不會影響對 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();

IntPtrUIntPtrnintnuint

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 中進行零開銷抽象的一些簡單介紹,在開發中的區域熱點利用這些技巧能夠大幅度提升運行效率和記憶體效率,

轉載請註明出處,本文鏈接:https://www.uj5u.com/net/524844.html

標籤:.NET Core

上一篇:XAF新手入門 - XAF設計模式探討

下一篇:XAF新手入門 - XAF設計模式探討

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • WebAPI簡介

    Web體系結構: 有三個核心:資源(resource),URL(統一資源識別符號)和表示 他們的關系是這樣的:一個資源由一個URL進行標識,HTTP客戶端使用URL定位資源,表示是從資源回傳資料,媒體型別是資源回傳的資料格式。 接下來我們說下HTTP. HTTP協議的系統是一種無狀態的方式,使用請求/ ......

    uj5u.com 2020-09-09 22:07:47 more
  • asp.net core 3.1 入口:Program.cs中的Main函式

    本文分析Program.cs 中Main()函式中代碼的運行順序分析asp.net core程式的啟動,重點不是剖析原始碼,而是理清程式開始時執行的順序。到呼叫了哪些實體,哪些法方。asp.net core 3.1 的程式入口在專案Program.cs檔案里,如下。ususing System; us ......

    uj5u.com 2020-09-09 22:07:49 more
  • asp.net網站作為websocket服務端的應用該如何寫

    最近被websocket的一個問題困擾了很久,有一個需求是在web網站中搭建websocket服務。客戶端通過網頁與服務器建立連接,然后服務器根據ip給客戶端網頁發送資訊。 其實,這個需求并不難,只是剛開始對websocket的內容不太了解。上網搜索了一下,有通過asp.net core 實作的、有 ......

    uj5u.com 2020-09-09 22:08:02 more
  • ASP.NET 開源匯入匯出庫Magicodes.IE Docker中使用

    Magicodes.IE在Docker中使用 更新歷史 2019.02.13 【Nuget】版本更新到2.0.2 【匯入】修復單列匯入的Bug,單元測驗“OneColumnImporter_Test”。問題見(https://github.com/dotnetcore/Magicodes.IE/is ......

    uj5u.com 2020-09-09 22:08:05 more
  • 在webform中使用ajax

    如果你用過Asp.net webform, 說明你也算是.NET 開發的老兵了。WEBform應該是2011 2013左右,當時還用visual studio 2005、 visual studio 2008。后來基本都用的是MVC。 如果是新開發的專案,估計沒人會用webform技術。但是有些舊版 ......

    uj5u.com 2020-09-09 22:08:50 more
  • iis添加asp.net網站,訪問提示:由于擴展配置問題而無法提供您請求的

    今天在iis服務器配置asp.net網站,遇到一個問題,記錄一下: 問題:由于擴展配置問題而無法提供您請求的頁面。如果該頁面是腳本,請添加處理程式。如果應下載檔案,請添加 MIME 映射。 WindowServer2012服務器,添加角色安裝完.netframework和iis之后,運行aspx頁面 ......

    uj5u.com 2020-09-09 22:10:00 more
  • WebAPI-處理架構

    帶著問題去思考,大家好! 問題1:HTTP請求和回傳相應的HTTP回應資訊之間發生了什么? 1:首先是最底層,托管層,位于WebAPI和底層HTTP堆疊之間 2:其次是 訊息處理程式管道層,這里比如日志和快取。OWIN的參考是將訊息處理程式管道的一些功能下移到堆疊下端的OWIN中間件了。 3:控制器處理 ......

    uj5u.com 2020-09-09 22:11:13 more
  • 微信門戶開發框架-使用指導說明書

    微信門戶應用管理系統,采用基于 MVC + Bootstrap + Ajax + Enterprise Library的技術路線,界面層采用Boostrap + Metronic組合的前端框架,資料訪問層支持Oracle、SQLServer、MySQL、PostgreSQL等資料庫。框架以MVC5,... ......

    uj5u.com 2020-09-09 22:15:18 more
  • WebAPI-HTTP編程模型

    帶著問題去思考,大家好!它是什么?它包含什么?它能干什么? 訊息 HTTP編程模型的核心就是訊息抽象,表示為:HttPRequestMessage,HttpResponseMessage.用于客戶端和服務端之間交換請求和回應訊息。 HttpMethod類包含了一組靜態屬性: private stat ......

    uj5u.com 2020-09-09 22:15:23 more
  • 部署WebApi隨筆

    一、跨域 NuGet參考Microsoft.AspNet.WebApi.Cors WebApiConfig.cs中配置: // Web API 配置和服務 config.EnableCors(new EnableCorsAttribute("*", "*", "*")); 二、清除默認回傳XML格式 ......

    uj5u.com 2020-09-09 22:15:48 more
最新发布
  • C#多執行緒學習(二) 如何操縱一個執行緒

    <a href="https://www.cnblogs.com/x-zhi/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/2943582/20220801082530.png" alt="" /></...

    uj5u.com 2023-04-19 09:17:20 more
  • C#多執行緒學習(二) 如何操縱一個執行緒

    C#多執行緒學習(二) 如何操縱一個執行緒 執行緒學習第一篇:C#多執行緒學習(一) 多執行緒的相關概念 下面我們就動手來創建一個執行緒,使用Thread類創建執行緒時,只需提供執行緒入口即可。(執行緒入口使程式知道該讓這個執行緒干什么事) 在C#中,執行緒入口是通過ThreadStart代理(delegate)來提供的 ......

    uj5u.com 2023-04-19 09:16:49 more
  • 記一次 .NET某醫療器械清洗系統 卡死分析

    <a href="https://www.cnblogs.com/huangxincheng/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/214741/20200614104537.png" alt="" /&g...

    uj5u.com 2023-04-18 08:39:04 more
  • 記一次 .NET某醫療器械清洗系統 卡死分析

    一:背景 1. 講故事 前段時間協助訓練營里的一位朋友分析了一個程式卡死的問題,回過頭來看這個案例比較經典,這篇稍微整理一下供后來者少踩坑吧。 二:WinDbg 分析 1. 為什么會卡死 因為是表單程式,理所當然就是看主執行緒此時正在做什么? 可以用 ~0s ; k 看一下便知。 0:000> k # ......

    uj5u.com 2023-04-18 08:33:10 more
  • SignalR, No Connection with that ID,IIS

    <a href="https://www.cnblogs.com/smartstar/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/u36196.jpg" alt="" /></a>...

    uj5u.com 2023-03-30 17:21:52 more
  • 一次對pool的誤用導致的.net頻繁gc的診斷分析

    <a href="https://www.cnblogs.com/dotnet-diagnostic/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/3115652/20230225090434.png" alt=""...

    uj5u.com 2023-03-28 10:15:33 more
  • 一次對pool的誤用導致的.net頻繁gc的診斷分析

    <a href="https://www.cnblogs.com/dotnet-diagnostic/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/3115652/20230225090434.png" alt=""...

    uj5u.com 2023-03-28 10:13:31 more
  • C#遍歷指定檔案夾中所有檔案的3種方法

    <a href="https://www.cnblogs.com/xbhp/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/957602/20230310105611.png" alt="" /></a&...

    uj5u.com 2023-03-27 14:46:55 more
  • C#/VB.NET:如何將PDF轉為PDF/A

    <a href="https://www.cnblogs.com/Carina-baby/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/2859233/20220427162558.png" alt="" />...

    uj5u.com 2023-03-27 14:46:35 more
  • 武裝你的WEBAPI-OData聚合查詢

    <a href="https://www.cnblogs.com/podolski/" target="_blank"><img width="48" height="48" class="pfs" src="https://pic.cnblogs.com/face/616093/20140323000327.png" alt="" /><...

    uj5u.com 2023-03-27 14:46:16 more