- A+
1. 前言
此文章是官方文档的翻译,由于官方文档中文版是机器翻译的,有些部分有疏漏和错误,所以本人进行了翻译供大家学习,如有问题欢迎指正。
参考资料:
memory-and-spans --- Microsoft
2. 简介
.NET 包含多个相互关联的类型,它们表示任意内存的连续的强类型区域。 这些方法包括:
-
System.Span<T>
- 用于访问连续的内存区域
- 得到该类型的实例:
- 1个T类型的数组
- 1个
String
- 1个使用
stackalloc
分配的缓冲区 - 1个指向非托管内存的指针
- 实例必须存储在堆栈(stack)上,因此有很对限制
- 类的字段不能是此类型
- 不能在异步操作中使用
-
System.ReadOnlySpan<T>
Span<T>
结构体的不可变版本
-
System.Memory<T>
- 连续的内存区域的包装器
- 实例创建
T
类型数组String
- 内存管理器
- 实例可以存储在托管堆(managed heap)上,所以它没有
Span<T>
的限制
-
System.ReadOnlyMemory<T>
Memory<T>
结构的不可变版本。
-
System.Buffers.MemoryPool<T>
- 它将强类型内存块从内存池分配给所有者
IMemoryOwner<T>
实例可以通过调用MemoryPool<T>.Rent
从池中租用- 通过调用
MemoryPool<T>.Dispose()
将其释放回池中
- 它将强类型内存块从内存池分配给所有者
-
System.Buffers.IMemoryOwner<T>
- 表示内存块的所有者,管理其生命周期
-
MemoryManager<T>
- 一个抽象基类,可用于替换
Memory<T>
的实现,以便Memory<T>
可以由其他类型(如安全句柄(safe handles))提供支持 - MemoryManager<T> 适用于高级方案。
- 一个抽象基类,可用于替换
-
ArraySegment<T>
- 是数组的包装,对应数组中,从特定索引开始的特定数量的一系列元素
-
System.MemoryExtensions
- 用于将String、数组和数组段(
ArraySegment<T>
)转换为Memory<T>
块的扩展方法集
- 用于将String、数组和数组段(
System.Span<T>
、System.Memory<T>
及其对应的只读类型被设计为:
- 避免不必要地复制内存或在托管堆上进行内存分配
- 通过
Slice
方法或这些类型的的构造函数创建它们, 并不涉及复制底层缓冲(underlying buffers): 只更新相关引用和偏移- 形象的说就是,只更新我们可以访问到的内存的位置和范围,而不是将这些内存数据复制出来
备注:
对于早期框架,Span<T>
和Memory<T>
在 System.Memory NuGet 包中提供。
使用 memory 和 span
- 由于 memory 和 span 相关类型通常用于在处理 pipeline 中存储数据,因此开发人员在使用
Span<T>
、Memory<T>
和相关类型时要务必遵循一套最佳做法。Memory<T>
和Span<T>
使用准则中介绍了这些最佳做法。
3. Memory<T>和Span<T>使用准则
Span<T>
和ReadOnlySpan<T>
- 是可由托管或非托管内存提供支持的轻量级内存缓冲区
Memory<T>
及其相关类型- 由托管和非托管内存提供支持
- 与
Span<T>
不同,Memory<T>
可以存储在托管堆上
Span<T>
和 Memory<T>
都是可用于 pipeline 的结构化数据的缓冲区。
- 它们设计的目的是将某些或所有数据有效地传递到 pipeline 中的组件,这些组件可以对其进行处理并修改(可选)缓冲区
- 由于
Memory<T>
及其相关类型可由多个组件或多个线程访问,因此开发人员必须遵循一些标准使用准则才能生成可靠的代码
3.1. 所有者, 消费者和生命周期管理
由于可以在各个 API 之间传送缓冲区,以及由于缓冲区有时可以从多个线程进行访问,因此请务必考虑生命周期管理。 下面介绍三个核心概念:
- 所有权:
- 缓冲区实例的所有者负责生命周期管理,包括当不再使用缓冲区时将其销毁
- 所有缓冲区都拥有一个所有者
- 通常,所有者是创建缓冲区或从工厂接收缓冲区的组件
- 所有权也可以转让;
- 组件 A 可以将缓冲区的控制权转让给组件 B,此时组件 A 就无法再使用该缓冲区,组件 B 将负责在不再使用缓冲区时将其销毁。
- 消费:
- 允许缓冲区实例的消费者通过读取和写入来使用缓冲区实例
- 缓冲区一次可以拥有一个消费者,除非提供了某些外部同步机制
- 缓冲区的活跃消费者不一定是缓冲区的所有者
- 租约:
- 租约是指允许特定组件在一个时间长度范围内成为缓冲区消费者
以下伪代码示例阐释了这三个概念。 它包括:
- 实例化类型为
Char
的Memory<T>
缓冲区的 - 调用
WriteInt32ToBuffer
方法以将整数的字符串表示形式写入缓冲区 - 然后调用
DisplayBufferToConsole
方法以显示缓冲区的值。
using System; class Program { // Write 'value' as a human-readable string to the output buffer. void WriteInt32ToBuffer(int value, Buffer buffer); // Display the contents of the buffer to the console. void DisplayBufferToConsole(Buffer buffer); // Application code static void Main() { var buffer = CreateBuffer(); try { int value = Int32.Parse(Console.ReadLine()); WriteInt32ToBuffer(value, buffer); DisplayBufferToConsole(buffer); } finally { buffer.Destroy(); } } }
- 所有者
- Main 方法创建缓冲区(在此示例中为
Span<T>
实例),因此它是其所有者。 因此,Main 将负责在不再使用缓冲区时将其销毁。
- Main 方法创建缓冲区(在此示例中为
- 消费者
WriteInt32ToBuffer
和DisplayBufferToConsole
- 一次只能有一个消费者
- 先是
WriteInt32ToBuffer
,然后是DisplayBufferToConsole
- 先是
- 这两个消费者都不拥有缓冲区
- 此上下文中的“消费者”并不意味着以只读形式查看缓冲区;如果提供了以读/写形式查看缓冲区的权限,则消费者可以像
WriteInt32ToBuffer
那样修改缓冲区的内容
- 租约
WriteInt32ToBuffer
方法在方法调用的开始时间和方法返回的时间之间会租用(能消费的)缓冲区DisplayBufferToConsole
在执行时会租用缓冲区,方法返回时将解除租用- 没有用于租约管理的 API,“租用”是概念性内容
3.2. Memory<T> 和所有者/消费者模型
.NET Core 支持以下两种所有权模型:
- 支持单个所有权的模型
- 缓冲区在其整个生存期内拥有单个所有者。
- 支持所有权转让的模型
- 缓冲区的所有权可以从其原始所有者(其创建者)转让给其他组件,该组件随后将负责缓冲区的生存期管理
- 该所有者可以反过来将所有权转让给其他组件等
使用 System.Buffers.IMemoryOwner<T>
接口显式的管理缓冲区的所有权。
IMemoryOwner<T>
支持上述这两种所有权模型- 具有
IMemoryOwner<T>
引用的组件拥有缓冲区 - 以下示例使用
IMemoryOwner<T>
实例反映Memory<T>
缓冲区的所有权。
using System; using System.Buffers; class Example { static void Main() { IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent(); Console.Write("Enter a number: "); try { var value = Int32.Parse(Console.ReadLine()); var memory = owner.Memory; WriteInt32ToBuffer(value, memory); DisplayBufferToConsole(owner.Memory.Slice(0, value.ToString().Length)); } catch (FormatException) { Console.WriteLine("You did not enter a valid number."); } catch (OverflowException) { Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}."); } finally { owner?.Dispose(); } } static void WriteInt32ToBuffer(int value, Memory<char> buffer) { var strValue = value.ToString(); var span = buffer.Span; for (int ctr = 0; ctr < strValue.Length; ctr++) span[ctr] = strValue[ctr]; } static void DisplayBufferToConsole(Memory<char> buffer) => Console.WriteLine($"Contents of the buffer: '{buffer}'"); }
也可以使用 using 编写此示例:
using System; using System.Buffers; class Example { static void Main() { using (IMemoryOwner<char> owner = MemoryPool<char>.Shared.Rent()) { Console.Write("Enter a number: "); try { var value = Int32.Parse(Console.ReadLine()); var memory = owner.Memory; WriteInt32ToBuffer(value, memory); DisplayBufferToConsole(memory.Slice(0, value.ToString().Length)); } catch (FormatException) { Console.WriteLine("You did not enter a valid number."); } catch (OverflowException) { Console.WriteLine($"You entered a number less than {Int32.MinValue:N0} or greater than {Int32.MaxValue:N0}."); } } } static void WriteInt32ToBuffer(int value, Memory<char> buffer) { var strValue = value.ToString(); var span = buffer.Slice(0, strValue.Length).Span; strValue.AsSpan().CopyTo(span); } static void DisplayBufferToConsole(Memory<char> buffer) => Console.WriteLine($"Contents of the buffer: '{buffer}'"); }
在此代码中:
- Main 方法保持对
IMemoryOwner<T>
实例的引用,因此 Main 方法是缓冲区的所有者。 WriteInt32ToBuffer
和DisplayBufferToConsole
方法接受 ``Memory
尽管 WriteInt32ToBuffer
方法用于将数据写入缓冲区,但 DisplayBufferToConsole
方法并不如此。
- 若要反映此情况,方法参数类型可改为
ReadOnlyMemory<T>
3.3. “缺少所有者” 的Memory<T> 实例
无需使用 IMemoryOwner<T>
即可创建 Memory<T>
实例。 在这种情况下,缓冲区的所有权是隐式的,并且仅支持单所有者模型。 可以通过以下方式达到此目的:
- 直接调用
Memory<T>
构造函数之一,传入T[]
,如下面的示例所示 - 调用
String.AsMemory
扩展方法以生成ReadOnlyMemory<char>
实例
using System; class Example { static void Main() { Memory<char> memory = new char[64]; Console.Write("Enter a number: "); var value = Int32.Parse(Console.ReadLine()); WriteInt32ToBuffer(value, memory); DisplayBufferToConsole(memory); } static void WriteInt32ToBuffer(int value, Memory<char> buffer) { var strValue = value.ToString(); strValue.AsSpan().CopyTo(buffer.Slice(0, strValue.Length).Span); } static void DisplayBufferToConsole(Memory<char> buffer) => Console.WriteLine($"Contents of the buffer: '{buffer}'"); }
- 最初创建
Memory<T>
实例的方法是缓冲区的隐式所有者。 无法将所有权转让给任何其他组件, 因为没有IMemoryOwner<T>
实例可用于进行转让- 也可以假设运行时的垃圾回收器拥有缓冲区,全部的方法只消费缓冲区
3.4. 使用准则
因为拥有一个内存块,但打算将其传递给多个组件,其中一些组件可能同时在特定的内存块上运行,所以建立使用Memory<T>
和Span<T>
的准则是很必要的,因为:
- 所有者释放它之后,一个组件还可能会保留对该存储块的引用。
- 两个组件可能并发的同时在缓冲区上进行操作,从而破坏了缓冲区中的数据。
- 尽管
Span<T>
的堆栈分配性质优化了性能,而且使Span<T>
成为在内存块上运行的首选类型,但它也使Span<T>
受到一些主要限制- 重要的是要知道何时使用
Span<T>
以及何时使用Memory<T>
- 重要的是要知道何时使用
下面介绍成功使用 Memory<T>
及其相关类型的建议。 除非另有明确说明,否则适用于 Memory<T>
和 Span<T>
的指南也适用于 ReadOnlyMemory<T>
和 ReadOnlySpan<T>
。
规则 1:对于同步 API,如有可能,请使用 Span<T>(而不是 Memory<T>)作为参数。
Span<T>
比 Memory<T>
更多功能:
- 可以表示更多种类的连续内存缓冲区
Span<T>
还提供比Memory<T>
更好的性能- 无法进行
Span<T>
到Memory<T>
的转换 - 可以使用
Memory<T>.Span
属性将Memory<T>
实例转换为Span<T>
- 如果调用方恰好具有
Memory<T>
实例,则它们不管怎样都可以使用Span<T>
参数调用你的方法
- 如果调用方恰好具有
使用类型 Span<T>
(而不是类型 Memory<T>
)作为方法的参数类型还可以帮助你编写正确的消费方法实现。 你将自动进行编译时检查,以确保不会企图访问此方法租约之外的缓冲区
有时,必须使用 Memory<T>
参数(而不是 Span<T>
参数),即使完全同步也是如此。 所依赖的 API 可能仅接受 Memory<T>
参数。 这没有问题,但当使用同步的 Memory<T>
时,应注意权衡利弊
规则 2:如果缓冲区应为只读,则使用 ReadOnlySpan<T> 或 ReadOnlyMemory<T>
在前面的示例中,DisplayBufferToConsole
方法仅从缓冲区读取数据;它不修改缓冲区的内容。 方法签名应进行修改如下。
void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);
事实上,如果我们结合 规则1 和 规则2 ,我们可以做得更好,并重写方法签名如下:
void DisplayBufferToConsole(ReadOnlySpan<char> buffer);
DisplayBufferToConsole
方法现在几乎适用于每一个能够想到的缓冲区类型:
T[]
、使用stackalloc
分配的存储 等等- 甚至可以向其直接传递
String
!
规则 3:如果方法接受 Memory<T> 并返回 void,则在方法返回之后不得使用 Memory<T> 实例。
这与前面提到的“租约”概念相关。 返回 void 的方法对 Memory<T>
实例的租用将在进入该方法时开始,并在退出该方法时结束。 请考虑以下示例,该示例会基于控制台中的输入在循环中调用 Log。
using System; using System.Buffers; public class Example { // implementation provided by third party static extern void Log(ReadOnlyMemory<char> message); // user code public static void Main() { using (var owner = MemoryPool<char>.Shared.Rent()) { var memory = owner.Memory; var span = memory.Span; while (true) { int value = Int32.Parse(Console.ReadLine()); if (value < 0) return; int numCharsWritten = ToBuffer(value, span); Log(memory.Slice(0, numCharsWritten)); } } } private static int ToBuffer(int value, Span<char> span) { string strValue = value.ToString(); int length = strValue.Length; strValue.AsSpan().CopyTo(span.Slice(0, length)); return length; } }
如果 Log
是完全同步的方法,则此代码将按预期运行,因为在任何给定时间只有一个活跃的内存实例消费者。 但是,请想象Log具有此实现。
// !!! INCORRECT IMPLEMENTATION !!! static void Log(ReadOnlyMemory<char> message) { // Run in background so that we don't block the main thread while performing IO. Task.Run(() => { StreamWriter sw = File.AppendText(@".input-numbers.dat"); sw.WriteLine(message); }); }
在此实现中,Log 违反了租约,因为它在 return 之后仍尝试在后台使用 Memory<T>
实例。 Main 方法可能会在 Log 尝试从缓冲区进行读取时更改缓冲区数据,这可能导致消费者在使用缓存区数据时数据已经被修改。
有多种方法可解决此问题:
- Log 方法可以按以下所示,返回 Task,而不是 void。
// An acceptable implementation. static Task Log(ReadOnlyMemory<char> message) { // Run in the background so that we don't block the main thread while performing IO. return Task.Run(() => { StreamWriter sw = File.AppendText(@".input-numbers.dat"); sw.WriteLine(message); sw.Flush(); }); }
- 也可以改为按如下所示实现 Log:
// An acceptable implementation. static void Log(ReadOnlyMemory<char> message) { string defensiveCopy = message.ToString(); // Run in the background so that we don't block the main thread while performing IO. Task.Run(() => { StreamWriter sw = File.AppendText(@".input-numbers.dat"); sw.WriteLine(defensiveCopy); sw.Flush(); }); }
规则 4:如果方法接受 Memory<T> 并返回某个Task,则在Task转换为终止状态之前不得使用 Memory<T> 实例。
这个是 规则3 的异步版本。 以下示例是遵守此规则,按上面例子编写的 Log
方法:
// An acceptable implementation. static Task Log(ReadOnlyMemory<char> message) { // Run in the background so that we don't block the main thread while performing IO. return Task.Run(() => { string defensiveCopy = message.ToString(); StreamWriter sw = File.AppendText(@".input-numbers.dat"); sw.WriteLine(defensiveCopy); sw.Flush(); }); }
此处的“终止状态”表示任务转换为 completed, faulted, canceled 状态。
此指南适用于返回 Task
、Task<TResult>
、ValueTask<TResult>
或任何类似类型的方法。
规则5:如果构造函数接受Memory <T>作为参数,则假定构造对象上的实例方法是Memory<T>实例的消费者。
请看以下示例:
class OddValueExtractor { public OddValueExtractor(ReadOnlyMemory<int> input); public bool TryReadNextOddValue(out int value); } void PrintAllOddValues(ReadOnlyMemory<int> input) { var extractor = new OddValueExtractor(input); while (extractor.TryReadNextOddValue(out int value)) { Console.WriteLine(value); } }
此处的 OddValueExtractor
构造函数接受 ReadOnlyMemory<int>
作为构造函数参数,因此构造函数本身是 ReadOnlyMemory<int>
实例的消费者,并且该实例的所有实例方法也是原始 ReadOnlyMemory<int>
实例的消费者。 这意味着 TryReadNextOddValue
消费 ReadOnlyMemory<int>
实例,即使该实例未直接传递到 TryReadNextOddValue
方法。
规则 6:如果一个类型具有可写的 Memory<T> 类型的属性(或等效的实例方法),则假定该对象上的实例方法是 Memory<T> 实例的消费者。
这是 规则5 的变体。之所以存在此规则,是因为假定使用了可写属性或等效方法来捕获并保留输入的 Memory<T>
实例,因此同一对象上的实例方法可以利用捕获的实例。
以下示例触发了此规则:
class Person { // Settable property. public Memory<char> FirstName { get; set; } // alternatively, equivalent "setter" method public SetFirstName(Memory<char> value); // alternatively, a public settable field public Memory<char> FirstName; }
规则 7:如果具有 IMemoryOwner<T>
的引用,则必须在某些时候对其进行处理或转让其所有权(但不同时执行两个操作)。
- 由于
Memory<T>
实例可能由托管或非托管内存提供支持,因此在对Memory<T>
实例执行的工作完成之后,所有者必须调用MemoryPool<T>.Dispose
。 - 此外,所有者可能会将
IMemoryOwner<T>
实例的所有权转让给其他组件,同时获取所有权的组件将负责在适当时间调用MemoryPool<T>.Dispose
- 调用 Dispose 方法失败可能会导致非托管内存泄漏或其他性能降低问题
- 此规则也适用于调用工厂方法的代码(如
MemoryPool<T>.Rent
)。 调用方将成为工厂生产的IMemoryOwner<T>
的所有者,并负责在完成后 Dispose 该实例。
规则 8:如果 API 接口中具有 IMemoryOwner<T>
参数,即表示你接受该实例的所有权。
接受此类型的实例表示组件打算获取此实例的所有权。 该组件将负责根据 规则7 进行正确处理。
在方法调用完成后,将 IMemoryOwner<T>
实例的所有权转让给其他组件,之后该组件将不再使用该实例。
重要:
构造函数接受IMemoryOwner<T>
作为参数的类应实现接口IDisposable
,并且Dispose
方法中应调用MemoryPool<T>.Dispose
。
规则 9:如果要封装同步的 p/invoke 方法,则应接受 Span<T> 作为参数
根据 规则1,Span<T>
通常是用于同步 API 的合规类型。 可以通过 fixed
关键字固定 Span<T>
实例,如下面的示例所示。
using System.Runtime.InteropServices; [DllImport(...)] private static extern unsafe int ExportedMethod(byte* pbData, int cbData); public unsafe int ManagedWrapper(Span<byte> data) { fixed (byte* pbData = &MemoryMarshal.GetReference(data)) { int retVal = ExportedMethod(pbData, data.Length); /* error checking retVal goes here */ return retVal; } }
在上一示例中,如果输入 span 为空,则 pbData
可以为 Null。 如果 ExportedMethod
方法参数 pbData
不能为 Null,可以按如下示例实现该方法:
public unsafe int ManagedWrapper(Span<byte> data) { fixed (byte* pbData = &MemoryMarshal.GetReference(data)) { byte dummy = 0; int retVal = ExportedMethod((pbData != null) ? pbData : &dummy, data.Length); /* error checking retVal goes here */ return retVal; } }
规则 10:如果要包装异步 p/invoke 方法,则应接受 Memory<T> 作为参数
由于 fixed 关键字不能在异步操作中使用,因此使用 Memory<T>.Pin
方法固定 Memory<T>
实例,无论实例代表的连续内存是哪种类型。 下面的示例演示了如何使用此 API 执行异步 p/invoke 调用。
using System.Runtime.InteropServices; [UnmanagedFunctionPointer(...)] private delegate void OnCompletedCallback(IntPtr state, int result); [DllImport(...)] private static extern unsafe int ExportedAsyncMethod(byte* pbData, int cbData, IntPtr pState, IntPtr lpfnOnCompletedCallback); private static readonly IntPtr _callbackPtr = GetCompletionCallbackPointer(); public unsafe Task<int> ManagedWrapperAsync(Memory<byte> data) { // setup var tcs = new TaskCompletionSource<int>(); var state = new MyCompletedCallbackState { Tcs = tcs }; var pState = (IntPtr)GCHandle.Alloc(state); var memoryHandle = data.Pin(); state.MemoryHandle = memoryHandle; // make the call int result; try { result = ExportedAsyncMethod((byte*)memoryHandle.Pointer, data.Length, pState, _callbackPtr); } catch { ((GCHandle)pState).Free(); // cleanup since callback won't be invoked memoryHandle.Dispose(); throw; } if (result != PENDING) { // Operation completed synchronously; invoke callback manually // for result processing and cleanup. MyCompletedCallbackImplementation(pState, result); } return tcs.Task; } private static void MyCompletedCallbackImplementation(IntPtr state, int result) { GCHandle handle = (GCHandle)state; var actualState = (MyCompletedCallbackState)(handle.Target); handle.Free(); actualState.MemoryHandle.Dispose(); /* error checking result goes here */ if (error) { actualState.Tcs.SetException(...); } else { actualState.Tcs.SetResult(result); } } private static IntPtr GetCompletionCallbackPointer() { OnCompletedCallback callback = MyCompletedCallbackImplementation; GCHandle.Alloc(callback); // keep alive for lifetime of application return Marshal.GetFunctionPointerForDelegate(callback); } private class MyCompletedCallbackState { public TaskCompletionSource<int> Tcs; public MemoryHandle MemoryHandle; }
注:
Memory<T>.Pin
方法返回内存句柄,且垃圾回收器将不会移动此处内存,直到释放该方法返回的MemoryHandle
对象为止。这使您可以检索和使用该内存地址。