Поделиться через


MemoryOwner<T>

Это MemoryOwner<T> тип буфера, реализующий IMemoryOwner<T>, внедренное свойство длины и ряд ориентированных на производительность API. Это по сути облегченная оболочка вокруг ArrayPool<T> типа, с некоторыми дополнительными вспомогательными служебными программами.

API платформы: MemoryOwner<T>, AllocationMode

Принцип работы

MemoryOwner<T> имеет следующие основные функции:

  • Одним из основных проблем массивов, возвращаемых ArrayPool<T> API и IMemoryOwner<T> экземплярами, возвращаемыми MemoryPool<T> API, является то, что размер, указанный пользователем, используется только в качестве минимального размера: фактический размер возвращаемых буферов может быть больше. 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, который просто возвращает новый экземпляр, упаковав указанную область интереса. Это позволяет пропустить аренду нового буфера и полностью копировать элементы.

Синтаксис

Ниже приведен пример аренды буфера и получения экземпляра 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 файлов приведет к тому, что общий размер выделения составляет около 1 МБ до 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) учитывают исходный запрошенный размер, который мы использовали, поэтому нам больше не нужно вручную отслеживать действующий размер в арендованном буфере.

Примеры

Дополнительные примеры можно найти в модульных тестах.