先談一下我對Span的看法, 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會在堆分配大量string小對象,對gc造成壓力;
case2對底層字符串切片,雖然會產生不同的透視對象Span, 但是實際還是指向的原始內存塊的偏移區間,不存在內存分配。
- ref struct:從C#7.2開始,ref可以作用在struct,指示該類型被分配在堆棧上,並且不能轉義到託管堆。
Span
,ReadonlySpan 包裝了對於任意連續內存快的透視操作,但是隻能被存儲堆棧上,不適用於一些場景,例如異步調用,.NET Core 2.1爲此新增了Memory , ReadOnlyMemory , 可以被存儲在託管堆上, 按下不表。
最後用一張圖總結