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


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

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

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

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

  • собственность. Владелец экземпляра буфера отвечает за управление жизненным циклом, включая уничтожение буфера, когда он больше не используется. Все буферы имеют одного владельца. Как правило, владельцем является компонент, который создал буфер или получил его из фабрики. Также можно передать владение; Компонент-A может отказаться от управления буфером в Component-B, в котором компонент-A больше не может использовать буфер, и Компонент-B становится ответственным за уничтожение буфера, когда он больше не используется.

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

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

Следующий пример псевдокода иллюстрирует эти три понятия. 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}'");
}

В этом коде:

  • Метод Main содержит ссылку на IMemoryOwner<T> экземпляр, поэтому 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 для преобразования экземпляра Memory<T> в Span<T>, хотя преобразование Span<T> в Memory<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 в цикле на основе входных данных из консоли.

// <Snippet1>
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;
    }
}
// </Snippet1>

// Possible implementation of Log:
    // private static void Log(ReadOnlyMemory<char> message)
    // {
    //     Console.WriteLine(message);
    // }

Если 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: Если ваш метод принимает Memory<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<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>. Это означает, что TryReadNextOddValue потребляет экземпляр ReadOnlyMemory<int>, даже если экземпляр не передается непосредственно в метод TryReadNextOddValue.

Правило #6: Если в вашем типе есть свойство типа Memory<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> может поддерживаться управляемой или неуправляемой памятью, владелец должен вызвать Dispose на IMemoryOwner<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 должен вызывать Dispose на объекте IMemoryOwner<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;
}

См. также