站在前人的肩膀上重新透視C# Span<T>數(shù)據(jù)結(jié)構(gòu)
先談一下我對(duì)Span的看法, Span是指向任意連續(xù)內(nèi)存空間的類(lèi)型安全、內(nèi)存安全的視圖。
Span和Memory都是包裝了可以在pipeline上使用的結(jié)構(gòu)化數(shù)據(jù)的內(nèi)存緩沖器,他們被設(shè)計(jì)用于在pipeline中高效傳遞數(shù)據(jù)。
定語(yǔ)解讀
這里面許多定語(yǔ),值得我們細(xì)細(xì)揣摩:
1. 指向任意連續(xù)內(nèi)存空間:支持托管堆,原生內(nèi)存、堆棧, 這個(gè)可從Span的幾個(gè)重載構(gòu)造函數(shù)窺視一二。
2. 類(lèi)型安全:Span 是一個(gè)泛型。
3. 內(nèi)存安全: Span[1]是一個(gè)readonly ref struct數(shù)據(jù)結(jié)構(gòu),用于表征一段連續(xù)內(nèi)存的關(guān)鍵屬性被設(shè)置成只讀readonly, 保證了所有的操作只能在這段內(nèi)存內(nèi)。
// 截取自Span源碼
public readonly ref struct Span<T>
{
// 表征一段連續(xù)內(nèi)存的關(guān)鍵屬性 Pointer & Length 都只能從構(gòu)造函數(shù)賦值
/// <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;
}
}
4. 視圖:操作結(jié)果會(huì)直接體現(xiàn)到底層的連續(xù)內(nèi)存。
至此我們來(lái)看一個(gè)簡(jiǎn)單的用法, 利用span操作指向一段堆??臻g。
static void Main()
{
Span<byte> arraySpan = stackalloc byte[100]; // 包含指針和Length的只讀指針, 類(lèi)似于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); // 因?yàn)槭侵蛔x屬性, 內(nèi)部New Span<>(), 產(chǎn)生新的切片
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 指向了特定的堆??臻g, Fill,Clear 等操作的效果直接體現(xiàn)到該段內(nèi)存。
- 注意Slice切片方法,內(nèi)部實(shí)質(zhì)是產(chǎn)生新的Span,是一個(gè)新的視圖,對(duì)新span的操作會(huì)體現(xiàn)到原始底層數(shù)據(jù)結(jié)構(gòu)。
[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切片源碼可以看到,實(shí)質(zhì)是利用原ptr & length 產(chǎn)生包含新的ptr & length的操作視圖, ptr其實(shí)是指針的移動(dòng),也就是定位新的數(shù)據(jù)塊, 但是終歸是在原始數(shù)據(jù)塊內(nèi)部。
衍生技能點(diǎn)
我們?cè)偌?xì)看Span的定義, 有幾個(gè)關(guān)鍵詞建議大家溫故而知新。
1. readonly strcut[2]
從C#7.2開(kāi)始,你可以將readonly作用在struct上,指示該struct不可改變。
span 被定義為readonly struct,內(nèi)部屬性自然也是readonly,從上面的分析和實(shí)例看我們可以針對(duì)Span表征的特定連續(xù)內(nèi)存空間做內(nèi)容更新操作;
如果想限制更新該連續(xù)內(nèi)存空間的內(nèi)容, C#提供了ReadOnlySpan類(lèi)型, 該類(lèi)型強(qiáng)調(diào)該塊內(nèi)存只讀,也就是不存在Span 擁有的Fill,Clear等方法。
一線碼農(nóng)大佬寫(xiě)了文章講述[使用span對(duì)字符串求和]的姿勢(shì),大家都說(shuō)使用span能高效操作內(nèi)存,我們對(duì)該用例BenchmarkDotNet壓測(cè)。
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;
// 對(duì)字符串切割, 會(huì)產(chǎn)生字符串小對(duì)象
[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;
}
}
// 對(duì)底層字符串切片
[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;
}
}
}
}
壓測(cè)解讀:
對(duì)字符串運(yùn)行時(shí)切分,不會(huì)利用駐留池,于是case1會(huì)分配大量小對(duì)象;
case2對(duì)底層字符串切片,雖然會(huì)產(chǎn)生不同的透視對(duì)象Span, 但是實(shí)際引用了的原始內(nèi)存塊的偏移區(qū)間, 不存在分配新內(nèi)存。
2. ref struct[3]
從C#7.2開(kāi)始,ref可以作用在struct,指示該類(lèi)型被分配在堆棧上,并且不能轉(zhuǎn)義到托管堆。
Span,ReadonlySpan 包裝了對(duì)于任意連續(xù)內(nèi)存快的透視操作,但是只能被存儲(chǔ)堆棧上,不適用于一些場(chǎng)景,例如異步調(diào)用,.NET Core 2.1為此新增了Memory[4] , ReadOnlyMemory, 可以被存儲(chǔ)在托管堆上,這個(gè)暫時(shí)按下不表。
最后用一張圖總結(jié), 本文成文,感謝[ yi念之間 ]大佬參與討論。
引用鏈接
[1] Span: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Private.CoreLib/src/System/Span.cs
[2] readonly strcut: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct#readonly-struct
[3] ref struct: https://docs.microsoft.com/en-us/dotnet/csharp/language-reference/builtin-types/struct
[4] Memory: https://docs.microsoft.com/en-us/dotnet/standard/memory-and-spans/memory-t-usage-guidelines