• 站在巨人的肩膀上重新审视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, 可以被存储在托管堆上, 按下不表。

    最后用一张图总结

  • 相关阅读:
    OpenGL编程学习笔记——交互与直线算法
    linux命令:netstat ---net-tools安装
    (219)Verilog HDL:实现简单 FSM2(同步复位)
    卷积神经网络(CNN)的组成结构以及其优点
    字符串相似度问题
    免费mes/开源MES/快速解决工厂生产管理难题
    ARMday2
    centos7手动配置jdk1.8环境与maven环境
    【SQL】595. 大的国家
    同事绝不会告诉你的10条职场黄金法则!
  • 原文地址:https://www.cnblogs.com/JulianHuang/p/16230168.html