MemoryOwner<T>

MemoryOwner<T> 是一种实现 IMemoryOwner<T>、一个嵌入式长度属性和一系列性能导向型 API 的缓冲区类型。 它本质上是围绕 ArrayPool<T> 类型的一种轻量级包装器,其中包含一些附加的帮助程序实用程序。

平台 APIMemoryOwner<T>AllocationMode

工作原理

MemoryOwner<T> 包含以下主要功能:

  • ArrayPool<T> API 返回的数组和 MemoryPool<T> API 返回的 IMemoryOwner<T> 实例的一个主要问题是,用户指定的大小仅用作最小大小:返回的缓冲区其实际大小可能更大。 MemoryOwner<T> 通过存储原始请求的大小来解决此问题,因此从中检索到的 Memory<T>Span<T> 实例永远不需要手动切片。
  • 使用 IMemoryOwner<T> 时,为基础缓冲区获取 Span<T> 需要首先获取 Memory<T> 实例,然后获取 Span<T>。 这相当昂贵,而且通常不必要,因为实际上可能根本不需要中间 Memory<T>。 而 MemoryOwner<T> 则拥有一个附加 Span 属性,因为其直接包装从池租用的内部 T[] 数组,因而极其轻量。
  • 默认情况下,不会清除从池租用的缓冲区,这意味着,如果以前返回到池时未清除缓冲区,则它们可能包含垃圾数据。 通常,用户需要手动清除这些租用的缓冲区,这可能非常繁琐,尤其是在频繁执行时。 MemoryOwner<T> 通过 Allocate(int, AllocationMode) API 对此采取了更灵活的方法。 此方法不仅可以分配完全符合请求大小的新实例,还可用于指定要使用的分配模式:与 ArrayPool<T> 相同的分配模式,或者自动清除租用缓冲区的分配模式。
  • 在某些情况下,租用缓冲区的大小可能会大于实际所需大小,然后再对大小做调整。 这通常要求用户租用新的缓冲区,并从旧缓冲区复制目标区域。 而 MemoryOwner<T> 则公开了一个 Slice(int, int) API,此 API 仅返回包装指定目标区域的新实例。 这样一来,就可以跳过租用新缓冲区和完全复制项。

语法

以下是如何租用缓冲区和检索 Memory<T> 实例的示例:

// Be sure to include this using at the top of the file:
using Microsoft.Toolkit.HighPerformance.Buffers;

using (MemoryOwner<int> buffer = MemoryOwner<int>.Allocate(42))
{
    // Both memory and span have exactly 42 items
    Memory<int> memory = buffer.Memory;
    Span<int> span = buffer.Span;

    // Writing to the span modifies the underlying buffer
    span[0] = 42;
}

在此示例中,我们使用 using 块来声明 MemoryOwner<T> 缓冲区:这特别有用,因为基础数组将自动返回到块末尾的池中。 相反,如果我们不直接控制 MemoryOwner<T> 实例的生存期,则当垃圾收集器完成对对象的处理时,缓冲区将直接返回到池中。 在这两种情况下,租用的缓冲区将始终正确返回到共享池。

何时应使用此功能?

MemoryOwner<T> 可用作常规用途缓冲区类型,其优势是可最大程度地减少随时间推移完成的分配数,因为它在内部重复使用共享池中的相同数组。 常见的用例是替换 new T[] 数组分配,尤其是在执行需要处理临时缓冲区或因此生成缓冲区的重复操作时。

假设我们有一个由一系列二进制文件构成的数据集,并且我们需要读取所有这些文件,然后以某种方式处理它们。 为了正确分离代码,我们最终可能会写入一个只读取一个二进制文件的方法,该方法可能如下所示:

public static byte[] GetBytesFromFile(string path)
{
    using Stream stream = File.OpenRead(path);

    byte[] buffer = new byte[(int)stream.Length];

    stream.Read(buffer, 0, buffer.Length);

    return buffer;
}

请注意 new byte[] 表达式。 如果我们读取大量文件,最终会分配大量新数组,这将给垃圾回收器施加很大的压力。 我们可能需要使用从池租用的缓冲区重构此代码,如下所示:

public static (byte[] Buffer, int Length) GetBytesFromFile(string path)
{
    using Stream stream = File.OpenRead(path);

    byte[] buffer = ArrayPool<T>.Shared.Rent((int)stream.Length);

    stream.Read(buffer, 0, (int)stream.Length);

    return (buffer, (int)stream.Length);
}

使用此方法时,缓冲区是从池中租用的,这意味着在大多数情况下,我们可以跳过分配。 此外,由于默认情况下不清除租用的缓冲区,因此还可以节省用零填充缓冲区所需的时间,进一步为性能带来少量提升。 在上面的示例中,加载 1000 个文件将使总分配大小从大约 1MB 减少到仅 1024 个字节,实际只分配单个缓冲区,然后自动重复使用。

上述代码有两个主要问题:

  • ArrayPool<T> 可能会返回大小大于请求大小的缓冲区。 为解决此问题,我们需要返回一个元组,以额外指示租用缓冲区中实际使用的大小。
  • 直接返回数组时,我们需要格外小心,以便正确跟踪其生存期,并将其返回到适当的池。 我们可能会改用 MemoryPool<T> 和返回 IMemoryOwner<T> 实例来解决此问题,但仍存在租用缓冲区的大小大于所需大小的问题。 此外,IMemoryOwner<T> 在检索要处理的 Span<T> 时会产生一些开销,因为它是一个接口,而且我们总是需要先获取 Memory<T> 实例,然后再获取 Span<T>

为解决这两个问题,可以使用 MemoryOwner<T> 重构此代码:

public static MemoryOwner<byte> GetBytesFromFile(string path)
{
    using Stream stream = File.OpenRead(path);

    MemoryOwner<byte> buffer = MemoryOwner<byte>.Allocate((int)stream.Length);

    stream.Read(buffer.Span);

    return buffer;
}

返回的 IMemoryOwner<byte> 实例将负责在调用 IDisposable.Dispose 方法时释放基础缓冲区,并将其返回到池中。 我们可以用它来获取 Memory<T>Span<T> 实例,以与加载的数据进行交互,然后在不再需要时释放该实例。 此外,所有 MemoryOwner<T> 属性(如 MemoryOwner<T>.Span)都遵循我们使用的初始请求大小,因此无需再手动跟踪租用缓冲区内的实际大小。

示例

可以在单元测试中查找更多示例。