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

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

前言

作為.net程式設計師,使用過指標,寫過不安全程式碼嗎?

為什麼要使用指標,什麼時候需要使用它?

如果能很好地回答這兩個問題,那麼就能很好地理解今天了主題了。C#構建了一個託管世界,在這個世界裡,只要不寫不安全程式碼,不操作指標,那麼就能獲得.Net至關重要的安全保障,即什麼都不用擔心;那如果我們需要操作的資料不在託管記憶體中,而是來自於非託管記憶體,比如位於本機記憶體或者堆疊上,該如何編寫程式碼支援來自任意區域的記憶體呢?這個時候就需要寫不安全程式碼,使用指標了;而如何安全、高效地操作任何型別的記憶體,一直都是C#的痛點,今天我們就來談談這個話題,講清楚 What、How 和 Why ,讓你知其然,更知其所以然,以後有人問你這個問題,就讓他看這篇文章吧,呵呵。

what - 痛點是什麼?

回答這個問題前,先總結一下如何用C#操作任何型別的記憶體:

  1. 託管記憶體(managed memory )

    var mangedMemory = new Student();

    很熟悉吧,只需使用new操作符就分配了一塊託管記憶體,而且還不用手工釋放它,因為它是由垃圾收集器(GC)管理的,GC會智慧地決定何時釋放它,這就是所謂的託管記憶體。預設情況下,GC通過複製記憶體的方式分代管理小物件(size < 85000 bytes),而專門為大物件(size >= 85000 bytes)開闢大物件堆(LOH),管理大物件時,並不會複製它,而是將其放入一個列表,提供較慢的分配和釋放,而且很容易產生記憶體碎片。

  2. 棧記憶體(stack memory )

    unsafe{
        var stackMemory = stackalloc byte[100];
    }

    很簡單,使用stackalloc關鍵字非常快速地就分配好了一塊記憶體,也不用手工釋放,它會隨著當前作用域而釋放,比如方法執行結束時,就自動釋放了。棧記憶體的容量非常小( ARM、x86 和 x64 計算機,預設堆疊大小為 1 MB),當你使用棧記憶體的容量大於1M時,就會報StackOverflowException 異常 ,這通常是致命的,不能被處理,而且會立即幹掉整個應用程式,所以棧記憶體一般用於需要小記憶體,但是又不得不快速執行的大量短操作,比如微軟使用棧記憶體來快速地記錄ETW事件日誌。

  3. 本機記憶體(native memory )

    IntPtr nativeMemory0 = default(IntPtr), nativeMemory1 = default(IntPtr);
    try
    {
        unsafe
        {
            nativeMemory0 = Marshal.AllocHGlobal(256);
            nativeMemory1 = Marshal.AllocCoTaskMem(256);
        }
    }
    finally
    {
        Marshal.FreeHGlobal(nativeMemory0);
        Marshal.FreeCoTaskMem(nativeMemory1);
    }

    通過呼叫方法Marshal.AllocHGlobalMarshal.AllocCoTaskMem來分配非託管記憶體,非託管就是垃圾回收器(GC)不可見的意思,並且還需要手工呼叫方法Marshal.FreeHGlobal or Marshal.FreeCoTaskMem 釋放它,千萬不能忘記,不然就產生記憶體碎片了。

拋磚引玉 - 痛點

首先我們設計一個解析完整或部分字串為整數的API,如下

public interface IntParser
{
    // allows us to parse the whole string.
    int Parse(string managedMemory);

    // allows us to parse part of the string.
    int Parse(string managedMemory, int startIndex, int length);

    // allows us to parse characters stored on the unmanaged heap / stack.
    unsafe int Parse(char* pointerToUnmanagedMemory, int length);

    // allows us to parse part of the characters stored on the unmanaged heap / stack.
    unsafe int Parse(char* pointerToUnmanagedMemory, int startIndex, int length); 
}

從上面可以看到,為了支援解析來自任何記憶體區域的字串,一共寫了4個過載方法。

接下來在來設計一個支援複製任何記憶體塊的API,如下

public interface MemoryblockCopier
{
    void Copy<T>(T[] source, T[] destination);
    void Copy<T>(T[] source, int sourceStartIndex, T[] destination, int destinationStartIndex, int elementsCount);
    unsafe void Copy<T>(void* source, void* destination, int elementsCount);
    unsafe void Copy<T>(void* source, int sourceStartIndex, void* destination, int destinationStartIndex, int elementsCount);
    unsafe void Copy<T>(void* source, int sourceLength, T[] destination);
    unsafe void Copy<T>(void* source, int sourceStartIndex, T[] destination, int destinationStartIndex, int elementsCount);
}

腦袋蒙圈沒,以前C#操縱各種記憶體就是這麼複雜、麻煩。通過上面的總結如何用C#操作任何型別的記憶體,相信大多數同學都能夠很好地理解這兩個類的設計,但我心裡是沒底的,因為使用了不安全程式碼和指標,這些操作是危險的、不可控的,根本無法獲得.net至關重要的安全保障,並且可能還會有難以預估的問題,比如堆疊溢位、記憶體碎片、棧撕裂等等,微軟的工程師們早就意識到了這個痛點,所以span誕生了,它就是這個痛點的解決方案

how - span如何解決這個痛點?

先來看看,如何使用span操作各種型別的記憶體(虛擬碼):

  1. 託管記憶體(managed memory )

    var managedMemory = new byte[100];
    Span<byte> span = managedMemory;
  2. 棧記憶體(stack memory )

    var stackedMemory = stackalloc byte[100];
    var span = new Span<byte>(stackedMemory, 100);
  3. 本機記憶體(native memory )

    var nativeMemory = Marshal.AllocHGlobal(100);
    var nativeSpan = new Span<byte>(nativeMemory.ToPointer(), 100);

span就像黑洞一樣,能夠吸收來自於記憶體任意區域的資料,實際上,現在,在.Net的世界裡,Span就是所有型別記憶體的抽象化身,表示一段連續的記憶體,它的API設計和效能就像陣列一樣,所以我們完全可以像使用陣列一樣地操作各種記憶體,真的是太方便了。

現在重構上面的兩個設計,如下:

public interface IntParser
{
    int Parse(Span<char> managedMemory);
    int Parse(Span<char>, int startIndex, int length);
}
public interface MemoryblockCopier
{
    void Copy<T>(Span<T> source, Span<T> destination); 
    void Copy<T>(Span<T> source, int sourceStartIndex, Span<T> destination, int destinationStartIndex, int elementsCount);
}

上面的方法根本不關心它操作的是哪種型別的記憶體,我們可以自由地從託管記憶體切換到本機程式碼,再切換到堆疊上,真正的享受玩轉記憶體的樂趣。

why - 為什麼span能解決這個痛點?

淺析span的工作機制

先來窺視一下原始碼:

我已經圈出的三個欄位:偏移量、索引、長度(使用過ArraySegment<byte> 的同學可能已經大致理解到設計的精髓了),這就是它的主要設計,當我們訪問span表示的整體或部分記憶體時,內部的索引器會按照下面的演算法運算指標(虛擬碼):

ref T this[int index]
{
    get => ref ((ref reference + byteOffset) + index * sizeOf(T));
}

整個變化的過程,如圖所示:

上面的動畫非常清楚了吧,舊span整合它的引用和偏移成新的span的引用,整個過程並沒有複製記憶體,而是直接返回引用,因此效能非常高,因為新span獲得並更新了引用,所以垃圾回收器(GC)知道如何處理新的span,從而獲得了.Net至關重要的安全保障,而這些都是span內部默默完成的,開發人員根本不用擔心,非託管世界依然美好。
正是由於span的高效能,目前很多基礎設施都開始支援span,甚至使用span進行重構,比如:System.String.Substring方法,我們都知道此方法是非常消耗效能的,首先會建立一個新的字串,然後在複製原始字串的字符集給它,而使用span可以實現Non-Allocating、Zero-coping,下面是我做的一個基準測試:

使用String.SubString和Span.Slice分別擷取長度為10和1000的字串前一半,從中指標Mean可以看出方法SubString的耗時隨著字串長度呈線性增長,而Slice幾乎保持不變;從指標Allocated Memory/Op可以看出,方法Slice並沒有被分配新的記憶體,實踐出真知,可以預見Span未來將會成為.Net下編寫高效能應用程式的重要積木,應用前景也會非常地廣,微服務、物聯網都是它發光發熱的好地方。

總結

看完本篇部落格,應該對Span的What、Why、How瞭如指掌了,那麼我的目的就達到了,不懂的同學可以多讀幾遍,下一篇,我將會暢談Span的應用場景、優缺點,讓大家能夠安全高效地使用好它,大家也可以在評論留言自己的應用場景,我會在寫下一篇部落格時多多參考。

最後

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

延伸閱讀