其實 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>
切片與 String
的 Split()
方法的效能。
先看看我們用 String
的 Split()
方法的程式碼:
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 types 與 Span 的出現功不可沒,事實上 .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