[DotNetBook]: Span, Memory и ReadOnlyMemory
Этой статьей я продолжаю публиковать целую серию статей, результатом которой будет книга по работе .NET CLR, и .NET в целом. За ссылками — добро пожаловать по кат.
Memory и ReadOnlyMemory
Визуальных отличий Memory
от Span
два. Первое — тип Memory
не содержит ограничения ref
в заголовке типа. Т.е., другими словами, тип Memory
имеет право находиться не только на стеке, являясь либо локальной переменной либо параметром метода либо его возвращаемым значением, но и находиться в куче, ссылаясь оттуда на некоторые данные в памяти. Однако эта маленькая разница создает огромную разницу в поведении и возможностях Memory
в сравнении с Span
. В отличии от Span
, который представляет собой средство пользования неким буфером данных для некоторых методов, тип Memory
предназначен для хранения информации о буфере, а не для работы с ним.
Эта статья — вторая из цикла про Span
и Memory . Она является вводной для Memory в том плане что здесь я решил расписать общую терминилогию, а вот примеры совместного использования — решил вывести в отдельную статью
Отсюда возникает разница в API:
Memory
не содержит методов доступа к данным, которыми он заведует. Вместо этого он имеет свойствоSpan
и методSlice
, которые возвращают рабочую лошадку — экземпляр типаSpan
.Memory
дополнительно содержит методPin()
, предназначенный для сценариев, когда хранящийся буфер необходимо передать вunsafe
код. При его вызове для случаев, когда память была выделена в .NET, буфер будет закреплен (pinned) и не будет перемещаться при срабатывании GC, возвращая пользователю экземпляр структурыMemoryHandle
, инкапсулирующей в себе понятие отрезка жизниGCHandle
, закрепившего буфер в памяти:
public unsafe struct MemoryHandle : IDisposable
{
private void* _pointer;
private GCHandle _handle;
private IPinnable _pinnable;
///
/// Создает MemoryHandle для участка памяти
///
public MemoryHandle(void* pointer, GCHandle handle = default, IPinnable pinnable = default)
{
_pointer = pointer;
_handle = handle;
_pinnable = pinnable;
}
///
/// Возвращает указатель на участок памяти, который как предполагается, закреплен и данный адрес не поменяется
///
[CLSCompliant(false)]
public void* Pointer => _pointer;
///
/// Освобождает _handle и _pinnable, также сбрасывая указатель на память
///
public void Dispose()
{
if (_handle.IsAllocated)
{
_handle.Free();
}
if (_pinnable != null)
{
_pinnable.Unpin();
_pinnable = null;
}
_pointer = null;
}
}
Однако, для начала предлагаю познакомиться со всем набором классов. И в качестве первого из них, взглянем на саму структуру Memory
(показаны не все члены типа, а показавшиеся наиболее важными):
public readonly struct Memory
{
private readonly object _object;
private readonly int _index, _length;
public Memory(T[] array) { ... }
public Memory(T[] array, int start, int length) { ... }
internal Memory(MemoryManager manager, int length) { ... }
internal Memory(MemoryManager manager, int start, int length) { ... }
public int Length => _length & RemoveFlagsBitMask;
public bool IsEmpty => (_length & RemoveFlagsBitMask) == 0;
public Memory Slice(int start, int length);
public void CopyTo(Memory destination) => Span.CopyTo(destination.Span);
public bool TryCopyTo(Memory destination) => Span.TryCopyTo(destination.Span);
}
Помимо указания полей структуры я решил дополнительно указать на то, что существует еще два internal
конструктора типа, работающих на основании еще одной сущности — MemoryManager
, речь о котором зайдет несколько дальше и что не является чем-то, о чем вы, возможно, только что подумали: менеджером памяти в классическом понимании. Однако, как и Span
, Memory
точно также содержит в себе ссылку на объект, по которому будет производить навигация, а также смещение и размер внутреннего буфера. Также, дополнительно, стоит отметить что Memory
может быть создан оператором new
только на основании массива плюс методами расширения — на основании строки, массива и ArraySegment
. Т.е. его создание на основании unmanaged памяти вручную не подразумевается. Однако, как мы видим, существует некий внутренний метод создания этой структуры на основании MemoryManager
:
Файл MemoryManager.cs
public abstract class MemoryManager : IMemoryOwner, IPinnable
{
public abstract MemoryHandle Pin(int elementIndex = 0);
public abstract void Unpin();
public virtual Memory Memory => new Memory(this, GetSpan().Length);
public abstract Span GetSpan();
protected Memory CreateMemory(int length) => new Memory(this, length);
protected Memory CreateMemory(int start, int length) => new Memory(this, start, length);
void IDisposable.Dispose()
protected abstract void Dispose(bool disposing);
}
Я позволю себе несколько поспорить с терминологией, которую ввели в команде CLR, назвав тип именем MemoryManager. Когда я его увидел, то сначала решил что это будет что-то типа менеджмента памяти, но ручного, отличного от LOH/SOH. Но был сильно разочарован, увидев реальность. Возможно, стоило назвать его по анаолгии с интерфейсом: MemoryOwner.
Которая инкапсулирует в себе понятие владельца участка памяти. Другими словами если Span
— средство работы с памятью, Memory
— средство хранения информации о конкретном участке, то MemoryManager
— средство контроля его жизни, его владелец. Для примера можно взять тип NativeMemoryManager
, который хоть и написан для тестов, однако не плохо отражает суть понятия «владение»:
Файл NativeMemoryManager.cs
internal sealed class NativeMemoryManager : MemoryManager
{
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;
}
}
}
}
}
// Другие методы
}
Т.е., другими словами, класс обеспечивает возможность вложенных вызовов метода Pin()
подсчитывая тем самым образующиеся ссылки из unsafe
мира.
Еще одной сущностью, тесно связанной с Memory
является MemoryPool
, который обеспечивает пулинг экземпляров MemoryManager
(а по факту — IMemoryOwner
):
Файл MemoryPool.cs
public abstract class MemoryPool : IDisposable
{
public static MemoryPool Shared => s_shared;
public abstract IMemoryOwner Rent(int minBufferSize = -1);
public void Dispose() { ... }
}
Который предназначен для выдачи буферов необходимого размера во временное пользование. Арендуемые экземпляры, реализующие интерфейс IMemoryOwner
имеют метод Dispose()
, который возвращает арендованный массив обратно в пул массивов. Причем по умолчанию вы можете пользоваться общим пулом буферов, который построен на основе ArrayMemoryPool
:
Файл ArrayMemoryPool.cs
internal sealed partial class ArrayMemoryPool : MemoryPool
{
private const int MaximumBufferSize = int.MaxValue;
public sealed override int MaxBufferSize => MaximumBufferSize;
public sealed override IMemoryOwner Rent(int minimumBufferSize = -1)
{
if (minimumBufferSize == -1)
minimumBufferSize = 1 + (4095 / Unsafe.SizeOf());
else if (((uint)minimumBufferSize) > MaximumBufferSize)
ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.minimumBufferSize);
return new ArrayMemoryPoolBuffer(minimumBufferSize);
}
protected sealed override void Dispose(bool disposing) { }
}
И на основании увиденного, вырисовывается следующая картина мира:
- Тип данных
Span
необходимо использовать в параметрах методов, если вы подразумеваете либо считывание данных (ReadOnlySpan
), либо запись (Span
). Но не задачу его сохранения в поле класса для использования в будущем - Если вам необходимо хранить ссылку на буфер данных из поля класса, необходимо использовать
Memory
илиReadOnlyMemory
— в зависимости от целей MemoryManager
— это владелец буфера данных (можно не использовать: по необходимости). Необходим, когда, например, встает необходимость подсчитывать вызовыPin()
. Или когда необходимо обладать знаниями о том, как освобождать память- Если
Memory
построен вокруг неуправляемого участка памяти,Pin()
ничего не сделает. Однако, это унифицирует работу с разными типами буферов: как в случае управляемого так и в случае неуправляемого кода интерфейс взаимодействия будет одинаковым - Каждый из типов имеет публичные конструкторы. А это значит, что вы можете пользоваться как
Span
напрямую, так и получать его экземпляр изMemory
. СамMemory
вы можете создать как отдельно, так и организовать для негоIMemoryOwner
тип, который будет владеть участком памяти, на который будет ссылатьсяMemory
. Частным случаем может являться любой тип, основанный наMemoryManager
: некоторое локальное владение участком памяти (например, с подсчетом ссылок изunsafe
мира). Если при этом необходим пуллинг таких буферов (ожидается частый траффик буферов примерно равного размера), можно возпользоваться типомMemoryPool
. Memory
в отличии отSpan
не имеет средств доступа к элементам буфера. А значит, не является средством работы с буфером данных.Memory
является хранилищем информации о данных, которое можно поместить в поле некоторого класса и должен использоваться именно для этих целей. И наоборот: когда необходима обработка данных в методах, следует принимать типSpan
илиReadOnlySpan
в зависимости от того, пишете вы что-то в буфер или нет;- Если подразумевается что вам необходимо работать с
unsafe
кодом, передавая туда некий буфер данных, стоит использовать типMemory
: он имеет методPin
, автоматизирующий фиксацию буфера в куче .NET, если тот был там создан. - Если же вы имеете некий трафик буферов (например, вы решаете задачу парсинга текста программы или какого-то DSL), стоит воспользоваться типом
MemoryPool
, который можно организовать очень правильным образом, выдавая из пула буферы подходящего размера (например, немного большего если не нашлось подходящего, но с обрезкойoriginalMemory.Slice(requiredSize)
чтобы не фрагментировать пул)
Ссылка на всю книгу