Рекомендации по использованию памяти<T> и Span<T>

.NET включает ряд типов, представляющих произвольный смежный регион памяти. Span<T> и ReadOnlySpan<T> являются упрощенными буферами памяти, которые упаковывают ссылки на управляемую или неуправляемую память. Так как эти типы могут храниться только в стеке, они недоступны для сценариев, таких как асинхронные вызовы методов. Для решения этой проблемы .NET 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 и иногда могут быть доступны из нескольких потоков, поэтому помните, как управляется время существования буфера. Есть три важных момента:

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

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

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

Следующий пример псевдокода иллюстрирует три этих понятия. 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 для управления арендой, так как аренда — концептуальное понятие.)

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

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

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

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

С помощью интерфейса 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
        {
            string? s = Console.ReadLine();

            if (s is null)
                return;

            var value = Int32.Parse(s);

            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
            {
                string? s = Console.ReadLine();

                if (s is null)
                    return;

                var value = Int32.Parse(s);

                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. Использование ReadOnlySpan<T или ReadOnlyMemory<T>>, если буфер должен быть только для чтения.

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

Можно создать экземпляр 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: ");
        string? s = Console.ReadLine();

        if (s is null)
            return;

        var value = Int32.Parse(s);

        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 используйте диапазон<T> вместо памяти<T> в качестве параметра, если это возможно.

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

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

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

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

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

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

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

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

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

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

Это относится к упомянутой ранее концепции аренды. Аренда возвращающего слово 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)
            {
                string? s = Console.ReadLine();

                if (s is null)
                    return;

                int value = Int32.Parse(s);
                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. Если метод принимает память<T> и возвращает задачу, то после перехода задачи в состояние терминала не следует использовать экземпляр Memory<T> .

Это попросту асинхронный вариант правила 3. Метод 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();
    });
}

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

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

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

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

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. Если у вас есть свойство T типа памяти<(или эквивалентный метод экземпляра) в типе, предполагается, что методы экземпляра этого объекта являются потребителями экземпляра Memory<T>>.

Это всего лишь разновидность правила 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. Если у вас есть ссылка IMemoryOwner<T> , необходимо в какой-то момент удалить его или передать его владение (но не оба).

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

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

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

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

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

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

Внимание

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

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

В соответствии с правилом 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;
    }
}

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

Так как ключевое слово 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;
}

См. также