
内存<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
方法以及CopyTo
和TryCopyTo
复制方法。
谈到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
手动实现相比,它们的使用具有不可否认的优势; - 这三种方式都尽可能地具有生产力,而用任何工具来统一阵列都无法实现。
本章由作者和专业翻译员共同译自俄语。 您可以帮助我们将俄语或英语翻译成任何其他语言,主要是中文或德语。
