在 C# 中使用 Span 和 Memory 編寫高性能程式碼
- 2022 年 8 月 22 日
- 筆記
- .NET Core / ASP.NET Core, c# 教程
在 C# 中使用 Span 和 Memory 編寫高性能程式碼
原作者:Joydip Kanjilal
原文地址://www.codemag.com/Article/2207031/Writing-High-Performance-Code-Using-SpanT-and-MemoryT-in-C
本文採用半譯方式。
在本文中,將會介紹 C# 7.2 中引入的新類型:Span 和 Memory,文章深入研究 Span<T>
和 Memory<T>
,並演示如何在 C# 中使用它們。
本文所有程式碼用例在 .NET 6.0 下運行。
.NET 中支援的記憶體類型
.NET 中,開發者能夠使用的三種記憶體類型,分別是:
- Stack memory 堆棧記憶體: 駐留在堆棧中,並使用
stackalloc
關鍵詞分配; - Managed memory 託管記憶體: 駐留在堆中並由 GC 管理;
- Unmanaged memory 非託管記憶體: 駐留在非託管堆中,並通過調用
Marshal.AllocHGlobal
or 或者Marshal.AllocCoTaskMem
方法 分配;
.NET Core 2.1 中新增的類型
.NET Core 2.1 中新引入的類型包括:
-
System.Span: 這以類型安全和記憶體安全的方式表示任意記憶體的連續部分;
-
System.ReadOnlySpan: 這表示任意連續記憶體區域的類型安全和記憶體安全只讀表示形式;
-
System.Memory: 這表示一個連續的記憶體區域;
-
System.ReadOnlyMemory: 類似
ReadOnlySpan
, 此類型表示記憶體的連續部分ReadOnlySpan
, 它不是 ByRef 類型;譯者註:
ByRef
類型指的是ref readonly struct
。
訪問連續記憶體: Span 和 Memory
開發者可能經常需要在應用程式中處理大量數據,例如字元串處理在任何應用程式中都是至關重要的,因此開發者必須遵循推薦的實踐以避免不必要的分配。開發者可以使用不安全的程式碼塊和指針直接操作記憶體,但是這種方法有相當大的風險,指針操作容易出現錯誤,如溢出、空指針訪問、緩衝區溢出和懸空指針。如果 bug 隻影響堆棧或靜態記憶體區域,那麼它將是無害的,但是如果它影響關鍵的系統記憶體區域,則可能導致應用程式崩潰。
因此,出現了 Span<T>
和 Memory<T>
,能夠以安全的方式使用指針訪問記憶體。
Span<T>
和 Memory<T>
是 .NET 新引入的類型(都是 struct
),它們提供了一種類型安全的方法來訪問任意記憶體的連續區域。Span<T>
和 Memory<T>
都是 System 命名空間的一部分,表示連續的記憶體塊,沒有任何複製語義。
C# 新版本添加了 Span<T>
、 Memory<T>
、 ReadOnlySpan
和 ReadOnlyMemory
類型 ,它們可以幫助開發者在安全和性能方面直接使用記憶體。
這些新類型在 System.Memory
命名空間中,適用於需要處理大量數據或希望避免不必要的記憶體分配(例如在使用緩衝區時)的高性能場景。與在 GC 堆上分配記憶體的數組類型不同,這些新類型提供了對任意託管或本機記憶體的連續區域的抽象,而不需要在 GC 堆上分配記憶體。
譯者註:因為它們都是 struct,會被分配到棧中。
Span<T>
和 Memory<T>
結構體為數組、字元串或任何連續的託管或非託管記憶體塊提供低級介面,它們的主要功能是促進微優化和編寫低分配程式碼,以減少託管記憶體分配,從而減少垃圾收集器的負擔。它們還允許切片或處理數組、字元串或記憶體塊的某個部分,而無需複製原始記憶體塊。Span<T>
和 Memory<T>
在高性能領域非常有用,例如 ASP.NET Core 6 request-processing 管道。
Span 介紹
Span<T>
(早期稱為 Slice) 出現於 C# 7.2/NET Core 2.1,創建它的開銷幾乎為零,它提供了一種使用連續記憶體塊的類型安全方法,例如:
- Arrays and subarrays 數組和子數組
- Strings and substrings 字元串和子字元串
- Unmanaged memory buffers 非託管記憶體緩衝區
Span 類型表示駐留在託管堆、堆棧甚至非託管記憶體中的連續記憶體塊,如果創建一個基元類型的數組(使用 stackalloc
創建),它將在堆棧上分配,並且不需要垃圾回收來管理其生存期。Span<T>
能夠指向分配給堆棧或堆上的記憶體塊。但是,因為 Span<T>
被定義為 ref 結構,所以它應該只駐留在堆棧上。
以下是一目了然的 Span<T>
的特徵:
- Value type 值類型
- Low or zero overhead 低或零開銷
- High performance 高性能
- Provides memory and type safety 提供記憶體和類型安全
開發者可以將 Span 與下列任一項一起使用
- Arrays
- Strings
- Native buffers 本地緩衝區
可以轉換為 Span<T>
的類型列表如下:
- Arrays
- Pointers 指針
- IntPtr 指針
- stackalloc
開發者可以將以下所有內容轉換為 ReadOnlySpan<T>
:
- Arrays
- Pointers 指針
- IntPtr 指針
- stackalloc
- string
Span<T>
是一個僅堆棧類型, 準確地說它是一個 ByRef 類型。因此,既不能將 span 裝箱,也不能顯示為僅限堆棧類型的欄位,也不能在泛型參數中使用它們。但是,可以使用 span 來表示返回值或方法參數。請參考下面給出的程式碼片段,它說明了 Span 結構的完整源程式碼:
public readonly ref struct Span<T>
{
internal readonly
ByReference<T> _pointer;
private readonly int _length;
//Other members
}
因為 Span 定義時,是 public readonly ref struct Span<T>
,表示只能在堆棧中分配。
開發者可以在這裡查看 struct Span<T>
的完整源程式碼: //github.com/dotnet/corefx/blob/master/src/common/src/corelib/system/Span.cs。
Span<T>
源程式碼顯示它基本上包含兩個只讀欄位: 一個本機指針和一個長度屬性,表示 Span 包含的元素數。
Span 的使用方式與數組相同,但是與數組不同,它可以引用堆棧記憶體,即堆棧上分配的記憶體、託管記憶體和本機記憶體。這為開發者提供了一種簡單的方法來利用以前只有在處理非託管程式碼時才能獲得的性能改進。
若要創建空的 Span,可以使用 Span.Empty 屬性:
Span<char> span = Span<char>.Empty;
下面的程式碼片段演示如何在託管記憶體中創建 Byte 數組,然後從中創建 span 實例。
var array = new byte[100];
var span = new Span<byte>(array);
C# 中的 Span
下面是如何在堆棧中分配一塊記憶體並使用 Span 指向它:
Span<byte> span = stackalloc byte[100];
下面的程式碼片段顯示了如何使用位元組數組創建 Span、如何將整數存儲在位元組數組中以及如何計算存儲的所有整數的總和。
var array = new byte[100];
var span = new Span<byte>(array);
byte data = 0;
for (int index = 0; index < span.Length; index++)
span[index] = data++;
int sum = 0;
foreach (int value in array)
sum += value;
下面的程式碼片段從本機記憶體(非託管記憶體)創建一個 Span:
var nativeMemory = Marshal.AllocHGlobal(100);
Span<byte> span;
unsafe
{
span = new Span<byte>(nativeMemory.ToPointer(), 100);
}
現在可以使用下面的程式碼片段在 Span 指向的記憶體中存儲整數,並顯示存儲的所有整數的總和:
byte data = 0;
for (int index = 0; index < span.Length; index++)
span[index] = data++;
int sum = 0;
foreach (int value in span)
sum += value;
Console.WriteLine ($"The sum of the numbers in the array is {sum}");
Marshal.FreeHGlobal(nativeMemory);
還可以使用 stackalloc 關鍵字在堆棧記憶體中分配 Span,如下所示:
byte data = 0;
Span<byte> span = stackalloc byte[100];
for (int index = 0; index < span.Length; index++)
span[index] = data++;
int sum = 0;
foreach (int value in span)
sum += value;
Console.WriteLine ($"The sum of the numbers in the array is {sum}");
需要開啟不安全程式碼設置。
Span 和 Arrays
切片允許將數據視為邏輯塊,然後可以以最小的資源開銷處理這些邏輯塊。Span<T>
可以包裝整個數組,因為它支援切片,所以可以讓它指向數組中的任何連續區域。下面的程式碼片段顯示了如何使用 Span<T>
指向數組中由三個元素組成的片段。
int[] array = new int[] { 1, 2, 3, 4, 5, 6, 7, 8, 9 } ;
Span<int> slice = new Span<int>(array, 2, 3);
作為 Span<T>
struct 的一部分,Slice 方法有兩個重載,允許基於索引創建,這允許將Span<T>
數據作為一系列邏輯塊來處理,這些邏輯塊可以單獨處理,也可以按照數據處理流水線的各個部分的要求來處理。
開發者可以使用 Span<T>
來包裝整個數組。因為它支援切片,所以它不僅可以指向數組的第一個元素,還可以指向數組中任何連續的元素範圍。
foreach (int i in slice)
Console.WriteLine($"{i} ");
執行前面的程式碼片段時,分片數組中的整數將顯示在控制台上,如圖2所示。
Span 和 ReadOnlySpan
ReadOnlySpan<T>
實例通常用於引用數組項或數組的塊。與數組不同,ReadOnlySpan<T>
實例可以引用本機記憶體、託管記憶體或堆棧記憶體。Span<T>
和 ReadOnlySpan<T>
都提供了連續記憶體區域的類型安全表示, Span<T>
提供對記憶體區域的讀寫訪問, ReadOnlySpan<T>
提供對記憶體段的只讀訪問。
下面的程式碼片段說明了如何使用 ReadOnlySpan 在 C# 中切割字元串的一部分:
ReadOnlySpan<char> readOnlySpan = "This is a sample data for testing purposes.";
int index = readOnlySpan.IndexOf(' ');
var data = ((index < 0) ?
readOnlySpan : readOnlySpan.Slice(0, index)).ToArray();
Memory 入門
Memory<T>
是一個引用類型,它表示記憶體的一個連續區域,具有一個長度,但不一定從索引0開始,可以是另一個 Memory 中的許多區域之一。由 Memory 表示的記憶體甚至可能不是開發者自己的進程,因為它可能已經在非託管程式碼中分配。記憶體對於表示非連續緩衝區中的數據非常有用,因為它允許開發者像對待單個連續緩衝區一樣對待它們,而不需要進行複製。
以下是 Memory
public struct Memory<T>
{
void* _ptr;
T[] _array;
int _offset;
int _length;
public Span<T> Span => _ptr == null ? new Span<T>(_array, _offset, _length) : new Span<T>(_ptr, _length);
}
除了包含 Span<T>
功能外,Memory<T>
還提供了一個安全的、可切片的視圖,可以進入任何連續的緩衝區,無論是數組還是字元串。與 Span<T>
不同,它沒有僅限於堆棧的約束,因為它不是類似於 ref 的類型。因此,開發者可以將它放在堆上,在集合中或非同步等待中使用它,將它保存為欄位或裝箱,就像對待任何其他 C# 結構一樣。
當需要修改或處理 Memory<T>
引用的緩衝區時,Span<T>
屬性允許開發者獲得高效的索引功能。相反,Memory<T>
是一種比 SpanReadOnlyMemory<T>
的不可變的只讀對應物。
儘管 Span<T>
和 Memory<T>
都代表一個連續的記憶體塊,但與 Span<T>
不同,Memory<T>
不是一個 ref 結構。因此,與 Span<T>
相反,開發者可以在託管堆上的任何位置使用 Memory<T>
。因此,在 Memory<T>
中沒有與 Span<T>
中相同的限制,開發者可以使用 Memory<T>
作為類欄位,並且可以跨 await 和 yield 邊界(下面會說到)。
ReadOnlyMemory
與 ReadOnlySpan<T>
類似,ReadOnlyMemory<T>
表示對連續記憶體區域的只讀訪問,但與 ReadOnlySpan<T>
不同,它不是 ByRef 類型。
現在請參考下面的字元串,其中包含由空格字元分隔的國家名稱。
string countries = "India Belgium Australia USA UK Netherlands";
var countries = ExtractStrings("India Belgium Australia USA UK Netherlands".AsMemory());
通過提取字元串的方法提取每個國家的名稱,如下所示:
public static IEnumerable<ReadOnlyMemory <char>> ExtractStrings(ReadOnlyMemory<char> c)
{
int index = 0, length = c.Length;
for (int i = 0; i < length; i++)
{
if (char.IsWhiteSpace(c.Span[i]) || i == length)
{
yield return c[index..i];
index = i + 1;
}
}
}
開發者可以調用上述方法,並使用以下程式碼片段在控制台窗口中顯示國家名稱:
var data = ExtractStrings(countries.AsMemory());
foreach(var str in data)
Console.WriteLine(str);
Span 和 Memory 的優勢
使用 Span 和 Memory 類型的主要優點是提高了性能。開發者可以通過使用 stackalloc 關鍵字來分配堆棧上的記憶體,該關鍵字分配一個未初始化的塊,該塊是 T[size]
類型的實例。如果開發者的數據已經在堆棧上,則不需要這樣做,但是對於大型對象,這樣做很有用,因為以這種方式分配的數組只有在其作用域持續存在時才存在。如果使用堆分配的數組,可以通過 Slice()
這樣的方法傳遞它們,並在不複製任何數據的情況下創建視圖。
這裡還有一些好處:
- 它們減少了垃圾收集器的分配數量。它們還減少了數據的副本數量,並提供了一種更有效的方法來同時處理多個緩衝區;
- 它們允許開發者編寫高性能程式碼。例如,如果開發者有一大塊記憶體需要分成小塊,那麼使用 Span 作為原始記憶體的視圖。這允許開發者的應用程式直接從原始緩衝區訪問位元組,而無需複製;
- 它們允許開發者直接訪問記憶體而無需複製記憶體。這在使用本機庫或與其他語言進行互操作時特別有用;
- 它們允許開發者在性能至關重要的緊密循環(如加密或網路包檢查)中消除邊界檢查;
- 它們允許開發者消除與通用集合(如 List)相關的裝箱和取消裝箱成本;
- 通過使用單一數據類型(Span)而不是兩種不同類型(Array 和 ArraySegment) ,它們可以編寫更容易理解的程式碼;
連續和非連續記憶體緩衝區
連續記憶體緩衝區是將數據保存在順序相鄰位置的記憶體塊,換句話說,所有的位元組在記憶體中都是相鄰的。數組表示連續的記憶體緩衝區。
例如:
int[] values = new int[5];
上面示例中的五個整數將從第一個元素(值[0])開始,按順序放置在記憶體中的五個位置。
與連續緩衝區不同,開發者可以使用非連續緩衝區來處理多個數據塊並不相鄰的情況,或者在使用非託管程式碼時使用非連續緩衝區,Span 和 Memory 類型是專門為非連續緩衝區設計的,並提供了使用它們的方便方法。
非連續的記憶體區域不能保證元素以任何特定的順序存儲,也不能保證元素在記憶體中緊密地存儲在一起。非連續緩衝區(如 ReadOnlySequence (與段一起使用時))駐留在記憶體的單獨區域中,這些區域可能分散在堆中,不能被單個指針訪問。
例如,IEnumable 是非連續的,因為在開發者逐個枚舉每個項之前,無法知道下一個項將在哪裡。為了表示段之間的這些間隔,必須使用附加數據來跟蹤每個段的開始和結束位置。
不連續的緩衝區: ReadOnly 序列
讓作者們假設開發者正在使用一個不連續的緩衝區。例如,數據可能來自網路流、資料庫調用或文件流。這些場景中的每一個都可以有多個大小不同的緩衝區。一個 ReadOnlySequence 實例可以包含一個或多個記憶體段,每個段可以有自己的 Memory 實例。因此,單個 ReadOnlySequence 實例可以更好地管理可用記憶體,並提供比許多串聯記憶體實例更好的性能。
開發者可以使用 SequenceReader 類上的工廠方法 Create()
以及 AsReadOnlySequence()
等其他方法創建 ReadOnlySequence 實例。Create()
方法有幾個重載,允許開發者傳入 byte []
或 ArraySegment
、位元組數組序列(IEnumable)或 IReadOnlyCollection
/IReadOnlyList/IList
/ ICollection
位元組數組集合(byte []
)和 ArraySegment
。
開發者現在知道 Span<T>
和 Memory<T>
提供了對連續記憶體緩衝區(如數組)的支援。系統。緩衝區命名空間包含一個名為 ReadOnlySequense<T>
的結構,該結構支援處理不連續的記憶體緩衝區。下面的程式碼片段說明了如何在 C# 中使用 ReadOnlySequence<T>
:
int[] array = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
var readOnlySequence = new ReadOnlySequence<int>(array);
var slicedReadOnlySequence = readOnlySequence.Slice(1, 5);
開發者也可以使用 ReadOnlyMemory<T>
,如下所示:
int[] array = { 1, 2, 3, 4, 5, 6, 7, 8, 9 };
ReadOnlyMemory<int> memory = array;
var readOnlySequence = new ReadOnlySequence<int>(memory);
var slicedReadOnlySequence = readOnlySequence.Slice(1, 5);
實際場景
現在讓作者們來談談一個現實中的場景,以及 Span<T>
和 Memory<T>
是如何起作用的。請考慮以下字元串數組,其中包含從日誌文件檢索到的日誌數據:
string[] logs = new string[]
{
"a1K3vlCTZE6GAtNYNAi5Vg::05/12/2022 09:10:00 AM:://localhost:2923/api/customers/getallcustomers",
"mpO58LssO0uf8Ced1WtAvA::05/12/2022 09:15:00 AM:://localhost:2923/api/products/getallproducts",
"2KW1SfJOMkShcdeO54t1TA::05/12/2022 10:25:00 AM:://localhost:2923/api/orders/getallorders",
"x5LmCTwMH0isd1wiA8gxIw::05/12/2022 11:05:00 AM:://localhost:2923/api/orders/getallorders",
"7IftPSBfCESNh4LD9yI6aw::05/12/2022 11:40:00 AM:://localhost:2923/api/products/getallproducts"
};
請記住,開發者可以擁有數百萬條日誌記錄,因此性能至關重要。這個示例只是從大量日誌數據中提取的日誌數據。每個行的數據由 HTTP 請求 ID、 HTTP 請求的 DateTime 和端點 URL 組成。現在假設開發者需要從這些數據中提取請求 ID 和端點 URL。
開發者需要一個高性能的解決方案。如果使用 String 類的 Substring 方法,就會創建許多字元串對象,這也會降低應用程式的性能。最好的解決方案是在這裡使用 Span<T>
來避免分配。解決這個問題的方法是使用 Span<T>
和 Slice 方法,如下一節所示。
Benchmarking 基準測試
是時候測量一下了。現在讓作者們對 Span<T>
struct 與 String 類的 Substring 方法的性能進行基準測試。
安裝 NuGet 包
目前為止還不錯。下一步是安裝必要的 NuGet 包。要將所需的包安裝到項目中,右鍵單擊解決方案並選擇 Manage NuGet Packages for Solution...
。現在在搜索框中搜索名為 BenchmarkDotNet 的軟體包並安裝它。或者,開發者也可以在NuGet Package Manager
命令提示符下鍵入以下命令:
PM> Install-Package BenchmarkDotNet
Benchmarking Span
現在讓作者們研究一下如何對 Substring 和 Slice 方法的性能進行基準測試。使用清單1中的程式碼創建一個名為 BenchmarkPerformance 的新類。開發者應該注意在 GlobalSetup 方法中如何設置數據以及 GlobalSetup 屬性的用法。
[MemoryDiagnoser]
[Orderer(BenchmarkDotNet.Order.SummaryOrderPolicy.FastestToSlowest)]
[RankColumn]
public class BenchmarkPerformance
{
[Params(100, 200)]
public int N;
string countries = null;
int index, numberOfCharactersToExtract;
[GlobalSetup]
public void GlobalSetup()
{
countries = "India, USA, UK, Australia, Netherlands, Belgium";
index = countries.LastIndexOf(",",StringComparison.Ordinal);
numberOfCharactersToExtract = countries.Length - index;
}
}
現在,編寫名為 Substring 和 Span 的兩個方法,如清單2所示。前者使用 String 類的 Substring 方法檢索最後一個國家名稱,而後者使用 Slice 方法提取最後一個國家名稱。
[Benchmark]
public void Substring()
{
for(int i = 0; i < N; i++)
{
var data = countries.Substring(index + 1, numberOfCharactersToExtract - 1);
}
}
[Benchmark(Baseline = true)]
public void Span()
{
for(int i=0; i < N; i++)
{
var data = countries.AsSpan().Slice(index + 1, numberOfCharactersToExtract - 1);
}
}
執行基準測試
class Program
{
static void Main()
{
BenchmarkRunner.Run<BenchmarkPerformance>();
}
}
若要執行基準測試,請將項目的編譯模式設置為「發布」,並在項目文件所在的同一文件夾中運行以下命令:
dotnet run -c Release
下圖顯示了基準測試的執行結果。
解讀基準測試結果
如上一小節的圖所示,在使用 Slice 方法提取字元串時,絕對沒有分配。對於每個基準測試方法,都會生成一行結果數據。因為有兩個基準測試方法,所以有兩行基準測試結果數據。基準測試結果顯示了平均執行時間、 Gen0集合和分配的記憶體。從基準測試結果中可以明顯看出,Span 比 Substring 方法快7.5倍以上(譯者圖中的結果是9倍)。
Span 限制
Span<T>
是僅堆棧的,這意味著它不適合在堆上存儲對緩衝區的引用,例如在執行非同步調用的常式中。它不在託管堆中分配,而是在堆棧中分配,並且它不支援裝箱以防止升級到託管堆。不能將 Span<T>
用作泛型類型,但可以將其用作 ref 結構中的欄位類型。不能將 Span<T>
賦給動態類型、對象類型或任何其他介面類型的變數。不能在引用類型中使用 Span<T>
作為欄位,也不能跨等待和產生邊界使用它。此外,由於 Span<T>
不繼承 IEnumable,因此不能對其使用 LINQ。
需要注意的是,類中不能有 Span<T>
欄位,不能創建 Span<T>
數組,也不能包含 Span<T>
實例。注意, Span<T>
和Memory<T>
都沒有實現 IEnumable<T>
,因此,開發者將無法使用 LINQ 與這兩者操作。但是,開發者可以利用 SpanLinq 、 NetFabric、Hyperlinq 庫來繞過這個限制。
結論
在本文中,作者研究了 Span<T>
和 Memory<T>
的特性和優點,以及如何在應用程式中實現它們。作者還討論了一個實際場景,其中可以使用 SpanSpan<T>
比 Memory<T>
更多才多藝,性能也更好,但它並不能完全取代它。
封面: