添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接

其實 C# 的 Span<T> 結構已經問世很多年了,直到昨天有同事問我這個東西要怎麼用,我就把多年前做好的簡報給他研究,然後順便複習一下這個厲害的玩意。

什麼是 Span<T> 結構

Span<T> 結構是一個泛型結構,它可以用來表示一個 連續的記憶體區塊 ,而且它是一個 可變動 的結構,也就是說你可以透過它來修改記憶體中的資料。 Span<T> 結構是在 .NET Core 2.1 ( C# 7.2 ) 中推出的,它的目的是為了提供一個 高效率 的記憶體存取方式,避免經常在 Heap 配置新的物件,我們可以在不需要額外配置記憶體的情況下,直接存取記憶體中的資料。由於 Span<T> 可以直接存取記憶體,減少無意義的記憶體取用,相對的也會大幅減少 GC (Garbage Collector) 的發生,這對於提高效能和降低記憶體用量非常有幫助。

若要在 .NET Framework 4.6.1+ 使用 Span<T> 型別,必須安裝 System.Memory 套件。

簡單來說, Span<T> 提供一個 安全 有效率 的方式存取記憶體!

  • 安 全 :宣告記憶體空間後就無法存取超出範圍的記憶體
  • 有效率 :減少配置記憶體的動作發生
  • 幾個 Span<T> 使用範例

    我用一段簡短的程式碼來說明 Span<T> 結構的用法,他可以將一個 int[] 轉成 Span<int> 結構:

    // 建立一個整數陣列
    int[] numbers = { 1, 2, 3, 4, 5 };
    // 使用 Span<T> 建立一個切片(Slice)
    Span<int> slice = numbers.AsSpan();
    // 修改切片的內容,這也會修改原始陣列元素的內容 (因為直接存取記憶體的關係)
    slice[2] = 99;
    // 原始陣列的值也已經被修改
    Console.WriteLine(numbers[2]); // 輸出 99
    

    在 MemoryExtensions 靜態類別下有個 AsSpan() 擴充方法,支援將許多型別轉換成 Span<T> 結構。我再以一個 string 轉成 ReadOnlySpan<char> 的情境來說明:

    // 建立一個字串
    string name = "Will";
    // 使用 .AsSpan() 建立一個 ReadOnlySpan<T> 唯讀切片(Slice)
    ReadOnlySpan<char> slice = name.AsSpan();
    // 直接存取記憶體,不用另外建立一個字串物件
    Console.WriteLine(slice[0]); // 輸出 "W"
    

    Span<T> 真的很快嗎?

    我用 BenchmarkDotNet 對比了一下使用 Span<T> 切片與 StringSplit() 方法的效能。

    先看看我們用 StringSplit() 方法的程式碼:

    static string date = "2022-04-15";
    [Benchmark]
    public (string, string, string) ParseDateUsingSplit()
        var y = date.Split("-")[0];
        var m = date.Split("-")[1];
        var d = date.Split("-")[2];
        return (y, m, d);
    

    再看看使用 Span<T> 切片的程式碼:

    static string date = "2022-04-15";
    [Benchmark]
    public (string, string, string) ParseDateUsingReadOnlySpan()
        ReadOnlySpan<char> nameAsSpan = date.AsSpan();
        var y = nameAsSpan.Slice(0, 4);
        var m = nameAsSpan.Slice(5, 2);
        var d = nameAsSpan.Slice(8);
        return (y.ToString(), m.ToString(), d.ToString());
    

    以下是跑完效能比較的結果,使用 ReadOnlySpan<char> 的執行效率整整快了 4.7 倍:

    不過,如果我拿掉回傳字串的寫法,單純的比較字串處理效率的話,ReadOnlySpan<char> 的效率將會比 Split 操作快了 557 倍!

    static string date = "2024-09-19";
    [Benchmark]
    public void ParseDateUsingSplit()
        var y = date.Split("-")[0];
        var m = date.Split("-")[1];
        var d = date.Split("-")[2];
    [Benchmark]
    public void ParseDateUsingReadOnlySpan()
        ReadOnlySpan<char> nameAsSpan = date.AsSpan();
        var y = nameAsSpan.Slice(0, 4);
        var m = nameAsSpan.Slice(5, 2);
        var d = nameAsSpan.Slice(8);
    

    經過網友 土阿 的提醒,我加入了 Substring() 的效能評比,結果意外的發現,使用 Substring() 的處理方法,竟然比 ReadOnlySpan<char>ToString() 還快,還快了 1.26 倍!

    [Benchmark]
    public (string, string, string) ParseDateReturnString_UsingSubstring()
        var y = date.Substring(0, 4);
        var m = date.Substring(5, 2);
        var d = date.Substring(8, 2);
        return (y, m, d);
    [Benchmark]
    public (string, string, string) ParseDateReturnString_UsingReadOnlySpan()
        ReadOnlySpan<char> nameAsSpan = date.AsSpan();
        var y = nameAsSpan.Slice(0, 4);
        var m = nameAsSpan.Slice(5, 2);
        var d = nameAsSpan.Slice(8);
        return (y.ToString(), m.ToString(), d.ToString());
    

    我想應該是 ParseDateReturnString_UsingReadOnlySpan() 最後的輸出的 3 個字串,因為需要將 ReadOnlySpan<char> 轉成 3 個新的字串,需要配置全新的記憶體空間,最終導致了效能變差的結果!

    我們之所以要用 Span 或 ReadOnlySpan 結構,其目的不外乎就是有效率的去處理一段連續的記憶體區塊,處理的過程是非常快的,我們從上述的效能檢測看的出來,但是最終如果又會產生新的記憶體空間,那麼效能就會變差,所以我們在使用 Span 結構的時候,一定要注意不要產生新的記憶體空間,否則就會失去效能的優勢。

    接著,我又調整了一下寫法如下,利用 Int32.Parse() 方法,將字串轉成 Int32 型別。這次的評測結果就比較符合預期了,使用 ReadOnlySpan<char> 的版本快了 1.67 倍:

    [Benchmark]
    public (int, int, int) ParseDateReturnInt32_UsingSubstring()
        var y = date.Substring(0, 4);
        var m = date.Substring(5, 2);
        var d = date.Substring(8, 2);
        return (Int32.Parse(y), Int32.Parse(m), Int32.Parse(d));
    [Benchmark]
    public (int, int, int) ParseDateReturnInt32_UsingReadOnlySpan()
        ReadOnlySpan<char> nameAsSpan = date.AsSpan();
        var y = nameAsSpan.Slice(0, 4);
        var m = nameAsSpan.Slice(5, 2);
        var d = nameAsSpan.Slice(8);
        // 注意: Int32.Parse() 方法也有支援 ReadOnlySpan<char> 的重載方法
        return (Int32.Parse(y), Int32.Parse(m), Int32.Parse(d));
    

    由於 ParseDateReturnInt32_UsingReadOnlySpan() 方法,在處理字串時,一直都沒有產生新的字串物件,因此記憶體使用率較佳,效能也較好。

    自從 .NET Core 2.1 開始,之所以 .NET Core 在效能上能有大幅改進,其中 ref struct typesSpan 的出現功不可沒,事實上 .NET 的 BCL (Base Class Library) 已經有許多方法都採用了 Span<T>ReadOnlySpan<T> 結構,例如:

  • Int32.Parse
  • StringBuilder.Append
  • Path.GetFileName
  • Stream.Read
  • 所以之後有機會遇到陣列或字串操作,都可以先想到用 Span<T> 來處理,相信你會有意想不到的效能提升!

    Span 還有許多的跟記憶體相關的開發技巧,如果有興趣深入研究,可以參考本文的相關連結

  • Microsoft Learn
  • ref struct types - C# reference
  • Span Struct
  • ReadOnlySpan Struct (System)
  • MemoryExtensions.AsSpan 方法
  • Memory and spans - .NET | Microsoft Learn
  • Memory and Span usage guidelines
  • Medium
  • An Introduction to Writing High-Performance C# Using Span Struct | by Nishān Wickramarathna
  • .NET高性能编程 - C#如何安全、高效地玩转任何种类的内存之Span的本质(一)。 - justmine
  • 釐清 CLR、.NET、C#、Visual Studio、ASP.NET 各版本之間的關係
    長久以來,我發現有許多 .NET 開發人員其實不是很熟悉自己每天都在面對的 .NET Framework, C#, Visual Studio 與 ASP.NET 版本之間的關係,以至於經常在找資料時...
  • ASP.NET Core 所有可能用到的 ASPNETCORE_* 環境變數總整理
    我們在撰寫 .NET 的時候,有許多「組態設定」可以輕易的透過「環境變數」來進行調整或變更,這裡同時也包含了 ASP.NET Core 內建的許多 ASPNETCORE_ 開頭的內建環境變數名稱,可以
  • Windows Container 版本相容性與多重架構容器映像介紹
    自從 Windows 核心版本 v14393 開始,也就是 Windows Server 2016 LTSC 與 Windows 10 年度更新版,正式開始支援 Windows 容器,這意謂著企業可以
  • Microsoft Certified: Azure Solutions Architect Expert
  • Microsoft Certified: DevOps Engineer Expert
  • CKAD: Certified Kubernetes Application Developer
  •