内存和跨度相关类型
从 .NET Core 2.1 开始,.NET 包含多个相互关联的类型,它们表示任意内存的相邻强类型区域。 这些方法包括:
-
System.Span<T>,用于访问连续内存区域的类型。 Span<T> 实例可由一组
T
类型、一个 String、一个使用 stackalloc 分配的缓冲区或一个指向非托管内存的指针提供支持。 由于它必须在堆栈上进行分配,因此存在诸多限制。 例如,类中的字段不能是 Span<T> 类型,跨度类型也不能在异步操作中使用。 -
System.ReadOnlySpan<T>,Span<T> 结构的不可变版本。
-
System.Memory<T>,在托管堆而不是堆栈上分配的内存的相邻区域。 Memory<T> 实例可以由一组
T
类型或一个 String 提供支持。 因为它可以存储在托管堆上,所以 Memory<T> 没有任何 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> 可以由其他类型(如安全句柄)提供支持。 MemoryManager<T> 适用于高级方案。
-
ArraySegment<T>,从特定索引开始的特定数量数组元素的包装器。
-
System.MemoryExtensions,用于将字符串、数组和数组段转换为 Memory<T> 块的扩展方法集合。
所有者、使用者和生存期管理
由于可以在各个 API 之间传送缓冲区,以及由于缓冲区有时可以从多个线程进行访问,因此请务必考虑生存期管理。 下面介绍三个核心概念:
-
所有权。 缓冲区实例的所有者负责生存期管理,包括在不再使用缓冲区时将其销毁。 所有缓冲区都拥有一个所有者。 通常,所有者是创建缓冲区或从工厂接收缓冲区的组件。 所有权也可以转让;组件 A 可以将缓冲区的控制权转让给组件 B,此时组件 A 就无法再使用该缓冲区,组件 B 将负责在不再使用缓冲区时将其销毁。
-
使用。 允许缓冲区实例的使用者通过从中读取并可能写入其中来使用缓冲区实例。 缓冲区一次可以拥有一个使用者,除非提供了某些外部同步机制。 缓冲区的当前使用者不一定是缓冲区的所有者。
-
租用。 租用是允许特定组件成为缓冲区使用者的时长。没有用于租用管理的 API;“租用”是概念性内容
.NET Core 支持以下两种所有权模型:
-
支持单个所有权的模型。 缓冲区在其整个生存期内拥有单个所有者。
-
支持所有权转让的模型。 缓冲区的所有权可以从其原始所有者(其创建者)转让给其他组件,该组件随后将负责缓冲区的生存期管理。 该所有者可以反过来将所有权转让给其他组件等。
使用 System.Buffers.IMemoryOwner<T> 接口显式管理缓冲区的所有权。 IMemoryOwner<T> 支持两种所有权模型。 具有 IMemoryOwner<T> 引用的组件拥有缓冲区。 以下示例使用 IMemoryOwner<T> 实例反映 Memory<T> 缓冲区的所有权。
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<T> 作为公共 API。 因此,它们是缓冲区的使用者。 并且它们一次仅使用一个。
尽管 WriteInt32ToBuffer
方法用于将值写入缓冲区,但 DisplayBufferToConsole
方法并不如此。 若要反映此情况,可以接受类型为 ReadOnlyMemory<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> 实例可用于进行转让。 (或者,也可以假设运行时的垃圾回收器拥有缓冲区,而所有方法仅使用缓冲区。)
使用准则
由于拥有内存块,但打算传递到多个组件,因此其中一些组件可能会同时在特定的内存块上运行,请务必建立使用 Memory<T> 和 Span<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>)的参数还可以帮助你编写正确的使用方法实现。 你将自动进行编译时检查,以确保不尝试访问方法租用之外的缓冲区(后续部分将对此进行详细介绍)。
- 规则 2:如果缓冲区应为只读,则使用 ReadOnlySpan<T> 或 ReadOnlyMemory<T>。
- 规则 3:如果方法接受 Memory<T> 并返回
void
,则在方法返回之后不得使用 Memory<T> 实例。
这与前面提到的“租用”概念相关。 返回 void 的方法对 Memory<T> 实例的租用将在进入该方法时开始,并在退出该方法时结束。
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
是完全同步的方法,则此代码将按预期运行,因为在任何给定时间只有一个活动的内存实例使用者。
但如果是异步方法,如下例子,则导致访问已经释放的缓冲区
// !!! 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
方法可以按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(); }); }
-
规则 7:如果具有 IMemoryOwner<T> 引用,则必须在某些时候对其进行处理或转让其所有权。
由于 Memory<T> 实例可能由托管或非托管内存提供支持,因此在对 Memory<T> 实例执行的工作完成之后,所有者必须调用 MemoryPool<T>.Dispose。 此外,所有者可能会将 IMemoryOwner<T> 实例的所有权转让给其他组件,同时获取组件将负责在适当时间调用 MemoryPool<T>.Dispose(稍后将对此进行详细介绍)。
调用 Dispose 方法失败可能会导致非托管内存泄漏或其他性能降低。
此规则也适用于调用工厂方法(如 MemoryPool<T>.Rent)的代码。 调用方将成为返回的 IMemoryOwner<T> 的所有者,并负责在完成后处理实例。
- 规则 8:如果 API 接口中具有 IMemoryOwner<T> 参数,即表示你接受该实例的所有权。
接受此类型的实例表示组件打算获取此实例的所有权。 组件将负责根据规则 7 进行正确处理。
在方法调用完成后,将 IMemoryOwner<T> 实例的所有权转让给其他组件的任何组件应不再使用该实例。
如果构造函数接受 IMemoryOwner<T> 作为参数,则其类型应实现 IDisposable,并且 Dispose 方法应调用 MemoryPool<T>.Dispose。