.NET高性能編程 - C#如何安全、高效地玩轉任何種類的內存之Span的秉性特色(二)。

前言

讀完上篇《通俗易懂,C#如何安全、高效地玩轉任何種類的內存之Span的本質(一)。》,相信你們對span的本質應該很是清楚了。含着金鑰匙出生的它,從小就被寄予厚望要成爲.NET下編寫高性能應用程序的重要積木,並且不少老前輩爲了接納它,都紛紛作出了改變,好比String、Int、Array。如今,它長大了,已經成爲.NET下發揮關鍵做用的新值類型和旗艦成員。html

那咱們又該如何接納它呢?git

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

脾氣秉性 - 特色

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支持在簽名中直接返回引用(至關於直接整合了這個過程),這樣就無需經過計算來肯定指針開頭及其起始偏移,從而真正擁有和訪問數組同樣高的效率,以下:c#

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

這種只包含兩個字段的span就叫Fast spanwindows

在全部的.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。CLR和編譯器會自動檢測Stack-Only約束。

因此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);
    public static String Create<TState>(int length, TState state, SpanAction<char, TState> action);
  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);
  4. Guid

    public static bool TryParse(ReadOnlySpan<char> input, out Guid result);
    public bool TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format = default (ReadOnlySpan<char>));

最後使用上面的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只能駐留到棧上,完美解決了如下的問題:

  1. 更高效地內存訪問,快取快用快放的自然保障
  2. 更高效地GC跟蹤
  3. 併發內存安全

備註:正是因爲Stack-Only這個特色,在底層數據訪問、轉換以及同步處理方面,Span性能很是出色。

此外,本篇還在上篇的基礎上,詳細講解span的脾氣秉性,以及每種特色下的非法應用場景,一切都是爲了你們可以在.NET 程序中使用span高效安全地訪問內存,但願你們能有所收穫。下一篇可能會講span的增強,也可能會講它在數據轉換以及同步處理方面的應用,好比:Data Pipelines、Discontinuous Buffers、Buffer Pooling等,也可能會講Memory<T>,感興趣請繼續關注。

最後

若是有什麼疑問和看法,歡迎評論區交流。
若是你以爲本篇文章對您有幫助的話,感謝您的【推薦】。
若是你對高性能編程感興趣的話能夠關注我,我會按期的在博客分享個人學習心得。
歡迎轉載,請在明顯位置給出出處及連接

延伸閱讀

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

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

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://docs.microsoft.com/en-us/dotnet/csharp/write-safe-efficient-code

相關文章
相關標籤/搜索