重新審視C# Span<T>資料結構
阿新 • • 發佈:2022-05-07
先談一下我對Span的看法, span是指向任意連續記憶體空間的型別安全、記憶體安全的檢視。
Span和Memory都是包裝了可以在pipeline上使用的結構化資料的記憶體緩衝器,他們被設計用於在pipeline中高效傳遞資料。
定語解讀
-
指向任意連續記憶體空間: 支援託管堆,原生記憶體、堆疊, 這個可從Span
的幾個過載建構函式窺視一二。 -
型別安全: Span
是一個泛型 -
記憶體安全: Span
是一個 readonly ref struct
資料結構, 用於表徵一段連續記憶體的關鍵屬性被設定成只讀readonly, 保證了所有的操作只能在這段記憶體內。
// 擷取自Span原始碼,表徵一段連續記憶體的關鍵屬性 Pointer & Length 都只能從建構函式賦值 public readonly ref struct Span<T> { /// <summary>A byref or a native ptr.</summary> internal readonly ByReference<T> _reference; /// <summary>The number of elements this Span contains.</summary> private readonly int _length; [MethodImpl(MethodImplOptions.AggressiveInlining)] public Span(T[]? array) { if (array == null) { this = default; return; // returns default } if (!typeof(T).IsValueType && array.GetType() != typeof(T[])) ThrowHelper.ThrowArrayTypeMismatchException(); _reference = new ByReference<T>(ref MemoryMarshal.GetArrayDataReference(array)); _length = array.Length; } }
- 檢視:操作結果會直接體現在底層的連續記憶體。
至此我們來看一個簡單的用法, 利用span操作指向一段堆疊空間。
static void Main() { Span<byte> arraySpan = stackalloc byte[100]; // 包含指標和Length的只讀指標, 類似於go裡面的切片 byte data = 0; for (int ctr = 0; ctr < arraySpan.Length; ctr++) arraySpan[ctr] = data++; arraySpan.Fill(1); var arraySum = Sum(arraySpan); Console.WriteLine($"The sum is {arraySum}"); // 輸出100 arraySpan.Clear(); var slice = arraySpan.Slice(0,50); // 因為是隻讀屬性, 內部New Span<>(), 產生新的切片 arraySum = Sum(slice); Console.WriteLine($"The sum is {arraySum}"); // 輸出0 } [MethodImpl(MethodImplOptions.AggressiveInlining)] static int Sum(Span<byte> array) { int arraySum = 0; foreach (var value in array) arraySum += value; return arraySum; }
- 此處Span
指向了特定的堆疊空間, Fill,Clear 等操作的效果直接體現到該段記憶體。 - 注意Slice切片方法,內部實質是產生新的Span,也是一個新的檢視,對新span的操作會體現到原始底層資料結構。
[MethodImpl(MethodImplOptions.AggressiveInlining)] public Span<T> Slice(int start) { if ((uint)start > (uint)_length) ThrowHelper.ThrowArgumentOutOfRangeException(); return new Span<T>(ref Unsafe.Add(ref _reference.Value, (nint)(uint)start /* force zero-extension */), _length - start); }
從Slice切片原始碼,看到利用現有的ptr 和length,產生了新的操作檢視,ptr的計算有賴於原ptr移動指標,但是依舊是作用在原始資料塊上。
衍生技能點
我們再細看Span的定義, 有幾個關鍵詞建議大家溫故而知新。
- readonly strcut :從C#7.2開始,你可以將readonly作用在struct上,指示該struct不可改變。
span
被定義為readonly struct,內部屬性自然也是readonly,從上面的分析和例項看我們可以針對Span表徵的特定連續記憶體空間做內容更新操作;
如果想限制更新該連續記憶體空間的內容, C#提供了ReadOnlySpan<T>
型別, 該型別強調該塊記憶體只讀,也就是不存在Span擁有的Fill,Clear等方法。
一線碼農大佬寫了文章講述[使用span對字串求和]的姿勢,大家都說使用span能高效操作記憶體,我們對該用例BenchmarkDotnet壓測。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Buffers;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
namespace ConsoleApp3
{
public class Program
{
static void Main()
{
var summary = BenchmarkRunner.Run<MemoryBenchmarkerDemo>();
}
}
[MemoryDiagnoser,RankColumn]
public class MemoryBenchmarkerDemo
{
int NumberOfItems = 100000;
// 對字串切割, 會產生字串小物件
[Benchmark]
public void StringSplit()
{
for (int i = 0; i < NumberOfItems; i++)
{
var s = "97 3";
var arr = s.Split(new string[] { " " }, StringSplitOptions.RemoveEmptyEntries);
var num1 = int.Parse(arr[0]);
var num2 = int.Parse(arr[1]);
_ = num1 + num2;
}
}
// 對底層字串切片
[Benchmark]
public void StringSlice()
{
for (int i = 0; i < NumberOfItems; i++)
{
var s = "97 3";
var position = s.IndexOf(' ');
ReadOnlySpan<char> span = s.AsSpan();
var num1 = int.Parse(span.Slice(0, position));
var num2 = int.Parse(span.Slice(position));
_= num1+ num2;
}
}
}
}
解讀:
對字串執行時切分,不會利用駐留池,於是case1會分配大量小物件;
case2對底層字串切片,雖然會產生不同的透視物件Span, 但是實際還是指向的原始記憶體塊的偏移區間,不存在記憶體分配。
- ref struct:從C#7.2開始,ref可以作用在struct,指示該型別被分配在堆疊上,並且不能轉義到託管堆。
Span
,ReadonlySpan 包裝了對於任意連續記憶體快的透視操作,但是隻能被儲存堆疊上,不適用於一些場景,例如非同步呼叫,.NET Core 2.1為此新增了Memory , ReadOnlyMemory , 可以被儲存在託管堆上, 按下不表。
最後用一張圖總結