重新審視C# Span<T>資料結構

部落格猿馬甲哥發表於2022-05-07

先談一下我對Span的看法, span是指向任意連續記憶體空間的型別安全、記憶體安全的檢視。

Span和Memory都是包裝了可以在pipeline上使用的結構化資料的記憶體緩衝器,他們被設計用於在pipeline中高效傳遞資料。

定語解讀

  1. 指向任意連續記憶體空間: 支援託管堆,原生記憶體、堆疊, 這個可從Span的幾個過載建構函式窺視一二。
  2. 型別安全: Span 是一個泛型
  3. 記憶體安全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;
   }
}
  1. 檢視:操作結果會直接體現在底層的連續記憶體。

至此我們來看一個簡單的用法, 利用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, 可以被儲存在託管堆上, 按下不表。

最後用一張圖總結

相關文章