1. 程式人生 > >通俗易懂,C#如何安全、高效地玩轉任何種類的記憶體之Span(二)。

通俗易懂,C#如何安全、高效地玩轉任何種類的記憶體之Span(二)。

前言

讀完上篇《通俗易懂,C#如何安全、高效地玩轉任何種類的記憶體之Span(一)。》,相信大家對span的本質應該非常清楚了。含著金鑰匙出生的它,從小就被寄予厚望要成為.NET下編寫高效能應用程式的重要積木,而且很多老前輩為了接納它,都紛紛做出了改變,比如String、Int、Array。現在,它長大了,已經成為.NET下發揮關鍵作用的新值型別。

那我們又該如何接納它呢?

一句話,熟悉它的脾氣秉性,讓好鋼用到刀刃上

脾氣秉性 - 特點

Slow vs Fast Span

上篇部落格介紹了span的本質,主要涉及到三個欄位,如下:

public struct Span<T> {
    internal IntPtr _byteOffset; // 偏移量
    internal object _reference;// 引用,可以看作當前物件的索引
    internal int _length;// 長度
}

當我們訪問span表示的整體或部分記憶體時,內部的索引器通過計算(ref reference + byteOffset) + index * sizeOf(T)來正確直接地返回實際儲存位置的引用,而不是通過複製記憶體來返回相對位置的副本,從而達到高效能,但是,現在我要告訴你,這種span被叫做slow span,為什麼呢?因為C#7.2的新特性ref T支援在簽名中直接返回引用(相當於直接整合了這個過程),這樣就無需通過計算來確定指標開頭及其起始偏移,從而真正擁有和訪問陣列一樣高的效率,如下:

public struct Span<T> {
    internal ref T _reference;// 引用,本身已整合_byteOffset、_reference兩者。
    internal int _length;// 長度
}

這種只包含兩個欄位的span就叫Fast span

在所有的.NET平臺,Slow Span都是可得到的,但是目前只有.NET Core 2.X原生支援Fast span。

為了讓大家更直觀地瞭解這兩種Span,下面來做兩組基準測試

  • 不同執行時下Span進行10萬次Get、Set的基準測試

    上圖非常清楚了吧,從Mean(均值)指標可以看出差異還是比較大的(約60%),net framework時代追求生產力,而core時代追求高效能,所以還是早轉core吧,並且新版本core還會進一步優化span,差距將會越來越大。

  • Span vs Array的基準測試

    不同執行時下,對Span和Array進行10萬次Get、Set操作

    從上圖Mean(均值)指標可以得出:

    • slow span,即執行時原生不支援,在效能上,它的Get、Set操作和陣列差異50%左右。
    • fast span,即執行時原生支援,在效能上,它的Get、Set操作和陣列相當。

看了上面測試,可能有的同學就會問了用Array就行了,如果總是操作整個陣列,這是合適的,但如果想運算元組的一部分資料呢?按照以前的做法每次複製一份相對位置的副本給呼叫方,這就非常消耗效能的,那麼如何支援對完整或部分陣列的操作保持同樣高的效能呢?答案就是span,沒有之一。span不僅能用於訪問陣列和分離陣列子集,還可引用來自記憶體任意區域的資料,比如本機程式碼、棧記憶體、託管記憶體。

基準測試示例原始碼參考

Stack-Only

分配一塊棧記憶體是非常快速的,也無需手工釋放,它會隨著當前作用域而釋放,比如方法執行結束時,就自動釋放了,所以需要快取快用快放。Span雖然支援所有型別的記憶體,但決定安全、高效地操作各種記憶體的下限自然取決於最嚴苛的記憶體型別,即棧記憶體,好比木桶能裝多少水,取決於最短的那塊木板。此外,上一篇部落格的動畫非常清晰地演示了span的本質,每次都是通過整合內部指標為新的引用返回,而.NET執行時跟蹤這些內部指標的成本非常高昂,所以將span約束為僅存在於棧上,從而隱式地限制了可以存在的內部指標數量。

備註:棧記憶體的容量非常小, ARM、x86 和 x64 計算機,預設堆疊大小為 1 MB。

所以span必須是值型別,它不能被儲存到堆上。

Stack-Only的應用場景

  1. Span不能作為類的欄位

    class Impossible
    {
        Span<byte> field;
    }
  2. Span不能實現任何介面

    先來看一段C#(虛擬碼):

    struct StructType<T> : IEnumerable<T> { }
    class SpanStructTypeSample
    {
        static void Test()
        {
            var value = new StructType<int>();
            Parse(value);
        }
    
        static void Parse(IEnumerable<int> collection) { }
    }

    使用ILDasm檢視生成的IL程式碼:

    .method public hidebysig static void  Test() cil managed // 呼叫Test方法
    {
      // Code size       22 (0x16)
      .maxstack  1
      .locals init (valuetype SpanTest.StructType`1<int32> V_0)
      IL_0000:  nop
      IL_0001:  ldloca.s   V_0
      IL_0003:  initobj    valuetype SpanTest.StructType`1<int32>
      IL_0009:  ldloc.0
      IL_000a:  box        valuetype SpanTest.StructType`1<int32> // 裝箱,意味著被儲存到託管堆上。
      IL_000f:  call       void SpanTest.SpanStructTypeSample::Parse(class [System.Runtime]System.Collections.Generic.IEnumerable`1<int32>)
      IL_0014:  nop
      IL_0015:  ret
    } // end of method SpanStructTypeSample::Test

    上面的程式碼很明確,首先讓自定義的值型別實現介面IEnumerable,然後作為引數傳遞給Parse,最後分析IL程式碼發現引數被裝箱了,意味著將被儲存到託管堆上,如果將來C#能專門定義只用於struct的介面,那麼就能擴充套件Stack-Only結構到此應用場景了,一起期待吧。

  3. Span不能作為非同步方法的引數

    首先asyncawait 是非常棒的語法糖,不僅僅大大地簡化了編寫非同步程式碼的難度,而且還帶來了程式碼的優雅度。

    同樣,先來看一段C#程式碼:

    public async Task TestAsync(Span<byte> data) { }

    這樣的用法也是禁止的,編譯時就會報錯Parameter or local type Span<byte> cannot be declared in async method.。因為本質上,async & await 的內部是通過AsyncMethodBuilder來建立一個非同步的狀態機,某一時刻可能會將方法引數儲存到託管堆上。

  4. Span不能作為泛型型別的引數

    同樣,先來看一段C#程式碼:

    Func<Span<byte>> valueProvider = () => new Span<byte>(new byte[256]);
    object value = valueProvider.Invoke(); // 裝箱

    這樣的用法也是禁止的,編譯時會報錯The type Span<byte>may not be used as a type argument.。同理,span<byte>可以表示記憶體任意區域,而實際使用時肯定需要型別化物件,無法避免裝箱。那麼微軟為什麼不引入一種新的泛型約束:stackonly,而是決定禁止span作為泛型引數,因為這需要編譯器檢查所有的程式碼,可能還需要理解程式碼邏輯(因為有的型別需要執行時才能確定),不然是無法保證stackonly約束的,呵呵,目前看來是不現實的,不知人工智慧能否解決這個問題。

Stack Tearing

闡述這個特點前,先簡單說說計算機的字大小。

  • 計算機的字大小

    表示計算機中CPU的字長,32位CPU字長為32位,即4位元組;64位CPU字長為64位,即8位元組。CPU的字長決定了每次能夠原子更新的連續記憶體塊的大小

棧撕裂其實是多執行緒下的資料同步問題,當結構資料大於當前處理器的字大小時,都會存在面臨這個問題。如前所述,span內部包含多個欄位,這就意味著,一些處理器可能無法保證原子更新span_reference_length 欄位,也就是說,多執行緒下_reference_length可能來自於兩個不同的span。

internal class Buffer
{
    Span<byte> _memory = new byte[1024];

    public void Resize(int newSize)
    {
        _memory = new byte[newSize]; // 因為這裡無法保證原子更新
    }

    public byte this[int index] => _memory[index]; // 所以這裡可能的部分更新
}

其實有兩種辦法可以解決這個問題:

  1. 直接處理 - 加鎖,即強制同步訪問。
  2. 間接處理 - 私有化欄位,即不給外面觀察到部分更新的機會。

如果這樣,就無法保證像陣列一樣的高效能,因此不能給欄位加鎖,也不能限制訪問(沒意義),另外對Span的訪問和寫入都是直接操作的記憶體,如果_reference_length出現不同步的情況,還會導致記憶體安全問題。

這也是為什麼span只能存在於棧上,即指標、資料、長度全都存於棧上,而不是引用存在堆,資料存在棧,因為span<T>不需要暫留,必須快取快用快放,否則就不要使用span。

備註:對於需要暫留到堆上的場景,它的解決方案是Memory<T>,大家可以繼續關注。

.NET庫的整合

為了支援輕鬆高效地處理 {ReadOnly}Span ,微軟向.NET添加了數百個新成員和型別。目前大多是基於陣列、字串和基元型別的方法的過載 ,除此之外,還包括一些專注於特定處理方面的全新型別,比如:System.IO.Pipelines。

下面是一些比較常用的擴充套件:

  1. 基元型別(虛擬碼)

    short.Parse(ReadOnlySpan<char> s);
    int.Parse(ReadOnlySpan<char> s);
    long.Parse(ReadOnlySpan<char> s);
    DateTime.Parse(ReadOnlySpan<char> s);
    TimeSpan.Parse(ReadOnlySpan<char> input);
    Guid.Parse(ReadOnlySpan<char> input);
  2. 字串

    public static ReadOnlySpan<char> AsSpan(this string text, int start, int length);
    public static ReadOnlySpan<char> AsSpan(this string text, int start);
    public static ReadOnlySpan<char> AsSpan(this string text);
  3. 陣列

    public static Span<T> AsSpan<T>(this T[] array, int start);
    public static Span<T> AsSpan<T>(this T[] array);
    public static Span<T> AsSpan<T>(this ArraySegment<T> segment, int start, int length);
    public static Span<T> AsSpan<T>(this ArraySegment<T> segment, int start);
    public static Span<T> AsSpan<T>(this T[] array, int start, int length);

最後使用上面的API演示一個官網的例子,解析字串"123,456"中的數字:

以前的寫法

var input = "123,456";
var commaPos = input.IndexOf(',');
var first = int.Parse(input.Substring(0, commaPos));// yes-Allocating, yes-Coping
var second = int.Parse(input.Substring(commaPos + 1));// yes-Allocating, yes-Coping

現在的寫法

var input = "123,456";
var inputSpan = input.AsSpan();
var commaPos = input.IndexOf(',');
var first = int.Parse(inputSpan.Slice(0, commaPos));// no-Allocating, no-Coping
var second = int.Parse(inputSpan.Slice(commaPos + 1));// no-Allocating, no-Coping

當然還是有許多這樣的方法,比如System.Random、System.Net.Socket、Utf8Formatter、Utf8Parser等,明白了它的脾氣秉性,對於具體的應用場景大家可以先自行查閱資料,相信認真讀完上篇、本篇的同學已經具備用好這把尖刀的能力了。

總結

本篇在上篇(理解span的本質)的基礎上,詳細講解span的特點和每種特點下的應用場景,希望大家能有所收穫。下一篇可能會講span的加強,以及在資料轉換方面的應用,比如:Data PipelinesDiscontinuous BuffersBuffer Pooling等,也可能會講Memory<T>,看到時候的準備吧,感興趣請繼續關注。

最後

如果有什麼疑問和見解,歡迎評論區交流。
如果你覺得本篇文章對您有幫助的話,感謝您的【推薦】。
如果你對高效能程式設計感興趣的話可以關注我,我會定期的在部落格分享我的學習心得。
歡迎轉載,請在明顯位置給出出處及連結

延伸閱讀

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Span.Fast.cs

https://github.com/dotnet/coreclr/blob/master/src/System.Private.CoreLib/shared/System/Span.cs

https://blogs.msdn.microsoft.com/dotnet/2017/10/16/ryujit-just-in-time-compiler-optimization-enhancements

https://adamsitnik.com/Hardware-Counters-Diagnoser/#how-to-get-it-running-for-net-coremono-on-windows