内存和跨度pt.3


内存<T>和ReadOnlyMemory <T>


Memory<T>Span<T>之间有两个视觉差异。 第一个是Memory<T>类型在该类型的标头中不包含ref修饰符。 换句话说, Memory<T>类型既可以作为局部变量或方法参数,也可以作为其返回值在堆栈上分配,也可以在堆栈上分配,并且可以从那里引用内存中的某些数据。 但是,与Span<T>相比,这种小的差异在Memory<T>的行为和功能上产生了巨大的差异。 与Span<T>是使用某些方法使用某些数据缓冲区的工具不同, Memory<T>类型旨在存储有关缓冲区的信息,但不处理该信息。 因此,API有所不同。


  • Memory<T>没有方法来访问它负责的数据。 相反,它具有Span属性和Slice方法,这些方法返回Span类型的实例。
  • 此外, Memory<T>包含Pin()方法,用于将存储的缓冲区数据传递给unsafe代码的情况。 如果在.NET中分配内存时调用此方法,则GC处于活动状态时,缓冲区将被固定并且不会移动。 此方法将返回MemoryHandle结构的实例,该实例封装GCHandle以指示生命周期的一段并固定到内存中的数组缓冲区。

本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。

另外,如果您想感谢我们,最好的方法是在github上给我们加星号或分支存储库 github / sidristij / dotnetbook

但是,我建议我们熟悉整个类集。 首先,让我们看一下Memory<T>结构本身(这里只显示那些我发现最重要的类型成员):


  public readonly struct Memory<T> { private readonly object _object; private readonly int _index, _length; public Memory(T[] array) { ... } public Memory(T[] array, int start, int length) { ... } internal Memory(MemoryManager<T> manager, int length) { ... } internal Memory(MemoryManager<T> manager, int start, int length) { ... } public int Length => _length & RemoveFlagsBitMask; public bool IsEmpty => (_length & RemoveFlagsBitMask) == 0; public Memory<T> Slice(int start, int length); public void CopyTo(Memory<T> destination) => Span.CopyTo(destination.Span); public bool TryCopyTo(Memory<T> destination) => Span.TryCopyTo(destination.Span); public Span<T> Span { get; } public unsafe MemoryHandle Pin(); } 

如我们所见,结构包含基于数组的构造函数,但将数据存储在对象中。 这是为了另外引用没有为其设计构造函数的字符串,但是可以与AsMemory() string方法一起使用,它返回ReadOnlyMemory 。 但是,由于两种类型都应该是二进制相似的,所以Object_object字段的类型。


接下来,我们看到两个基于MemoryManager构造函数。 我们稍后再讨论。 获取Length (大小)和IsEmpty的属性检查一个空集。 另外,还有用于获取子集的Slice方法以及CopyToTryCopyTo复制方法。


谈到Memory我想详细描述这种类型的两种方法: Span属性和Pin方法。


内存<T> .Span


 public Span<T> Span { get { if (_index < 0) { return ((MemoryManager<T>)_object).GetSpan().Slice(_index & RemoveFlagsBitMask, _length); } else if (typeof(T) == typeof(char) && _object is string s) { // This is dangerous, returning a writable span for a string that should be immutable. // However, we need to handle the case where a ReadOnlyMemory<char> was created from a string // and then cast to a Memory<T>. Such a cast can only be done with unsafe or marshaling code, // in which case that's the dangerous operation performed by the dev, and we're just following // suit here to make it work as best as possible. return new Span<T>(ref Unsafe.As<char, T>(ref s.GetRawStringData()), s.Length).Slice(_index, _length); } else if (_object != null) { return new Span<T>((T[])_object, _index, _length & RemoveFlagsBitMask); } else { return default; } } } 

即,处理字符串管理的行。 他们说如果我们将ReadOnlyMemory<T>转换为Memory<T> (这些在二进制表示形式中是相同的,甚至有一条注释,这些类型必须以二进制方式重合,因为一种类型是通过调用Unsafe.As从另一种类型产生的)。我们将有机会进入秘密房间,并有机会更换琴弦。 这是一个极其危险的机制:


 unsafe void Main() { var str = "Hello!"; ReadOnlyMemory<char> ronly = str.AsMemory(); Memory<char> mem = (Memory<char>)Unsafe.As<ReadOnlyMemory<char>, Memory<char>>(ref ronly); mem.Span[5] = '?'; Console.WriteLine(str); } --- Hello? 

这种机制与字符串实习结合在一起会产生可怕的后果。


内存<T> .Pin


第二种引起关注的方法是Pin


 public unsafe MemoryHandle Pin() { if (_index < 0) { return ((MemoryManager<T>)_object).Pin((_index & RemoveFlagsBitMask)); } else if (typeof(T) == typeof(char) && _object is string s) { // This case can only happen if a ReadOnlyMemory<char> was created around a string // and then that was cast to a Memory<char> using unsafe / marshaling code. This needs // to work, however, so that code that uses a single Memory<char> field to store either // a readable ReadOnlyMemory<char> or a writable Memory<char> can still be pinned and // used for interop purposes. GCHandle handle = GCHandle.Alloc(s, GCHandleType.Pinned); void* pointer = Unsafe.Add<T>(Unsafe.AsPointer(ref s.GetRawStringData()), _index); return new MemoryHandle(pointer, handle); } else if (_object is T[] array) { // Array is already pre-pinned if (_length < 0) { void* pointer = Unsafe.Add<T>(Unsafe.AsPointer(ref array.GetRawSzArrayData()), _index); return new MemoryHandle(pointer); } else { GCHandle handle = GCHandle.Alloc(array, GCHandleType.Pinned); void* pointer = Unsafe.Add<T>(Unsafe.AsPointer(ref array.GetRawSzArrayData()), _index); return new MemoryHandle(pointer, handle); } } return default; } 

这也是统一的重要工具,因为如果我们要将缓冲区传递给非托管代码,则无论Memory<T>所指的是哪种数据类型,我们都只需调用Pin()方法并传递指向此代码的指针即可。 该指针将存储在结果结构的属性中。


 void PinSample(Memory<byte> memory) { using(var handle = memory.Pin()) { WinApi.SomeApiMethod(handle.Pointer); } } 

这段代码中调用Pin()无关紧要:它可以是表示T[] Memory ,也可以是string或非托管内存的缓冲区。 仅数组和字符串将获得真实的GCHandle.Alloc(array, GCHandleType.Pinned)并且在非托管内存的情况下不会发生任何事情。


MemoryManager,IMemoryOwner,MemoryPool


除了指出结构字段外,我还要注意还有另外两个基于另一个实体MemoryManager internal类型构造函数。 这不是您可能想到的经典内存管理器,我们将在稍后讨论。 您可能想到的经典内存管理器,稍后我们将讨论它。 像Span一样, Memory具有对导航对象的引用,偏移量和内部缓冲区的大小。 请注意,您只能使用new运算符从数组创建Memory 。 或者,您可以使用扩展方法从字符串,数组或ArraySegment创建Memory 。 我的意思是它不是设计为手动从非托管内存创建的。 但是,我们可以看到有一个内部方法可以使用MemoryManager创建此结构。


文件MemoryManager.cs


 public abstract class MemoryManager<T> : IMemoryOwner<T>, IPinnable { public abstract MemoryHandle Pin(int elementIndex = 0); public abstract void Unpin(); public virtual Memory<T> Memory => new Memory<T>(this, GetSpan().Length); public abstract Span<T> GetSpan(); protected Memory<T> CreateMemory(int length) => new Memory<T>(this, length); protected Memory<T> CreateMemory(int start, int length) => new Memory<T>(this, start, length); void IDisposable.Dispose() protected abstract void Dispose(bool disposing); } 

此结构指示存储范围的所有者。 换句话说, Span是使用内存的工具, Memory是用于存储有关特定内存范围的信息的工具,而MemoryManager是控制该范围(即其所有者)生存期的工具。 例如,我们可以查看NativeMemoryManager<T>类型。 尽管用于测试,但此类型明确表示“所有权”的概念。


文件NativeMemoryManager.cs


 internal sealed class NativeMemoryManager : MemoryManager<byte> { private readonly int _length; private IntPtr _ptr; private int _retainedCount; private bool _disposed; public NativeMemoryManager(int length) { _length = length; _ptr = Marshal.AllocHGlobal(length); } public override void Pin() { ... } public override void Unpin() { lock (this) { if (_retainedCount > 0) { _retainedCount--; if (_retainedCount== 0) { if (_disposed) { Marshal.FreeHGlobal(_ptr); _ptr = IntPtr.Zero; } } } } } // Other methods } 

这意味着该类允许对Pin()方法的嵌套调用,从而计算来自unsafe世界的生成引用。


Memory紧密相关的另一个实体是MemoryPool ,它汇集了MemoryManager实例(实际上是IMemoryOwner ):


文件MemoryPool.cs


 public abstract class MemoryPool<T> : IDisposable { public static MemoryPool<T> Shared => s_shared; public abstract IMemoryOwner<T> Rent(int minBufferSize = -1); public void Dispose() { ... } } 

它用于租赁必要大小的缓冲区以供临时使用。 具有实现的IMemoryOwner<T>接口的租用实例具有Dispose()方法,以将租用的阵列返回到阵列池。 默认情况下,您可以使用基于ArrayMemoryPool构建的可共享缓冲区池:


文件ArrayMemoryPool.cs


 internal sealed partial class ArrayMemoryPool<T> : MemoryPool<T> { private const int MaximumBufferSize = int.MaxValue; public sealed override int MaxBufferSize => MaximumBufferSize; public sealed override IMemoryOwner<T> Rent(int minimumBufferSize = -1) { if (minimumBufferSize == -1) minimumBufferSize = 1 + (4095 / Unsafe.SizeOf<T>()); else if (((uint)minimumBufferSize) > MaximumBufferSize) ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.minimumBufferSize); return new ArrayMemoryPoolBuffer(minimumBufferSize); } protected sealed override void Dispose(bool disposing) { } } 

基于此架构,我们有以下图片:


  • 如果要读取数据( ReadOnlySpan )或读写数据( Span ),则应将Span数据类型用作方法参数。 但是,不应将其存储在类的字段中以备将来使用。
  • 如果需要存储从类的字段到数据缓冲区的引用,则需要根据目标使用Memory<T>ReadOnlyMemory<T>
  • MemoryManager<T>是数据缓冲区的所有者(可选)。 例如,如果您需要计数Pin()调用,则可能有必要。 或者,如果您需要知道如何释放内存。
  • 如果“ Memory是建立在非托管内存范围内的,则Pin()不能执行任何操作。 但是,这是使用不同类型的缓冲区的统一方法:对于托管代码和非托管代码,交互接口将相同。
  • 每种类型都有公共构造函数。 这意味着您可以直接使用Span或从Memory获取其实例。 对于此类Memory ,您可以单独创建它,也可以创建IMemoryOwner拥有并由Memory引用的Memory 。 任何基于MemoryManger类型都可以视为特定情况,它拥有一些本地内存范围(例如,伴随着来自unsafe世界的引用的计数)。 另外,如果您需要池化此类缓冲区(预计大小几乎相等的缓冲区的频繁通信),则可以使用MemoryPool类型。
  • 如果打算通过在其中传递数据缓冲区来处理unsafe代码,则应使用具有Pin()方法的Memory类型,该类型会自动将缓冲区固定在.NET堆上(如果已在其中创建)。
  • 如果您有一些缓冲区流量(例如,您解析程序或DSL的文本),则最好使用MemoryPool类型。 您可以正确地实现它,以从池中输出必要大小的缓冲区(例如,如果没有合适的缓冲区,则使用稍大的缓冲区,但使用originalMemory.Slice(requiredSize)以避免池碎片)。

性能表现


为了衡量新数据类型的性能,我决定使用已经成为标准BenchmarkDotNet的库:


 [Config(typeof(MultipleRuntimesConfig))] public class SpanIndexer { private const int Count = 100; private char[] arrayField; private ArraySegment<char> segment; private string str; [GlobalSetup] public void Setup() { str = new string(Enumerable.Repeat('a', Count).ToArray()); arrayField = str.ToArray(); segment = new ArraySegment<char>(arrayField); } [Benchmark(Baseline = true, OperationsPerInvoke = Count)] public int ArrayIndexer_Get() { var tmp = 0; for (int index = 0, len = arrayField.Length; index < len; index++) { tmp = arrayField[index]; } return tmp; } [Benchmark(OperationsPerInvoke = Count)] public void ArrayIndexer_Set() { for (int index = 0, len = arrayField.Length; index < len; index++) { arrayField[index] = '0'; } } [Benchmark(OperationsPerInvoke = Count)] public int ArraySegmentIndexer_Get() { var tmp = 0; var accessor = (IList<char>)segment; for (int index = 0, len = accessor.Count; index < len; index++) { tmp = accessor[index]; } return tmp; } [Benchmark(OperationsPerInvoke = Count)] public void ArraySegmentIndexer_Set() { var accessor = (IList<char>)segment; for (int index = 0, len = accessor.Count; index < len; index++) { accessor[index] = '0'; } } [Benchmark(OperationsPerInvoke = Count)] public int StringIndexer_Get() { var tmp = 0; for (int index = 0, len = str.Length; index < len; index++) { tmp = str[index]; } return tmp; } [Benchmark(OperationsPerInvoke = Count)] public int SpanArrayIndexer_Get() { var span = arrayField.AsSpan(); var tmp = 0; for (int index = 0, len = span.Length; index < len; index++) { tmp = span[index]; } return tmp; } [Benchmark(OperationsPerInvoke = Count)] public int SpanArraySegmentIndexer_Get() { var span = segment.AsSpan(); var tmp = 0; for (int index = 0, len = span.Length; index < len; index++) { tmp = span[index]; } return tmp; } [Benchmark(OperationsPerInvoke = Count)] public int SpanStringIndexer_Get() { var span = str.AsSpan(); var tmp = 0; for (int index = 0, len = span.Length; index < len; index++) { tmp = span[index]; } return tmp; } [Benchmark(OperationsPerInvoke = Count)] public void SpanArrayIndexer_Set() { var span = arrayField.AsSpan(); for (int index = 0, len = span.Length; index < len; index++) { span[index] = '0'; } } [Benchmark(OperationsPerInvoke = Count)] public void SpanArraySegmentIndexer_Set() { var span = segment.AsSpan(); for (int index = 0, len = span.Length; index < len; index++) { span[index] = '0'; } } } public class MultipleRuntimesConfig : ManualConfig { public MultipleRuntimesConfig() { Add(Job.Default .With(CsProjClassicNetToolchain.Net471) // Span not supported by CLR .WithId(".NET 4.7.1")); Add(Job.Default .With(CsProjCoreToolchain.NetCoreApp20) // Span supported by CLR .WithId(".NET Core 2.0")); Add(Job.Default .With(CsProjCoreToolchain.NetCoreApp21) // Span supported by CLR .WithId(".NET Core 2.1")); Add(Job.Default .With(CsProjCoreToolchain.NetCoreApp22) // Span supported by CLR .WithId(".NET Core 2.2")); } } 

现在,让我们看看结果。


效果图


查看它们,我们可以获得以下信息:


  • ArraySegment太糟糕了。 但是,如果将其包装在Span ,则可以减少麻烦。 在这种情况下,性能将提高7倍。
  • 如果考虑使用.NET Framework 4.7.1(对于4.5同样适用),则使用Span会大大降低处理数据缓冲区时的性能。 它将减少30–35%。
  • 但是,如果我们查看.NET Core 2.1+,则性能仍然保持不变甚至有所提高,因为Span可以使用数据缓冲区的一部分来创建上下文。 可以在ArraySegment找到相同的功能,但是它的运行速度非常慢。

因此,我们可以得出有关使用这些数据类型的简单结论:


  • 对于.NET Framework 4.5+.NET Core它们具有唯一的优势:处理原始数组的子集时,它们比ArraySegment更快。
  • .NET Core 2.1+ ,与ArraySegment和任何Slice手动实现相比,它们的使用具有不可否认的优势;
  • 这三种方式都尽可能地具有生产力,而用任何工具来统一阵列都无法实现。
    本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。


Source: https://habr.com/ru/post/zh-CN443980/


All Articles