[DotNetBook]: Span, Memory и ReadOnlyMemory

34uae6usmglyw10vxga3sgfgh8c.jpeg Этой статьей я продолжаю публиковать целую серию статей, результатом которой будет книга по работе .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) чтобы не фрагментировать пул)


Ссылка на всю книгу


© Habrahabr.ru