真实的国产乱ⅩXXX66竹夫人,五月香六月婷婷激情综合,亚洲日本VA一区二区三区,亚洲精品一区二区三区麻豆

成都創(chuàng)新互聯(lián)網(wǎng)站制作重慶分公司

.NET 零開銷抽象指南

背景

2008 年前后的 Midori 項(xiàng)目試圖構(gòu)建一個(gè)以 .NET 為用戶態(tài)基礎(chǔ)的操作系統(tǒng),在這個(gè)項(xiàng)目中有很多讓 CLR 以及 C# 的類型系統(tǒng)向著適合系統(tǒng)編程的方向改進(jìn)的探索,雖然項(xiàng)目最終沒有面世,但是積累了很多的成果。近些年由于 .NET 團(tuán)隊(duì)在高性能和零開銷設(shè)施上的需要,從 2017 年開始,這些成果逐漸被加入 CLR 和 C# 中,從而能夠讓 .NET 團(tuán)隊(duì)將原先大量的 C++ 基礎(chǔ)庫函數(shù)用 C# 重寫,不僅能減少互操作的開銷,還允許 JIT 進(jìn)行 inline 等優(yōu)化。

創(chuàng)新互聯(lián)主要從事成都做網(wǎng)站、網(wǎng)站制作、網(wǎng)頁設(shè)計(jì)、企業(yè)做網(wǎng)站、公司建網(wǎng)站等業(yè)務(wù)。立足成都服務(wù)龍子湖,十年網(wǎng)站建設(shè)經(jīng)驗(yàn),價(jià)格優(yōu)惠、服務(wù)專業(yè),歡迎來電咨詢建站服務(wù):13518219792

與常識(shí)可能不同,將原先 C++ 的函數(shù)重寫成 C# 之后,帶來的結(jié)果反而是大幅提升了運(yùn)行效率。例如 Visual Studio 2019 的 16.5 版本將原先 C++ 實(shí)現(xiàn)的查找與替換功能用 C# 重寫之后,更是帶來了超過 10 倍的性能提升,在十萬多個(gè)文件中利用正則表達(dá)式查找字符串從原來的 4 分多鐘減少只需要 20 多秒。

目前已經(jīng)到了 .NET 7 和 C# 11,我們已經(jīng)能找到大量的相關(guān)設(shè)施,不過我們?nèi)蕴幵诟倪M(jìn)進(jìn)程的中途。

本文則利用目前為止已有的設(shè)施,講講如何在 .NET 中進(jìn)行零開銷的抽象。

基礎(chǔ)設(shè)施

首先我們來通過以下的不完全介紹來熟悉一下部分基礎(chǔ)設(shè)施。

ref、outinref readonly

談到 refout,相信大多數(shù)人都不會(huì)陌生,畢竟這是從 C# 1 開始就存在的東西。這其實(shí)就是內(nèi)存安全的指針,允許我們在內(nèi)存安全的前提之下,享受到指針的功能:

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 則多用于傳遞函數(shù)的結(jié)果,非常類似 C/C++ 以及 COM 中返回調(diào)用是否成功,而實(shí)際數(shù)據(jù)則通過參數(shù)里的指針傳出的方法:

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 才引入的,相對(duì)于 ref 而言,in 提供了只讀引用的功能。通過 in 傳入的參數(shù)會(huì)通過引用方式進(jìn)行只讀傳遞,類似 C++ 中的 const T*。

為了提升 in 的易用性,C# 為其加入了隱式引用傳遞的功能,即調(diào)用時(shí)不需要在調(diào)用處寫一個(gè) in,編譯器會(huì)自動(dòng)為你創(chuàng)建局部變量并傳遞對(duì)該變量的引用:

void Foo(in Mat3x3 mat)
{
    mat.X13 = 4.2f; // 錯(cuò)誤,因?yàn)橹蛔x引用不能修改
}

// 編譯后會(huì)自動(dòng)創(chuàng)建一個(gè)局部變量保存這個(gè) new 出來的 Mat3x3
// 然后調(diào)用函數(shù)時(shí)會(huì)傳遞對(duì)該局部變量的引用
Foo(new() {  }); 

struct Mat3x3
{
    public float X11, X12, X13, X21, X22, X23, X31, X32, X33;
}

當(dāng)然,我們也可以像 ref 那樣使用 in,明確指出我們引用的是什么東西:

Mat3x3 x = ...;
Foo(in x);

struct 默認(rèn)的參數(shù)傳遞行為是傳遞值的拷貝,當(dāng)傳遞的對(duì)象較大時(shí)(一般指多于 4 個(gè)字段的對(duì)象),就會(huì)發(fā)生比較大的拷貝開銷,此時(shí)只需要利用只讀引用的方法傳遞參數(shù)即可避免,提升程序的性能。

從 C# 7 開始,我們可以在方法中返回引用,例如:

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

調(diào)用該函數(shù)時(shí),如果通過 ref 方式調(diào)用,則會(huì)接收到返回的引用:

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

否則表示接收值,與返回非引用沒有區(qū)別:

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 顯式標(biāo)記一個(gè)東西是否是引用,如果沒有標(biāo)記 ref,則一定不會(huì)是引用。

當(dāng)然,配套而來的便是返回只讀引用,確保返回的引用是不可修改的。與 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; // 錯(cuò)誤
ref readonly int y = ref array[1];
y = 3; // 錯(cuò)誤

ref struct

C# 7.2 引入了一種新的類型:ref struct。這種類型由編譯器和運(yùn)行時(shí)同時(shí)確保絕對(duì)不會(huì)被裝箱,因此這種類型的實(shí)例的生命周期非常明確,它只可能在棧內(nèi)存中,而不可能出現(xiàn)在堆內(nèi)存中:

Foo[] foos = new Foo[] { new(), new() }; // 錯(cuò)誤

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

借助 ref struct,我們便能在 ref struct 中保存引用,而無需擔(dān)心 ref struct 的實(shí)例因?yàn)樯芷诒灰馔庋娱L而導(dǎo)致出現(xiàn)無效引用。

Span、ReadOnlySpan

從 .NET Core 2.1 開始,.NET 引入了 SpanReadOnlySpan 這兩個(gè)類型來表示對(duì)一段連續(xù)內(nèi)存的引用和只讀引用。

SpanReadOnlySpan 都是 ref struct,因此他們絕對(duì)不可能被裝箱,這確保了只要在他們自身的生命周期內(nèi),他們所引用的內(nèi)存絕對(duì)都是有效的,因此借助這兩個(gè)類型,我們可以代替指針來安全地操作任何連續(xù)內(nèi)存。

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

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

我們還可以在 foreach 中使用 refref readonly 來以引用的方式訪問各成員:

Span 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 之外,我們還有一個(gè)關(guān)鍵字 stackalloc,允許我們在棧內(nèi)存上分配數(shù)組:

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

這樣我們就成功在棧上分配出了一個(gè)數(shù)組,這個(gè)數(shù)組的生命周期就是所在代碼塊的生命周期。

ref field

我們已經(jīng)能夠在局部變量中使用 refref readonly 了,自然,我們就想要在字段中也使用這些東西。因此我們在 C# 11 中迎來了 refref readonly 字段。

字段的生命周期與包含該字段的類型的實(shí)例相同,因此,為了確保安全,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; // 錯(cuò)誤

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

ref struct Bar
{
    public ref readonly int X;
}

當(dāng)然,上面的 Bar 里我們展示了對(duì)只讀內(nèi)容的引用,但是字段本身也可以是只讀的,于是我們就還有:

ref struct Bar
{
    public ref int X; // 引用可變內(nèi)容的可變字段
    public ref readonly int Y; // 引用只讀內(nèi)容的可變字段
    public readonly ref int Z; // 引用可變內(nèi)容的只讀字段
    public readonly ref readonly int W; // 引用只讀內(nèi)容的只讀字段
}

scopedUnscopedRef

我們再看看上面這個(gè)例子的 Foo,這個(gè) ref struct 中有接收引用作為參數(shù)的構(gòu)造函數(shù),這次我們不再在字段中保存引用:

Foo Test()
{
    Span x = stackalloc[] { 1, 2, 3, 4, 5 };
    Foo foo = new Foo(ref x[0]); // 錯(cuò)誤
    return foo;
}

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

你會(huì)發(fā)現(xiàn)這時(shí)代碼無法編譯了。

因?yàn)?stackalloc 出來的東西僅在 Test 函數(shù)的生命周期內(nèi)有效,但是我們有可能在 Foo 的構(gòu)造函數(shù)中將 ref int x 這一引用存儲(chǔ)到 Foo 的字段中,然后由于 Test 方法返回了 foo,這使得 foo 的生命周期被擴(kuò)展到了調(diào)用 Test 函數(shù)的函數(shù)上,有可能導(dǎo)致本身應(yīng)該在 Test 結(jié)束時(shí)就釋放的 x[0] 的生命周期被延長,從而出現(xiàn)無效引用。因此編譯器拒絕編譯了。

你可能會(huì)好奇,編譯器在理論上明明可以檢測到底有沒有實(shí)際的代碼在字段中保存了引用,為什么還是直接報(bào)錯(cuò)了?這是因?yàn)?,如果需要檢測則需要實(shí)現(xiàn)復(fù)雜度極其高的過程分析,不僅會(huì)大幅拖慢編譯速度,而且還存在很多無法靜態(tài)處理的邊緣情況。

那要怎么處理呢?這個(gè)時(shí)候 scoped 就出場了:

Foo Test()
{
    Span 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 前加一個(gè) scoped,顯式標(biāo)注出 ref int x 的生命周期不會(huì)超出該函數(shù),這樣我們就能通過編譯了。

此時(shí),如果我們試圖在字段中保存這個(gè)引用的話,編譯器則會(huì)有效的指出錯(cuò)誤:

ref struct Foo
{
    public ref int X;
    public Foo(scoped ref int x)
    {
        X = ref x; // 錯(cuò)誤
    }
}

同樣的,我們還可以在局部變量中配合 ref 或者 ref readonly 使用 scoped

Span 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

當(dāng)然,上面這個(gè)例子中即使不加 scoped,也是默認(rèn) scoped 的,這里標(biāo)出來只是為了演示,實(shí)際上與下面的代碼等價(jià):

Span 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

對(duì)于 ref struct 而言,由于其自身就是一種可以保存引用的“類引用”類型,因此我們的 scoped 也可以用于 ref struct,表明該 ref struct 的生命周期就是當(dāng)前函數(shù):

Span Foo(Span s)
{
    return s;
}

Span Bar(scoped Span s)
{
    return s; // 錯(cuò)誤
}

有時(shí)候我們希望在 struct 中返回 this 上成員的引用,但是由于 structthis 有著默認(rèn)的 scoped 生命周期,因此此時(shí)無法通過編譯。這個(gè)時(shí)候我們可以借助 [UnscopedRef] 來將 this 的生命周期從當(dāng)前函數(shù)延長到調(diào)用函數(shù)上:

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

struct Foo
{
    public int X;

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

這對(duì) out 也是同理的,因?yàn)?out 也是默認(rèn)有 scoped 生命周期:

ref int Foo(out int i) 
{
    i = 42;
    return ref i; // 錯(cuò)誤
}

但是我們同樣可以添加 [UnscopedRef] 來擴(kuò)展生命周期:

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

UnsafeMarshal、MemoryMarshalCollectionsMarshal、NativeMemoryBuffer

在 .NET 中,我們有著非常多的工具函數(shù),分布在 Unsafe.*、Marshal.*、MemoryMarshal.*CollectionsMarshal.*、NativeMemory.*Buffer.* 中。利用這些工具函數(shù),我們可以非常高效地在幾乎不直接使用指針的情況下,操作各類內(nèi)存、引用和數(shù)組、集合等等。當(dāng)然,使用的前提是你有相關(guān)的知識(shí)并且明確知道你在干什么,不然很容易寫出不安全的代碼,畢竟這里面大多數(shù) API 就是 unsafe 的。

例如消除掉邊界檢查的訪問:

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

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

查看生成的代碼驗(yàn)證:

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

可以看到,邊界檢查確實(shí)被消滅了,對(duì)比直接訪問的情況:

void Foo(Span 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 dict = new()
{
    [1] = 7,
    [2] = 42
};

// 如果存在則獲取引用,否則添加一個(gè) default 進(jìn)去然后再返回引用
ref int value = ref CollectionsMarshal.GetValueRefOrAddDefault(dict, 3, out bool exists);
value++;
Console.WriteLine(exists); // false
Console.WriteLine(dict[3]); // 1

如此一來,我們便不需要先調(diào)用 ContainsKey 再操作,只需要一次查找即可完成我們需要的操作,而不是 ContainsKey 查找一次,后續(xù)操作再查找一次。

我們還可以用 Buffer.CopyMemory 來實(shí)現(xiàn)與 memcpy 等價(jià)的高效率數(shù)組拷貝;再有就是前文中出現(xiàn)過的 NativeMemory,借助此 API,我們可以手動(dòng)分配非托管內(nèi)存,并指定對(duì)齊方式、是否清零等參數(shù)。

顯式布局、字段重疊和定長數(shù)組

C# 的 struct 允許我們利用 [StructLayout] 按字節(jié)手動(dòng)指定內(nèi)存布局,例如:

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、YXY 的內(nèi)存重疊,并且利用 Pack 指定了 padding 行為,使得 Foo 的長度為 10 字節(jié),而不是 16 字節(jié)。

我們還有定長數(shù)組:

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

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

此時(shí),我們便有一個(gè)長度固定為 4 的數(shù)組存在于 Foo 的字段中,占據(jù) 16 個(gè)字節(jié)的長度。

接口的虛靜態(tài)方法

.NET 7 中我們迎來了接口的虛靜態(tài)方法,這一特性加強(qiáng)了 C# 泛型的表達(dá)能力,使得我們可以更好地利用參數(shù)化多態(tài)來更高效地對(duì)代碼進(jìn)行抽象。

此前當(dāng)遇到字符串時(shí),如果我們想要編寫一個(gè)方法來對(duì)字符串進(jìn)行解析,得到我們想要的類型的話,要么需要針對(duì)各種重載都編寫一份,要么寫成泛型方法,然后再在里面判斷類型。兩種方法編寫起來都非常的麻煩:

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

或者:

T Parse(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 有能力在編譯時(shí)消除掉多余的分支(因?yàn)?T 在編譯時(shí)已知),編寫起來仍然非常費(fèi)勁,并且無法處理沒有覆蓋到的情況。

但現(xiàn)在我們只需要利用接口的虛靜態(tài)方法,即可高效的對(duì)所有實(shí)現(xiàn)了 IParsable 的類型實(shí)現(xiàn)這個(gè) Parse 方法。.NET 標(biāo)準(zhǔn)庫中已經(jīng)內(nèi)置了不少相關(guān)類型,例如 System.IParsable 的定義如下:

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

那么,我們只需要編寫一個(gè):

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

即可。

這樣,哪怕是其他地方定義的類型,只要實(shí)現(xiàn)了 IParsable,就能夠傳到這個(gè)方法中:

struct Point : IParsable
{
    public int X, Y;
    
    public static Point Parse(string s, IFormatProvider? provider) { ... }
    public static bool TryParse(string? s, IFormatProvider? provider, out Point result) { ... }
}

當(dāng)然,既然是虛靜態(tài)方法,那就意味著不僅僅可以是 abstract,更可以是 virtual 的,如此一來我們還可以提供自己的默認(rèn)實(shí)現(xiàn):

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

DisposeIDisposable

我們有時(shí)需要顯式地手動(dòng)控制資源釋放,而不是一味地交給 GC 來進(jìn)行處理,那么此時(shí)我們的老朋友 Dispose 就派上用場了。

對(duì)于 classstructrecord 而言,我們需要為其實(shí)現(xiàn) IDisposable 接口,而對(duì)于 ref struct 而言,我們只需要暴露一個(gè) public void Dispose()。這樣一來,我們便可以用 using 來自動(dòng)進(jìn)行資源釋放。

例如:

// 在 foo 的作用域結(jié)束時(shí)自動(dòng)調(diào)用 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);
    }
}

異常處理的編譯優(yōu)化

異常是個(gè)好東西,但是也會(huì)對(duì)效率造成影響。因?yàn)楫惓T诖a中通常是不常見的,因?yàn)?JIT 在編譯代碼時(shí),會(huì)將包含拋出異常的代碼認(rèn)定為冷塊(即不會(huì)被怎么執(zhí)行的代碼塊),這么一來會(huì)影響 inline 的決策:

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

例如上面這個(gè) Foo 方法,就很難被 inline 掉。

但是,我們可以將異常拿走放到單獨(dú)的方法中拋出,這么一來,拋異常的行為就被我們轉(zhuǎn)換成了普通的函數(shù)調(diào)用行為,于是就不會(huì)影響對(duì) Foo 的 inline 優(yōu)化,將冷塊從 Foo 轉(zhuǎn)移到了 Throw 中:

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

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

考慮到目前 .NET 還沒有 bottom types 和 union types,當(dāng)我們的 Foo 需要返回東西的時(shí)候,很顯然上面的代碼會(huì)因?yàn)椴皇撬新窂蕉挤祷亓藮|西而報(bào)錯(cuò),此時(shí)我們只需要將 Throw 的返回值類型改成我們想返回的類型,或者干脆封裝成泛型方法然后傳入類型參數(shù)即可。因?yàn)?throw 在 C# 中隱含了不會(huì)返回的含義,編譯器遇到 throw 時(shí)知道這個(gè)是不會(huì)返回的,也就不會(huì)因?yàn)?Throw 沒有返回東西而報(bào)錯(cuò):

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

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

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

指針和函數(shù)指針

指針相信大家都不陌生,像 C/C++ 中的指針那樣,C# 中套一個(gè) unsafe 就能直接用。唯一需要注意的地方是,由于 GC 可能會(huì)移動(dòng)堆內(nèi)存上的對(duì)象,所以在使用指針操作 GC 堆內(nèi)存中的對(duì)象前,需要先使用 fixed 將其固定:

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

當(dāng)然,指針不僅僅局限于對(duì)象,函數(shù)也可以有函數(shù)指針:

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

函數(shù)指針也可以指向非托管方法,例如來自 C++ 庫中、有著 cdecl 調(diào)用約定的函數(shù):

delegate* unmanaged[Cdecl] f = ...;

進(jìn)一步我們還可以指定 SuppressGCTransition 來取消做互操作時(shí) GC 上下文的切換來提高性能。當(dāng)然這是危險(xiǎn)的,只有當(dāng)被調(diào)用的函數(shù)能夠非??焱瓿蓵r(shí)才能使用:

delegate* unmanaged[Cdecl, SuppressGCTransition] f = ...;

SuppressGCTransition 同樣可以用于 P/Invoke:

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

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

IntPtr、UIntPtr、nintnuint

C# 中有兩個(gè)通過數(shù)值方式表示的指針類型:IntPtrUIntPtr,分別是有符號(hào)和無符號(hào)的,并且長度等于當(dāng)前進(jìn)程的指針類型長度。由于長度與平臺(tái)相關(guān)的特性,它也可以用來表示 native 數(shù)值,因此誕生了 nintnuint,底下分別是 IntPtrUIntPtr,類似 C++ 中的 ptrdiff_tsize_t 類型。

這么一來我們就可以方便地像使用其他的整數(shù)類型那樣對(duì) native 數(shù)值類型運(yùn)算:

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

當(dāng)然,寫成 IntPtrUIntPtr 也是沒問題的:

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

SkipLocalsInit

SkipLocalsInit 可以跳過 .NET 默認(rèn)的分配時(shí)自動(dòng)清零行為,當(dāng)我們知道自己要干什么的時(shí)候,使用 SkipLocalsInit 可以節(jié)省掉內(nèi)存清零的開銷:

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

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

Foo1(); // 一個(gè)不確定的 Guid
Foo2(); // 00000000-0000-0000-0000-000000000000

實(shí)際例子

熟悉完 .NET 中的部分基礎(chǔ)設(shè)施,我們便可以來實(shí)際編寫一些代碼了。

非托管內(nèi)存

在大型應(yīng)用中,我們偶爾會(huì)用到超出 GC 管理能力范圍的超大數(shù)組(> 4G),當(dāng)然我們可以選擇類似鏈表那樣拼接多個(gè)數(shù)組,但除了這個(gè)方法外,我們還可以自行封裝出一個(gè)處理非托管內(nèi)存的結(jié)構(gòu)來使用。另外,這種需求在游戲開發(fā)中也較為常見,例如需要將一段內(nèi)存作為頂點(diǎn)緩沖區(qū)然后送到 GPU 進(jìn)行處理,此時(shí)要求這段內(nèi)存不能被移動(dòng)。

那此時(shí)我們可以怎么做呢?

首先我們可以實(shí)現(xiàn)基本的存儲(chǔ)、釋放和訪問功能:

public sealed class NativeBuffer : 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 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
        {
            // 判斷內(nèi)存是否有效
            if (pointer != (T*)0)
            {
                NativeMemory.Free(pointer);
                pointer = (T*)0;
            }
        }
    }

    // 即使沒有調(diào)用 Dispose 也可以在 GC 回收時(shí)釋放資源
    ~NativeBuffer()
    {
        Dispose();
    }
}

如此一來,使用時(shí)只需要簡單的:

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

或者讓它在作用域結(jié)束時(shí)自動(dòng)釋放:

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

或者干脆不管了,等待 GC 回收時(shí)自動(dòng)調(diào)用我們的編寫的析構(gòu)函數(shù),這個(gè)時(shí)候就會(huì)從 ~NativeBuffer 調(diào)用 Dispose 方法。

緊接著,為了能夠使用 foreach 進(jìn)行迭代,我們還需要實(shí)現(xiàn)一個(gè) Enumerator,但是為了提升效率并且支持引用,此時(shí)我們選擇實(shí)現(xiàn)自己的 GetEnumerator

首先我們實(shí)現(xiàn)一個(gè) 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
            {
                // 確保指向的內(nèi)存仍然有效
                if (pointer == (T*)0)
                {
                    return ref Unsafe.NullRef();
                }
                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();
    }

    public bool MoveNext()
    {
        unsafe
        {
            // 確保沒有越界并且指向的內(nèi)存仍然有效
            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 方法返回我們的實(shí)現(xiàn)好的迭代器即可:

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

從此,我們便可以輕松零分配地迭代我們的 NativeBuffer 了:

int[] buffer = new[] { 1, 2, 3, 4, 5 };
using NativeBuffer 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

并且由于我們的迭代器中保存著對(duì) NativeBuffer.pointer 的引用,如果 NativeBuffer 被釋放了,運(yùn)行了一半的迭代器也能及時(shí)發(fā)現(xiàn)并終止迭代:

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

結(jié)構(gòu)化數(shù)據(jù)

我們經(jīng)常會(huì)需要存儲(chǔ)結(jié)構(gòu)化數(shù)據(jù),例如在進(jìn)行圖片處理時(shí),我們經(jīng)常需要保存顏色信息。這個(gè)顏色可能是直接從文件數(shù)據(jù)中讀取得到的。那么此時(shí)我們便可以封裝一個(gè) Color 來代表顏色數(shù)據(jù) RGBA:

[StructLayout(LayoutKind.Sequential)]
public struct Color : IEquatable
{
    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);
}

這么一來我們就有能表示顏色數(shù)據(jù)的類型了。但是這么做還不夠,我們需要能夠和二進(jìn)制數(shù)據(jù)或者字符串編寫的顏色值相互轉(zhuǎn)換,因此我們編寫 Serialize、DeserializeParse 方法來進(jìn)行這樣的事情:

[StructLayout(LayoutKind.Sequential)]
public struct Color : IParsable, IEquatable
{
    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 data)
    {
        return MemoryMarshal.Read(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);
    }
}

接下來,我們再實(shí)現(xiàn)一個(gè) ColorView,允許以多種方式對(duì) Color 進(jìn)行訪問和修改:

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(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 添加一個(gè) CreateView() 方法即可:

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

如此一來,我們便能夠輕松地通過不同視圖來操作 Color 數(shù)據(jù),并且一切抽象都是零開銷的:

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# 是一門自動(dòng)擋手動(dòng)擋同時(shí)具備的語言,上限極高的同時(shí)下限也極低??梢钥吹缴厦娴膸讉€(gè)例子中,盡管封裝所需要的代碼較為復(fù)雜,但是到了使用的時(shí)候就如同一切的底層代碼全都消失了一樣,各種語法糖加持之下,不僅僅用起來非常的方便快捷,而且借助零開銷抽象,代碼的內(nèi)存效率和運(yùn)行效率都能達(dá)到 C++、Rust 的水平。此外,現(xiàn)在的 .NET 7 有了 NativeAOT 之后更是能直接編譯到本機(jī)代碼,運(yùn)行時(shí)無依賴也完全不需要虛擬機(jī),實(shí)現(xiàn)了與 C++、Rust 相同的應(yīng)用形態(tài)。這些年來 .NET 在不同的平臺(tái)、不同工作負(fù)載上均有著數(shù)一數(shù)二的運(yùn)行效率表現(xiàn)的理由也是顯而易見的。

而代碼封裝的臟活則是由各庫的作者來完成的,大多數(shù)人在進(jìn)行業(yè)務(wù)開發(fā)時(shí),無需接觸和關(guān)系這些底層的東西,甚至哪怕什么都不懂都可以輕松使用封裝好的庫,站在這些低開銷甚至零開銷的抽象基礎(chǔ)之上來進(jìn)行應(yīng)用的構(gòu)建。

以上便是對(duì) .NET 中進(jìn)行零開銷抽象的一些簡單介紹,在開發(fā)中的局部熱點(diǎn)利用這些技巧能夠大幅度提升運(yùn)行效率和內(nèi)存效率。


分享文章:.NET 零開銷抽象指南
當(dāng)前網(wǎng)址:http://weahome.cn/article/dsoiops.html

其他資訊

在線咨詢

微信咨詢

電話咨詢

028-86922220(工作日)

18980820575(7×24)

提交需求

返回頂部