Diretrizes de uso de Memory<T> e Span<T>

O .NET inclui vários tipos que representam uma região contígua arbitrária de memória. O Span<T> e o ReadOnlySpan<T> são buffers de memória leves que encapsulam referências à memória gerenciada ou não gerenciada. Como esses tipos só podem ser armazenados na pilha, eles são inadequados para cenários como chamadas de método assíncronas. Para resolver esse problema, o .NET 2.1 adicionou mais alguns tipos, incluindo Memory<T>, ReadOnlyMemory<T>, IMemoryOwner<T> e MemoryPool<T>. Assim como Span<T>, Memory<T> e os tipos relacionados podem ter suporte de memória gerenciada e não gerenciada. Diferentemente de Span<T>, Memory<T> pode ser armazenado no heap gerenciado.

Tanto Span<T> como 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 com eficiência para os componentes no pipeline que pode processá-los e, opcionalmente, modificar o buffer. Como Memory<T> e os tipos relacionados podem ser acessados por vários componentes ou threads, é importante seguir algumas diretrizes de uso padrão para criar um código robusto.

Gerenciamento de proprietários, consumidores e vida útil

Os buffers podem ser passados entre APIs e, às vezes, podem ser acessados de vários threads, portanto, lembre-se de como o tempo de vida de um buffer é gerenciado. Os três principais conceitos são:

  • Propriedade. O proprietário de uma instância de buffer é responsável pelo gerenciamento da vida útil, inclusive pela destruição do buffer quando ele não está mais em uso. Todos os buffers pertencem a um único proprietário. Geralmente, o proprietário é o componente que criou o buffer ou que recebeu o buffer de fábrica. É possível também transferir a propriedade. O Componente-A poderá ceder o controle do buffer ao Componente-B, passando o Componente-A a não poder mais usar o buffer. A partir daí, o Componente-B se tornará responsável por destruir o buffer quando ele não estiver mais em uso.

  • Consumo. O consumidor de uma instância do buffer pode usar essa instância, lendo e possivelmente gravando nela. Os buffers podem ter um consumidor por 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.

  • Concessão. A concessão é o período de tempo em que um componente específico pode ser o consumidor do buffer.

O exemplo de pseudocódigo a seguir ilustra esses três conceitos. Buffer no pseudocódigo representa um buffer Memory<T> ou Span<T> do tipo Char. O método Main cria uma instância para o buffer, chama o método WriteInt32ToBuffer para gravar a representação da cadeia de caracteres de um número inteiro no buffer e, em seguida, chama o método DisplayBufferToConsole 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 método Main cria o buffer e, portanto, ele é o respectivo proprietário. Dessa forma, Main é responsável por destruir o buffer quando ele não está mais em uso. O pseudocódigo ilustra isso chamando um método Destroy no buffer. (Nem Memory<T> nem Span<T> tem realmente um método Destroy. Você verá exemplos de código reais mais adiante neste artigo.)

O buffer tem dois consumidores: WriteInt32ToBuffer e DisplayBufferToConsole. Há apenas um consumidor por vez (primeiro WriteInt32ToBuffer, depois DisplayBufferToConsole), e nenhum dos consumidores é proprietário do buffer. Ainda, "consumidor" neste contexto não implica uma exibição somente leitura do buffer. Os consumidores podem modificar o conteúdo do buffer, assim como WriteInt32ToBuffer o faz, se for fornecida uma exibição de leitura/gravação do buffer.

O método WriteInt32ToBuffer tem uma concessão (pode consumir) o buffer entre o início da chamada do método e a hora em que o método é retornado. Da mesma maneira, 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á APIs para gerenciamento de concessão. Uma "concessão" é uma questão conceitual.

Memory<T> e o modelo proprietário/consumidor

Conforme a seção Gerenciamento de proprietários, consumidores e vida útil descreve, um buffer tem sempre um proprietário. O .NET tem suporte para dois modelos de propriedade:

  • Um modelo com suporte para propriedade única. Um buffer tem um único proprietário por toda a vida útil.

  • Um modelo com suporte para transferência de propriedade. É possível transferir a propriedade de um buffer do proprietário original (o respectivo criador) para outro componente que se torna responsável pelo gerenciamento da vida útil do buffer. Esse proprietário pode, por sua vez, transferir a propriedade para outro componente, e assim por diante.

Use a interface System.Buffers.IMemoryOwner<T> para gerenciar explicitamente a propriedade de um buffer. IMemoryOwner<T> tem suporte para os dois modelos de propriedade. O componente que possui uma referência de IMemoryOwner<T> possui o buffer. O exemplo a seguir usa uma instância de IMemoryOwner<T> para refletir a propriedade de um buffer de 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}'");
}

Também podemos escrever este exemplo com a instrução 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}'");
}

Neste código:

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

  • Os métodos WriteInt32ToBuffer 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 método WriteInt32ToBuffer tenha a finalidade de gravar um valor no buffer, isso não se aplica ao método DisplayBufferToConsole. Para refletir isso, ele poderia aceitar um argumento de tipo ReadOnlyMemory<T>. Para obter mais informações sobre ReadOnlyMemory<T>, consulte a Regra nº 2: usar ReadOnlySpan<T> ou ReadOnlyMemory<T> se o buffer for somente leitura.

Instâncias de Memory<T> "sem proprietário"

É possível criar uma instância de Memory<T> sem usar IMemoryOwner<T>. Nesse caso, a propriedade do buffer é implícita, em vez de explícita, e tem suporte apenas para o modelo de proprietário único. É possível fazer isso da seguinte maneira:

  • Chamar um dos construtores de Memory<T> diretamente, passando um T[], assim como no exemplo a seguir.

  • Chamar o método de extensão String.AsMemory para criar uma instância de 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}'");
}

O método que inicialmente cria a instância de Memory<T> é o proprietário implícito do buffer. Não é possível transferir a propriedade para nenhum outro componente porque não há instâncias de IMemoryOwner<T> para facilitar a transferência. Como alternativa, imagine que o coletor de lixo do runtime possui o buffer, e que todos os métodos apenas o consomem.

Diretrizes de uso

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

  • Retenha uma referência a um bloco de memória depois que o respectivo proprietário o liberar.

  • Opere em um buffer, ao mesmo tempo em que outro componente esteja operando nele, corrompendo os dados no buffer durante o processo.

  • Embora a natureza alocada na pilha do Span<T> otimize o desempenho e torne Span<T> o tipo preferencial para a operação em um bloco de memória, ele também submete Span<T> para algumas restrições importantes. É importante saber quando usar Span<T> e quando usar Memory<T>.

A seguir estão nossas recomendações para usar com êxito o Memory<T> e os tipos relacionados. As diretrizes que se aplicam a Memory<T> e a Span<T> também se aplicam a ReadOnlyMemory<T> e ReadOnlySpan<T>, a menos que seja definido de outra forma.

Regra n° 1: no caso de uma API síncrona, usar Span<T> em vez de Memory <T> como parâmetro, sempre que possível.

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

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

Às vezes, você terá que usar um parâmetro Memory<T> em vez de um parâmetro Span<T>, mesmo que esteja totalmente síncrono. Talvez uma API da qual você dependa aceite apenas argumentos Memory<T>. Isso é possível, mas conheça as vantagens e desvantagens envolvidas com o uso de Memory<T> de maneira síncrona.

Regra n° 2: usar ReadOnlySpan<T> ou ReadOnlyMemory<T>, se o buffer for somente leitura.

Nos exemplos anteriores, o método DisplayBufferToConsole apenas lê no buffer, sem modificar o respectivo conteúdo. A assinatura do método deve ser alterada para a seguinte.

void DisplayBufferToConsole(ReadOnlyMemory<char> buffer);

Na verdade, se combinarmos esta regra com a Regra 1, poderemos fazer ainda melhor e reescrever a assinatura do método da seguinte forma:

void DisplayBufferToConsole(ReadOnlySpan<char> buffer);

O método DisplayBufferToConsole agora funciona praticamente com todos os tipos de buffer possíveis: T[], armazenamento alocado com stackalloc, e assim por diante. Você pode, inclusive, passar um String diretamente nele! Para obter mais informações, consulte o problema do GitHub dotnet/docs #25551.

Regra n° 3: se o método aceitar o Memory<T> e retornar void, não usar a instância de Memory<T>, quando o método for retornado.

Isso está relacionado ao conceito de "concessão" mencionado anteriormente. A concessão de um retorno de void do método na instância de Memory<T> 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á como esperado porque há apenas um consumidor ativo da instância de memória a qualquer momento. Mas imagine que Log tenha 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);
    });
}

Nesta implementação, Log viola a respectiva concessão porque ela ainda tenta usar a instância de Memory<T> em segundo plano, após o método original ter retornado. O método Main pode alterar o buffer enquanto Log tenta ler nele, o que pode resultar em dados corrompidos.

Há várias maneiras de resolver isso:

  • O método Log pode retornar uma classe Task em vez de void, assim como faz a seguinte implementação do método 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();
        });
    }
    
  • É possível implementar Log da seguinte maneira:

    // 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 n° 4: se o método aceitar o Memory<T> e retornar uma classe Task, não usar a instância de Memory<T> quando a classe Task passar para um estado terminal.

Esta é apenas a variante assíncrona da Regra 3. O método Log 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();
    });
}

Nesse caso, "estado terminal" significa que a tarefa passa para um estado concluído, com falha ou cancelado. Em outras palavras, "estado terminal" significa "qualquer coisa que cause atraso de lançamento ou continuação de execução".

Essa orientação se aplica a métodos que retornam Task, Task<TResult>, ValueTask<TResult> ou outros tipos semelhantes.

Regra n° 5: se o construtor aceitar o Memory<T> como parâmetro, os métodos da instância no objeto construído serão considerados consumidores da instância de 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);
    }
}

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

Regra n° 6: quando há uma propriedade tipada configurável de Memory<T> (ou um método de instância equivalente) no tipo, presume-se que os métodos da instância nesse objeto sejam consumidores da instância de Memory<T>.

Na verdade, esta é apenas uma variante da Regra 5. Esta regra existe porque presume-se que os setters de propriedade ou métodos equivalentes devam capturar e persistir as respectivas entradas, de modo que os métodos da instância no mesmo objeto possam usar o estado capturado.

O exemplo a seguir aciona esta 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 n° 7: quando há uma referência de IMemoryOwner<T>, é necessário descartá-la ou transferir a propriedade (mas não ambos), em algum momento.

Como uma instância de Memory<T> pode ter suporte de memória gerenciada ou não gerenciada, o proprietário deve chamar Dispose ou IMemoryOwner<T> quando concluir o trabalho realizado na instância de Memory<T>. Como alternativa, o proprietário pode transferir a propriedade da instância de IMemoryOwner<T> para um componente diferente, até que o componente receptor se torne responsável por chamar Dispose no tempo adequado (saiba mais sobre isso a seguir).

Uma falha ao chamar o método Dispose ou uma instância de IMemoryOwner<T> pode causar perdas de memória não gerenciada ou outra degradação do desempenho.

Esta regra também se aplica ao código que chama métodos de fábrica, como MemoryPool<T>.Rent. O chamador se torna proprietário do IMemoryOwner<T> retornado e fica responsável pelo descarte da instância após concluída.

Regra n° 8: se tiver um parâmetro IMemoryOwner<T> na superfície da API, então você estará aceitando a propriedade dessa instância.

Aceitar uma instância desse tipo indica que o componente pretende se apropriar dessa instância. O componente se torna responsável pelo descarte adequado de acordo com a Regra 7.

Os componentes que transferem a propriedade da instância de IMemoryOwner<T> para um componente diferente não devem mais usar essa instância após a conclusão da chamada do método.

Importante

Se o construtor aceitar IMemoryOwner<T> como parâmetro, o respectivo tipo deverá implementar IDisposable, e o método Dispose deverá chamar Dispose no objeto IMemoryOwner<T>.

Regra n° 9: se estiver encapsulando um método de P/Invoke síncrono, a 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 instâncias de Span<T> 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 poderá ser nulo, se o alcance da entrada estiver vazio. Se o método exportado exigir que pbData seja não nulo, mesmo que cbData seja 0, o método poderá 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 n° 10: se estiver encapsulando um método de P/Invoke assíncrono, a API deverá aceitar Memory<T> como parâmetro.

Como não é possível usar a palavra-chave fixed em operações assíncronas, use o método Memory<T>.Pin para fixar instâncias de Memory<T>, independentemente do tipo de memória contígua que a instância representar. O exemplo a seguir mostra como usar esta API para executar uma chamada de P/Invoke assíncrona.

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

Confira também