站在巨人的肩膀上重新審視C# Span<T>數據結構

先談一下我對Span的看法, 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會在堆分配大量string小對象,對gc造成壓力;
case2對底層字符串切片,雖然會產生不同的透視對象Span, 但是實際還是指向的原始內存塊的偏移區間,不存在內存分配。

  • ref struct:從C#7.2開始,ref可以作用在struct,指示該類型被分配在堆棧上,並且不能轉義到託管堆

Span,ReadonlySpan 包裝了對於任意連續內存快的透視操作,但是隻能被存儲堆棧上,不適用於一些場景,例如異步調用,.NET Core 2.1爲此新增了Memory , ReadOnlyMemory, 可以被存儲在託管堆上, 按下不表。

最後用一張圖總結

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章