Partilhar via


Diretrizes de uso de memória<T> e Span<T>

O .NET inclui vários tipos que representam uma região contígua arbitrária da memória. Span<T> e ReadOnlySpan<T> são buffers de memória leves que envolvem referências a memória gerenciada ou não gerenciada. Como esses tipos só podem ser armazenados na pilha, eles não são adequados para cenários como chamadas de método assíncronas. Para resolver esse problema, o .NET 2.1 adicionou alguns tipos adicionais, incluindo Memory<T>, ReadOnlyMemory<T>, IMemoryOwner<T>e MemoryPool<T>. Como Span<T>, Memory<T> e seus tipos relacionados podem ser apoiados por memória gerenciada e não gerenciada. Ao contrário Span<T>do , Memory<T> pode ser armazenado no heap gerenciado.

Ambos Span<T> e Memory<T> são wrappers sobre buffers de dados estruturados que podem ser usados em pipelines. Ou seja, eles são projetados para que alguns ou todos os dados possam ser passados de forma eficiente para componentes no pipeline, que podem processá-los e, opcionalmente, modificar o buffer. Como Memory<T> seus tipos relacionados podem ser acessados por vários componentes ou por vários threads, é importante seguir algumas diretrizes de uso padrão para produzir código robusto.

Proprietários, consumidores e gestão do tempo de vida

Os buffers podem ser passados entre APIs e, às vezes, podem ser acessados de vários threads, portanto, esteja ciente de como o tempo de vida de um buffer é gerenciado. Existem três conceitos centrais:

  • Apropriação. O proprietário de uma instância de buffer é responsável pelo gerenciamento do tempo de vida, incluindo a destruição do buffer quando ele não está mais em uso. Todos os buffers têm um único proprietário. Geralmente o proprietário é o componente que criou o buffer ou que recebeu o buffer de uma fábrica. A propriedade também pode ser transferida; O Componente-A pode ceder o controle do buffer para o Componente-B, momento em que o Componente-A não pode mais usar o buffer, e o Componente-Btorna-se responsável por destruir o buffer quando ele não estiver mais em uso.

  • Consumo. O consumidor de uma instância de buffer tem permissão para usar a instância de buffer lendo a partir dela e, possivelmente, gravando nela. Os buffers podem ter um consumidor de cada vez, a menos que algum mecanismo de sincronização externo seja fornecido. O consumidor ativo de um buffer não é necessariamente o proprietário do buffer.

  • Arrendamento. A concessão é o período de tempo durante o qual um determinado componente pode ser o consumidor do buffer.

O exemplo de pseudocódigo a seguir ilustra esses três conceitos. Buffer no pseudo-código representa um Memory<T> ou Span<T> buffer do tipo Char. O Main método instancia o buffer, chama o WriteInt32ToBuffer método para gravar a representação de cadeia de caracteres de um inteiro para o buffer e, em seguida, chama o DisplayBufferToConsole método para exibir o valor do buffer.

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

O Main método cria o buffer e assim é seu proprietário. Portanto, Main é responsável por destruir o buffer quando ele não está mais em uso. O pseudocódigo ilustra isso chamando um Destroy método no buffer. (Nem Memory<T>Span<T> sequer tem um Destroy método. Você verá exemplos de código reais mais adiante neste artigo.)

O buffer tem dois consumidores, WriteInt32ToBuffer e DisplayBufferToConsole. Há apenas um consumidor de cada vez (primeiro WriteInt32ToBuffer, depois DisplayBufferToConsole), e nenhum dos consumidores possui o buffer. Observe também que "consumidor" neste contexto não implica uma exibição somente leitura do buffer; Os consumidores podem modificar o conteúdo do buffer, como WriteInt32ToBuffer faz, se lhes for dada uma visualização de leitura/gravação do buffer.

O WriteInt32ToBuffer método tem uma concessão em (pode consumir) o buffer entre o início da chamada de método e o tempo que o método retorna. Da mesma forma, DisplayBufferToConsole tem uma concessão no buffer enquanto ele está em execução, e a concessão é liberada quando o método é desenrolado. (Não há API para gerenciamento de locação; uma "locação" é uma questão conceitual.)

Memória<T> e o modelo proprietário/consumidor

Como observa a seção Proprietários, consumidores e gerenciamento de tempo de vida, um buffer sempre tem um proprietário. O .NET suporta dois modelos de propriedade:

  • Um modelo que suporta a propriedade única. Um buffer tem um único proprietário durante toda a sua vida útil.

  • Um modelo que suporta a transferência de propriedade. A propriedade de um buffer pode ser transferida de seu proprietário original (seu criador) para outro componente, que então se torna responsável pelo gerenciamento do tempo de vida do buffer. Esse proprietário pode, por sua vez, transferir a propriedade para outro componente, e assim por diante.

Você usa a System.Buffers.IMemoryOwner<T> interface para gerenciar explicitamente a propriedade de um buffer. IMemoryOwner<T> suporta ambos os modelos de propriedade. O componente que tem uma IMemoryOwner<T> referência possui o buffer. O exemplo a seguir usa uma IMemoryOwner<T> instância para refletir a propriedade de um Memory<T> buffer.

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

Também podemos escrever este exemplo com a using declaração:

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

Neste código:

  • O Main método mantém a referência à IMemoryOwner<T> instância, portanto, o Main método é o proprietário do buffer.

  • Os WriteInt32ToBuffer métodos e DisplayBufferToConsole aceitam Memory<T> como uma API pública. Portanto, eles são consumidores do buffer. Esses métodos consomem o buffer um de cada vez.

Embora o WriteInt32ToBuffer método se destine a gravar um valor no buffer, o DisplayBufferToConsole método não se destina a. Para refletir este facto, poderia ter aceitado um argumento do tipo ReadOnlyMemory<T>. Para obter mais informações sobre ReadOnlyMemory<T>o , consulte a Regra #2: Usar ReadOnlySpan<T> ou ReadOnlyMemory<T> se o buffer deve ser somente leitura.

Instâncias T de memória<"> sem dono"

Você pode criar uma Memory<T> instância sem usar IMemoryOwner<T>o . Nesse caso, a propriedade do buffer é implícita em vez de explícita, e apenas o modelo de proprietário único é suportado. Pode fazer o seguinte:

  • Chamando um dos Memory<T> construtores diretamente, passando em um T[], como faz o exemplo a seguir.

  • Chamando o método de extensão String.AsMemory para produzir uma ReadOnlyMemory<char> instância.

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

O método que inicialmente cria a Memory<T> instância é o proprietário implícito do buffer. A propriedade não pode ser transferida para qualquer outro componente porque não há instância IMemoryOwner<T> que facilite a transferência. (Como alternativa, você também pode imaginar que o coletor de lixo do tempo de execução possui o buffer e todos os métodos apenas consomem o buffer.)

Diretrizes de utilização

Como um bloco de memória é de propriedade, mas destina-se a ser passado para vários componentes, alguns dos quais podem operar em um bloco de memória específico simultaneamente, é importante estabelecer diretrizes para o uso de ambos e Memory<T>Span<T>. As diretrizes são necessárias porque é possível que um componente:

  • Mantenha uma referência a um bloco de memória depois que seu proprietário o liberou.

  • Opere em um buffer ao mesmo tempo em que outro componente está operando nele, no processo de corrupção dos dados no buffer.

  • Embora a natureza alocada em pilha otimize o desempenho e torne Span<T> o tipo preferido para operar em um bloco de Span<T> memória, ele também está sujeito Span<T> a algumas restrições importantes. É importante saber quando usar um Span<T> e quando usar Memory<T>.

A seguir estão nossas recomendações para o uso Memory<T> bem-sucedido e seus tipos relacionados. Orientações que se aplicam e Memory<T>Span<T> também se aplicam a ReadOnlyMemory<T> e ReadOnlySpan<T> salvo indicação em contrário.

Regra #1: Para uma API síncrona, use Span<T> em vez de Memory<T> como parâmetro, se possível.

Span<T> é mais versátil do que Memory<T> e pode representar uma maior variedade de buffers de memória contíguos. Span<T> também oferece melhor desempenho do que Memory<T>. Finalmente, você pode usar a propriedade para converter uma Memory<T> instância em um Span<T>, embora a Memory<T>.Span conversão Span<T-to-Memory>< T> não seja possível. Portanto, se seus chamadores tiverem uma Memory<T> instância, eles poderão chamar seus métodos com Span<T> parâmetros de qualquer maneira.

Usar um parâmetro de tipo Span<T> em vez de tipo Memory<T> também ajuda a escrever uma implementação de método de consumo correta. Você receberá automaticamente verificações em tempo de compilação para garantir que não está tentando acessar o buffer além da concessão do método (mais sobre isso mais tarde).

Às vezes, você terá que usar um Memory<T> parâmetro em vez de um Span<T> parâmetro, mesmo que seja totalmente síncrono. Talvez uma API da qual você depende aceite apenas Memory<T> argumentos. Isso é bom, mas esteja ciente das compensações envolvidas ao usar Memory<T> de forma síncrona.

Regra #2: Use ReadOnlySpan<T> ou ReadOnlyMemory<T> se o buffer deve ser somente leitura.

Nos exemplos anteriores, o DisplayBufferToConsole método só lê a partir do buffer, não modifica o conteúdo do buffer. A assinatura do método deve ser alterada para o seguinte.

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

Na verdade, se combinarmos essa regra e a Regra #1, podemos fazer ainda melhor e reescrever a assinatura do método da seguinte maneira:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

O DisplayBufferToConsole método agora funciona com praticamente todos os tipos de buffer imagináveis: T[], armazenamento alocado com stackaloc e assim por diante. Você pode até passar um String diretamente para ele! Para obter mais informações, consulte GitHub issue dotnet/docs #25551.

Regra #3: Se seu método aceita Memory<T e retorna void, você não deve usar a instância Memory<T> depois que seu> método retorna.

Isto está relacionado com o conceito de "arrendamento" mencionado anteriormente. A concessão de um método de retorno de vazio na Memory<T> instância começa quando o método é inserido e termina quando o método é encerrado. Considere o exemplo a seguir, que chama Log em um loop com base na entrada do console.

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

Se Log for um método totalmente síncrono, esse código se comportará conforme o esperado porque há apenas um consumidor ativo da instância de memória a qualquer momento. Mas imagine, em vez disso, que Log tem essa implementação.

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

Nessa implementação, Log viola sua concessão porque ainda tenta usar a Memory<T> instância em segundo plano depois que o método original retornou. O Main método pode mutar o buffer enquanto Log tenta ler a partir dele, o que pode resultar em corrupção de dados.

Há várias maneiras de resolver isso:

  • O Log método pode retornar a em Task vez de void, como a seguinte implementação do Log método faz.

    // 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 pode, em vez disso, ser implementado da seguinte forma:

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

Regra #4: Se o seu método aceitar uma Memória<T> e retornar uma Tarefa, você não deverá usar a instância T> de Memória<após a transição da Tarefa para um estado terminal.

Esta é apenas a variante assíncrona da Regra #3. O Log método do exemplo anterior pode ser escrito da seguinte forma para cumprir esta regra:

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

Aqui, "estado terminal" significa que a tarefa transita para um estado concluído, com falha ou cancelado. Em outras palavras, "estado terminal" significa "qualquer coisa que faça aguardar para lançar ou continuar a execução".

Esta orientação aplica-se a métodos que retornam Task, Task<TResult>, ValueTask<TResult>, ou qualquer tipo semelhante.

Regra #5: Se o construtor aceitar Memory<T> como um parâmetro, os métodos de instância no objeto construído serão assumidos como consumidores da instância Memory<T> .

Considere o seguinte exemplo:

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

Aqui, o OddValueExtractor construtor aceita um ReadOnlyMemory<int> parâmetro como um construtor, portanto, o próprio construtor é um consumidor da instância, e todos os métodos de ReadOnlyMemory<int> instância no valor retornado também são consumidores da instância original ReadOnlyMemory<int> . Isso significa que consome a ReadOnlyMemory<int> instância, mesmo que TryReadNextOddValue ela não seja passada diretamente para o TryReadNextOddValue método.

Regra #6: Se você tiver uma propriedade settable Memory<T-typed> (ou um método de instância equivalente) em seu tipo, os métodos de instância nesse objeto serão considerados consumidores da<instância Memory T> .

Esta é realmente apenas uma variante da Regra #5. Essa regra existe porque se presume que os setters de propriedades ou métodos equivalentes capturam e persistem suas entradas, portanto, os métodos de instância no mesmo objeto podem utilizar o estado capturado.

O exemplo a seguir aciona essa regra:

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

Regra #7: Se você tiver uma referência IMemoryOwner<T> , você deve em algum momento descartá-la ou transferir sua propriedade (mas não ambas).

Como o backup de uma Memory<T> instância pode ser feito por memória gerenciada ou não gerenciada, o proprietário deve chamar DisposeIMemoryOwner<T> quando o Memory<T> trabalho executado na instância for concluído. Alternativamente, o proprietário pode transferir a IMemoryOwner<T> propriedade da instância para um componente diferente, momento em que o componente adquirente se torna responsável por chamar Dispose no momento apropriado (mais sobre isso mais tarde).

A falha ao chamar o Dispose método em uma IMemoryOwner<T> instância pode levar a vazamentos de memória não gerenciados ou outra degradação de desempenho.

Esta regra também se aplica ao código que chama métodos de fábrica como MemoryPool<T>.Rent. O chamador torna-se o proprietário do devolvido IMemoryOwner<T> e é responsável por eliminar a instância quando terminar.

Regra #8: Se você tiver um parâmetro IMemoryOwner<T> em sua superfície de API, estará aceitando a propriedade dessa instância.

Aceitar uma instância desse tipo sinaliza que seu componente pretende se apropriar dessa instância. O seu componente torna-se responsável pela eliminação adequada de acordo com a Regra #7.

Qualquer componente que transfira a IMemoryOwner<T> propriedade da instância para um componente diferente não deve mais usar essa instância após a conclusão da chamada de método.

Importante

Se seu construtor aceita IMemoryOwner<T> como um parâmetro, seu tipo deve implementar IDisposable, e seu Dispose método deve chamar Dispose o IMemoryOwner<T> objeto.

Regra #9: Se você estiver encapsulando um método p/invoke síncrono, sua API deverá aceitar Span<T> como parâmetro.

De acordo com a Regra #1, Span<T> geralmente é o tipo correto a ser usado para APIs síncronas. Você pode fixar Span<T> ocorrências por meio da palavra-chave fixed , como no exemplo a seguir.

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

No exemplo anterior, pbData pode ser nulo se, por exemplo, a extensão de entrada estiver vazia. Se o método exportado exigir absolutamente que seja não-nulo, mesmo cbData que pbData seja 0, o método pode ser implementado da seguinte maneira:

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

Regra #10: Se você estiver encapsulando um método assíncrono p/invoke, sua API deverá aceitar a memória<T> como parâmetro.

Como não é possível usar a fixed palavra-chave em operações assíncronas, você usa o Memory<T>.Pin método para fixar Memory<T> instâncias, independentemente do tipo de memória contígua que a instância representa. O exemplo a seguir mostra como usar essa API para executar uma chamada assíncrona 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;
}

Consulte também