Рекомендации по использованию MemoryT<> и SpanT<>

В .NET Core есть несколько типов, представляющих произвольную непрерывную область памяти. .NET впервые появилось Span<T> ядро 2.0 и ReadOnlySpan<T>которые представляют собой упрощенные буферы памяти, которые переносят ссылки на управляемую или неуправляемую память. Так как эти типы могут храниться только в стеке, они непригодны для ряда сценариев, включая вызовы асинхронных методов. В .NET Core 2.1 был добавлен ряд дополнительных типов, включая Memory<T>, ReadOnlyMemory<T>, IMemoryOwner<T> и MemoryPool<T>. Как и Span<T>, Memory<T> и связанные с ним типы могут поддерживаться управляемой и неуправляемой памятью. В отличие от Span<T>Memory<T> может храниться в управляемой куче.

Memory<T> Оба Span<T> и являются оболочками для буферов структурированных данных, которые можно использовать в конвейерах. То есть они разработаны так, чтобы некоторые или все данные могли быть эффективно переданы компонентам в конвейере, который способен их обрабатывать и при необходимости изменять буфер. Так как Memory<T> и связанные с ним типы могут быть доступными нескольким компонентам или нескольким потокам, важно, чтобы разработчик следовал некоторым общим рекомендациям для создания надежного кода.

Владельцы, объекты-получатели и управление жизненным циклом

Так как буферы можно передавать от API к API, а доступ к буферам иногда может осуществляться из нескольких потоков, необходимо управлять временем их существования. Есть три важных момента:

  • Владение. Владелец экземпляра буфера отвечает за управление его жизненным циклом, включая удаление буфера, который больше не используется. У буфера может быть только один владелец. Владельцем обычно является компонент, который создал буфер или получил его из фабрики. Право владения можно передавать. Компонент А может передать контроль над буфером компоненту Б, после чего компонент А больше не сможет использовать буфер, а компонент Б будет отвечать за удаление буфера, когда необходимость в нем исчезнет.

  • Использование. Объект-получатель экземпляра буфера может выполнять чтение и запись в буфер. У буфера одновременно может быть только один объект-получатель, если не обеспечен какой-либо внешний механизм синхронизации. Активным объектом-получателем буфера необязательно является владелец буфера.

  • Аренда. Аренда — это срок, в течение которого определенный компонент может быть объектом-получателем буфера.

Следующий пример псевдокода иллюстрирует три этих понятия. Buffer в псевдокоде представляет тип Memory<T> или Span<T> буфер типа Char. Метод Main создает экземпляр буфера, вызывает WriteInt32ToBuffer метод для записи строкового представления целого числа в буфер, а затем вызывает DisplayBufferToConsole метод для отображения значения буфера.

using System;

class Program
{
    // Write 'value' as a human-readable string to the output buffer.
    void WriteInt32ToBuffer(int value, Buffer buffer);

    // Display the contents of the buffer to the console.
    void DisplayBufferToConsole(Buffer buffer);

    // Application code
    static void Main()
    {
        var buffer = CreateBuffer();
        try
        {
            int value = Int32.Parse(Console.ReadLine());
            WriteInt32ToBuffer(value, buffer);
            DisplayBufferToConsole(buffer);
        }
        finally
        {
            buffer.Destroy();
        }
    }
}

Метод Main создает буфер и поэтому является его владельцем. Таким образом, Main отвечает за удаление буфера, когда он больше не используется. Псевдокод иллюстрирует это путем вызова Destroy метода в буфере. (Ни у Memory<T>Span<T> него Destroy нет метода. Вы увидите фактические примеры кода далее в этой статье.)

У буфера два объекта-получателя — WriteInt32ToBuffer и DisplayBufferToConsole. Одновременно у буфера может быть только один объект-получатель (сначала WriteInt32ToBuffer, а затем DisplayBufferToConsole), и ни один из них не является владельцем буфера. Обратите внимание, что объект-получатель здесь имеет не только возможность чтения из буфера. Объекты-получатели могут изменять содержимое буфера, как в случае WriteInt32ToBuffer, если предоставлены соответствующие права.

Метод WriteInt32ToBuffer арендует буфер (может быть его объектом-получателем), начиная с вызова метода и до момента его возвращения. Аналогичным образом DisplayBufferToConsole арендует буфер на время своего выполнения, и аренда заканчивается, когда выполнение завершается. (Не существует API для управления арендой, так как аренда — концептуальное понятие.)

MemoryT<> и модель владельца или потребителя

Как уже сообщалось в разделе Владельцы, объекты-получатели и управление жизненным циклом, у буфера всегда есть владелец. В .NET Core поддерживаются две модели владения:

  • Модель с единственным владельцем. У буфера есть только один владелец в течение всего его времени существования.

  • Модель, позволяющая передавать право владения. Право владения буфером может передаваться от его первоначального владельца (его создателя) другому компоненту, после чего последний отвечает за управление временем существования буфера. Новый владелец может, в свою очередь, передать право владения другому компоненту и так далее.

С помощью интерфейса System.Buffers.IMemoryOwner<T> можно явно управлять правом владения буфером. IMemoryOwner<T> поддерживает обе модели владения. Владельцем буфера является компонент, на который ссылается IMemoryOwner<T>. В следующем примере используется экземпляр IMemoryOwner<T> для указания владельца буфера Memory<T>.

using System;
using System.Buffers;

class Example
{
    static void Main()
    {
        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(owner.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}.");
        }
        finally {
            owner?.Dispose();
        }
    }

    static void WriteInt32ToBuffer(int value, Memory<char> buffer)
    {
        var strValue = value.ToString();

        var span = buffer.Span;
        for (int ctr = 0; ctr < strValue.Length; ctr++)
            span[ctr] = strValue[ctr];
    }

    static void DisplayBufferToConsole(Memory<char> buffer) =>
        Console.WriteLine($"Contents of the buffer: '{buffer}'");
}

Этот пример можно также написать, используя using.

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}'");
}

В этом коде:

  • Экземпляр IMemoryOwner<T> ссылается на метод Main, поэтому метод Main является владельцем буфера.

  • Методы WriteInt32ToBuffer и DisplayBufferToConsole принимают Memory<T> как общедоступный API-интерфейс. Таким образом, они являются объектами-получателями буфера, выступая в этой роли поочередно.

При этом метод WriteInt32ToBuffer позволяет выполнить запись значения в буфер, а метод DisplayBufferToConsole — нет. Чтобы отобразить этот факт, последний мог бы принять аргумент типа ReadOnlyMemory<T>. Дополнительные сведения см. в ReadOnlyMemory<T>правиле #2. Используйте ReadOnlySpanT< или ReadOnlyMemoryT>><, если буфер должен быть только для чтения.

Экземпляры MemoryT<> без владельца

Можно создать экземпляр Memory<T>, не используя IMemoryOwner<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>. Причины такого поведения следующие:

  • Компонент может сохранить ссылку на блок памяти после того, как владелец буфера ее передал.

  • Компоненты могут одновременно работать с буфером, что приведет к повреждению данных в буфере.

  • Хотя хранимая в стеке структура Span<T> оптимизирует производительность, что делает Span<T> предпочтительным типом для работы с блоком памяти, структура Span<T> также подвержена ряду важных ограничений. Важно понимать, когда следует использовать Span<T>, а когда — Memory<T>.

Ниже приведены наши рекомендации для успешного использования Memory<T> и связанных типов. Рекомендации по использованию Memory<T> и Span<T> также применяются к ReadOnlyMemory<T> и ReadOnlySpan<T>, если прямо не указано обратное.

Правило 1. Для синхронного API используйте SpanT< вместо MemoryT><> в качестве параметра, если это возможно.

Структура Span<T> более эффективна, чем Memory<T>, и обеспечивает больше возможностей по работе со смежными буферами памяти. Span<T> также обеспечивает лучшую производительность, чем Memory<T>. Наконец, можно использовать Memory<T>.Span свойство для преобразования экземпляра Span<T>Memory<T> в объект, хотя преобразование SpanT-to-MemoryT<<>> невозможно. Поэтому, если у вызывающих объектов есть экземпляр Memory<T>, они в любом случае смогут вызвать методы с помощью параметров Span<T>.

Использование параметра типа Span<T> вместо типа Memory<T> также помогает выполнить правильную реализацию метода использования. Вам нужно обеспечить автоматические проверки времени компиляции, гарантирующие, что доступ к буферу осуществляется только в период его аренды методом (подробнее об этом далее).

В некоторых случаях вам придется использовать параметр Memory<T> вместо параметра Span<T> даже в случае полной синхронизации. Возможно, API, от который вы зависите, принимает только Memory<T> аргументы. Это нормально, но следует учитывать компромиссы, связанные с синхронным использованием Memory<T>.

Правило 2. Используйте ReadOnlySpanT<> или ReadOnlyMemoryT<>, если буфер должен быть только для чтения.

В предыдущих примерах метод DisplayBufferToConsole только считывает данные из буфера, не изменяя его содержимое. Сигнатуру метода следует изменить следующим образом.

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

На самом деле, если мы объединим это правило с правилом 1, то сможем добиться лучшего результата и переписать сигнатуру метода следующим образом.

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

Метод DisplayBufferToConsole теперь работает с практически всеми возможными типами буфера: T[], блоком памяти, выделяемым с помощью ключевого слова stackalloc и т. д. Ему даже можно непосредственно передать String! Дополнительные сведения см. в разделе GitHub проблеме dotnet/docs #25551.

Правило 3. Если метод принимает MemoryT и возвращает значениеvoid, после возврата метода не следует использовать экземпляр MemoryT<>.<>

Это относится к упомянутой ранее концепции аренды. Аренда возвращающего слово void метода в экземпляре Memory<T> начинается с началом выполнения метода и заканчивается, когда он завершает работу. Рассмотрим следующий пример, в котором вызывается Log в цикле на основе входных данных из консоли.

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 — это полностью синхронный метод, этот код будет работать ожидаемо, так как в любой момент времени у экземпляра памяти есть только один объект-получатель. Но представьте себе, если реализация 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 нарушает свою аренду, так как он пытается использовать экземпляр Memory<T> в фоновом режиме после возврата первоначального метода. Метод Main может изменять буфер, когда Log пытается выполнить чтение из него, что может привести к повреждению данных.

Есть несколько способов устранить эту проблему.

  • Метод Log может возвращать Task вместо void, как это сделано в следующей реализации метода Log.

    // 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();
        });
    }
    

Правило 4. Если метод принимает memoryT<> и возвращает задачу, то после перехода задачи в состояние терминала не следует использовать экземпляр MemoryT><.

Это попросту асинхронный вариант правила 3. Метод Log из предыдущего примера можно записать следующим образом в соответствии с этим правилом.

// An acceptable implementation.
static void Log(ReadOnlyMemory<char> message)
{
    // Run in the background so that we don't block the main thread while performing IO.
    Task.Run(() => {
        string defensiveCopy = message.ToString();
        StreamWriter sw = File.AppendText(@".\input-numbers.dat");
        sw.WriteLine(defensiveCopy);
        sw.Flush();
    });
}

Здесь "конечное состояние" означает, что выполнение Task завершается успешно или сбоем, или же отменяется. Другими словами "конечное состояния" означает "отсутствие чего-либо, связанного с ожиданием возврата или продолжением выполнения".

Эти рекомендации относятся к методам, которые возвращают Task, Task<TResult>, ValueTask<TResult> или любой аналогичный тип.

Правило 5. Если конструктор принимает MemoryT<> в качестве параметра, предполагается, что методы экземпляра в созданном объекте считаются потребителями экземпляра MemoryT<>.

Рассмотрим следующий пример.

class OddValueExtractor
{
    public OddValueExtractor(ReadOnlyMemory<int> input);
    public bool TryReadNextOddValue(out int value);
}

void PrintAllOddValues(ReadOnlyMemory<int> input)
{
    var extractor = new OddValueExtractor(input);
    while (extractor.TryReadNextOddValue(out int value))
    {
      Console.WriteLine(value);
    }
}

Здесь конструктор OddValueExtractor принимает ReadOnlyMemory<int> в качестве параметра конструктора, поэтому сам конструктор является объектом-получателем экземпляра ReadOnlyMemory<int>, а все методы экземпляра возвращаемого значения также являются объектами-получателями исходного экземпляра ReadOnlyMemory<int>. Это означает, что TryReadNextOddValue — это объект-получатель экземпляра ReadOnlyMemory<int>, хотя экземпляр не передается непосредственно в метод TryReadNextOddValue.

Правило 6. Если у вас есть свойство с типом settable MemoryT<> (или эквивалентный метод экземпляра), то предполагается, что методы экземпляра этого объекта являются потребителями экземпляра MemoryT<>.

Это всего лишь разновидность правила 5. Это правило существует, так как предполагается, что методы задания свойств или эквивалентные методы записывают и сохраняют свои входные данные, чтобы методы экземпляра того же объекта могли использовать записанное состояние.

Это правило иллюстрирует следующий пример.

class Person
{
    // Settable property.
    public Memory<char> FirstName { get; set; }

    // alternatively, equivalent "setter" method
    public SetFirstName(Memory<char> value);

    // alternatively, a public settable field
    public Memory<char> FirstName;
}

Правило 7. Если у вас есть ссылка IMemoryOwnerT<>, необходимо в какой-то момент удалить его или передать его владение (но не оба).

Memory<T> Так как экземпляр может поддерживаться управляемой или неуправляемой памятью, владелец должен вызываться DisposeIMemoryOwner<T> при завершении работы с экземпляромMemory<T>. Кроме того, владелец может передать право владения экземпляром IMemoryOwner<T> другому компоненту, после чего этот компонент будет отвечать за вызов Dispose в соответствующее время (подробнее об этом далее).

Сбой Dispose вызова метода на IMemoryOwner<T> экземпляре может привести к утечкам неуправляемой памяти или другим снижению производительности.

Это правило также применяется к коду, который вызывает фабричные методы, такие как MemoryPool<T>.Rent. Вызывающий объект становится владельцем возвращаемого IMemoryOwner<T> и отвечает за удаление экземпляра после завершения.

Правило 8. Если у вас есть параметр IMemoryOwnerT<> в области API, вы принимаете право собственности на этот экземпляр.

Прием экземпляра этого типа означает, что ваш компонент собирается стать владельцем этого экземпляра. Компонент будет отвечать за правильное удаление экземпляра в соответствии с правилом 7.

Любой компонент, который передает право владения экземпляром IMemoryOwner<T> другому компоненту, больше не должен использовать этот экземпляр после завершения вызова метода.

Важно!

Если конструктор принимает IMemoryOwner<T> в качестве параметра, его тип должен реализовываться IDisposable, и метод Dispose должен вызывать DisposeIMemoryOwner<T> объект.

Правило 9. Если вы упаковываете синхронный метод p/invoke, API должен принять SpanT<> в качестве параметра.

В соответствии с правилом 1, Span<T> — это тот тип, который обычно следует использовать для синхронных API. Вы можете закрепить экземпляры Span<T> с помощью ключевого слова fixed, как показано в следующем примере.

using System.Runtime.InteropServices;

[DllImport(...)]
private static extern unsafe int ExportedMethod(byte* pbData, int cbData);

public unsafe int ManagedWrapper(Span<byte> data)
{
    fixed (byte* pbData = &MemoryMarshal.GetReference(data))
    {
        int retVal = ExportedMethod(pbData, data.Length);

        /* error checking retVal goes here */

        return retVal;
    }
}

В предыдущем примере pbData может принимать значение NULL, например, если входной диапазон пуст. Если экспортированный метод категорически требует, чтобы параметр pbData не мог возвращать значение NULL, даже если cbData равно 0, метод может быть реализован следующим образом.

public unsafe int ManagedWrapper(Span<byte> data)
{
    fixed (byte* pbData = &MemoryMarshal.GetReference(data))
    {
        byte dummy = 0;
        int retVal = ExportedMethod((pbData != null) ? pbData : &dummy, data.Length);

        /* error checking retVal goes here */

        return retVal;
    }
}

Правило No 10. Если вы упаковываете асинхронный метод p/invoke, API должен принимать MemoryT<> в качестве параметра.

Так как ключевое слово fixed нельзя использовать для асинхронных операций, используйте метод Memory<T>.Pin, чтобы закрепить экземпляры Memory<T>, независимо от вида непрерывной памяти, представленной экземпляром. В следующем примере показано, как использовать этот API, чтобы выполнить вызов асинхронного метода p/invoke.

using System.Runtime.InteropServices;

[UnmanagedFunctionPointer(...)]
private delegate void OnCompletedCallback(IntPtr state, int result);

[DllImport(...)]
private static extern unsafe int ExportedAsyncMethod(byte* pbData, int cbData, IntPtr pState, IntPtr lpfnOnCompletedCallback);

private static readonly IntPtr _callbackPtr = GetCompletionCallbackPointer();

public unsafe Task<int> ManagedWrapperAsync(Memory<byte> data)
{
    // setup
    var tcs = new TaskCompletionSource<int>();
    var state = new MyCompletedCallbackState
    {
        Tcs = tcs
    };
    var pState = (IntPtr)GCHandle.Alloc(state);

    var memoryHandle = data.Pin();
    state.MemoryHandle = memoryHandle;

    // make the call
    int result;
    try
    {
        result = ExportedAsyncMethod((byte*)memoryHandle.Pointer, data.Length, pState, _callbackPtr);
    }
    catch
    {
        ((GCHandle)pState).Free(); // cleanup since callback won't be invoked
        memoryHandle.Dispose();
        throw;
    }

    if (result != PENDING)
    {
        // Operation completed synchronously; invoke callback manually
        // for result processing and cleanup.
        MyCompletedCallbackImplementation(pState, result);
    }

    return tcs.Task;
}

private static void MyCompletedCallbackImplementation(IntPtr state, int result)
{
    GCHandle handle = (GCHandle)state;
    var actualState = (MyCompletedCallbackState)(handle.Target);
    handle.Free();
    actualState.MemoryHandle.Dispose();

    /* error checking result goes here */

    if (error)
    {
        actualState.Tcs.SetException(...);
    }
    else
    {
        actualState.Tcs.SetResult(result);
    }
}

private static IntPtr GetCompletionCallbackPointer()
{
    OnCompletedCallback callback = MyCompletedCallbackImplementation;
    GCHandle.Alloc(callback); // keep alive for lifetime of application
    return Marshal.GetFunctionPointerForDelegate(callback);
}

private class MyCompletedCallbackState
{
    public TaskCompletionSource<int> Tcs;
    public MemoryHandle MemoryHandle;
}

См. также