先谈一下我对Span的看法, span是指向任意连续内存空间的类型安全、内存安全的视图。
如果你了解【滑动窗口】, 对Span的操作还可以理解为 针对连续内存空间的 滑动窗口。
Span和Memory都是包装了可以在pipeline上使用的结构化数据的内存缓冲器,他们被设计用于在pipeline中高效传递数据。
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; }
[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的定义, 有几个关键词建议大家温故而知新。
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, 但是实际还是指向的原始内存块的偏移区间,不存在内存分配。
Span
,ReadonlySpan 包装了对于任意连续内存快的透视操作,但是只能被存储堆栈上,不适用于一些场景,例如异步调用,.NET Core 2.1为此新增了Memory , ReadOnlyMemory , 可以被存储在托管堆上, 按下不表。
最后用一张图总结