Compartilhar via


Reduzir alocações de memória usando novos recursos do C#

Importante

As técnicas descritas nesta seção melhoram o desempenho quando aplicadas a caminhos quentes em seu código. Os caminhos frequentes são as seções da base de código executadas com frequência e repetidamente em operações normais. A aplicação dessas técnicas ao código que não é executado com frequência terá impacto mínimo. Antes de fazer alterações para melhorar o desempenho, é essencial medir uma linha de base. Em seguida, analise essa linha de base para determinar onde ocorrem gargalos de memória. Você pode aprender sobre muitas ferramentas multiplataforma para medir o desempenho do aplicativo na seção sobre Diagnóstico e instrumentação. Você pode praticar uma sessão de criação de perfil no tutorial para medir o uso da memória na documentação do Visual Studio.

Depois de medir o uso da memória e determinar que você pode reduzir as alocações, use as técnicas nesta seção para reduzir as alocações. Após cada alteração sucessiva, meça o uso da memória novamente. Verifique se cada alteração tem um impacto positivo no uso da memória em seu aplicativo.

O trabalho de otimização de desempenho no .NET geralmente significa remover alocações do seu código. Cada bloco de memória alocado deve eventualmente ser liberado. Menos alocações reduzem o tempo gasto na coleta de lixo. Permite um tempo de execução mais previsível ao remover coletas de lixo de caminhos de código específicos.

Para reduzir alocações, uma tática comum é alterar as estruturas de dados críticas de tipos class para tipos struct. Essa alteração afeta a semântica de usar esses tipos. Parâmetros e retornos agora são passados por valor em vez de por referência. O custo de copiar um valor será insignificante se os tipos forem pequenos, três palavras ou menos (considerando que uma palavra é de tamanho natural de um inteiro). É mensurável e pode ter um impacto real no desempenho para tipos maiores. Para combater o efeito da cópia, os desenvolvedores podem passar esses tipos por meio de ref para recuperar a semântica pretendida.

Os recursos do C# ref oferecem a capacidade de expressar a semântica desejada para struct tipos sem afetar negativamente sua usabilidade geral. Antes desses aprimoramentos, os desenvolvedores precisavam recorrer a estruturas com ponteiros e memória não processada para alcançar o mesmo impacto no desempenho. O compilador gera um código verificávelmente seguro para os novos ref recursos relacionados. Código verificávelmente seguro significa que o compilador detecta possíveis sobrecargas de buffer ou acesso à memória não alocada ou liberada. O compilador detecta e impede alguns erros.

Transmitir e retornar por referência

Variáveis em C# armazenam valores. Em struct tipos, o valor é o conteúdo de uma instância do tipo. Em class tipos, o valor é uma referência a um bloco de memória que armazena uma instância do tipo. Adicionar o ref modificador significa que a variável armazena a referência ao valor. Em tipos struct, a referência aponta para o armazenamento que contém o valor. Em tipos class, a referência aponta para o armazenamento que contém a referência ao bloco de memória.

Em C#, os parâmetros para métodos são passados por valor e os valores retornados são retornados por valor. O valor do argumento é passado para o método. O valor do argumento de retorno é o valor retornado.

O ref, in, ref readonly ou out modificador indica que o argumento é passado por referência. Uma referência ao local de armazenamento é passada para o método. Adicionar ref à assinatura do método significa que o valor retornado é retornado por referência. Uma referência ao local de armazenamento é o valor retornado.

Você também pode usar a atribuição ref para que uma variável se refira a outra variável. Uma atribuição típica copia o valor do lado direito para a variável no lado esquerdo da atribuição. Uma atribuição ref copia o local de memória da variável no lado direito para a variável no lado esquerdo. O ref agora refere-se à variável original:

int anInteger = 42; // assignment.
ref int ___location = ref anInteger; // ref assignment.
ref int sameLocation = ref ___location; // ref assignment

Console.WriteLine(___location); // output: 42

sameLocation = 19; // assignment

Console.WriteLine(anInteger); // output: 19

Ao atribuir uma variável, você altera seu valor. Ao atribuir referências a uma variável, você altera aquilo a que ela se refere.

Você pode trabalhar diretamente com o armazenamento para valores usando variáveis ref, transmissão por referência e atribuição de ref. As regras de escopo impostas pelo compilador garantem a segurança ao trabalhar diretamente com o armazenamento.

Os modificadores ref readonly e in indicam que o argumento deve ser passado por referência e não pode ser reatribuído no método. A diferença é que ref readonly indica que o método usa o parâmetro como uma variável. O método pode capturar o parâmetro ou pode retornar o parâmetro como referência de somente leitura. Nesses casos, você deve usar o ref readonly modificador. Caso contrário, o in modificador oferecerá mais flexibilidade. Você não precisa adicionar o in modificador a um argumento para um in parâmetro, portanto, é possível atualizar as assinaturas de API existentes com segurança usando o in modificador. O compilador emitirá um aviso se você não adicionar o modificador ref ou in a um argumento para um parâmetro ref readonly.

Contexto ref seguro

O C# inclui regras para ref expressões para garantir que uma ref expressão não possa ser acessada quando o armazenamento ao qual se refere não é mais válido. Considere o seguinte exemplo:

public ref int CantEscape()
{
    int index = 42;
    return ref index; // Error: index's ref safe context is the body of CantEscape
}

O compilador relata um erro porque você não pode retornar uma referência a uma variável local de um método. O chamador não pode acessar o armazenamento que está sendo referenciado. O contexto de ref safe define o escopo no qual uma ref expressão é segura para acessar ou modificar. A tabela a seguir lista os contextos de ref safe para tipos de variáveis. Os campos ref não podem ser declarados em um class ou em uma struct não ref, portanto, essas linhas não estão na tabela:

Declaração contexto ref seguro
local não ref bloco onde o local é declarado
parâmetro não referencial método atual
Parâmetro ref, ref readonly, in método de chamada
Parâmetro out método atual
Campo de class método de chamada
campo struct não ref método atual
Campo ref de ref struct método de chamada

Uma variável poderá ser ref retornada se o contexto ref seguro for o método chamador. Se o contexto ref seguro for o método atual ou um bloco, o retorno de ref não será permitido. O snippet a seguir mostra dois exemplos. Um campo membro pode ser acessado no escopo que chama um método, portanto, o contexto ref seguro de um campo de classe ou estrutura é o método de chamada. O contexto seguro de referência para um parâmetro com os modificadores ref ou in abrange todo o método. Ambos podem ser ref retornados de um método membro:

private int anIndex;

public ref int RetrieveIndexRef()
{
    return ref anIndex;
}

public ref int RefMin(ref int left, ref int right)
{
    if (left < right)
        return ref left;
    else
        return ref right;
}

Observação

Quando o modificador ref readonly ou o modificador in é aplicado a um parâmetro, esse parâmetro pode ser retornado por ref readonly, não por ref.

O compilador garante que uma referência não possa escapar de seu contexto ref safe. Você pode usar os parâmetros ref, as variáveis locais ref return e ref com segurança porque o compilador detecta caso você tenha escrito acidentalmente um código onde uma expressão ref poderia ser acessada quando seu armazenamento não é válido.

Contexto seguro e estruturas de referência

ref struct os tipos exigem mais regras para garantir que eles possam ser usados com segurança. Um ref struct tipo pode incluir ref campos. Isso requer a introdução de um contexto seguro. Para a maioria dos tipos, o contexto seguro é o método de chamada. Em outras palavras, um valor que não é um ref struct sempre pode ser retornado de um método.

Informalmente, o contexto seguro para um ref struct é o escopo em que todos os seus ref campos podem ser acessados. Em outras palavras, é a interseção do contexto ref seguro de todos os campos ref. O método a seguir retorna um ReadOnlySpan<char> para um campo membro, portanto, o contexto seguro é o método:

private string longMessage = "This is a long message";

public ReadOnlySpan<char> Safe()
{
    var span = longMessage.AsSpan();
    return span;
}

Por outro lado, o código a seguir emite um erro porque o membro ref field do Span<int> refere-se a uma matriz de inteiros alocados na pilha. Ele não pode escapar do método:

public Span<int> M()
{
    int length = 3;
    Span<int> numbers = stackalloc int[length];
    for (var i = 0; i < length; i++)
    {
        numbers[i] = i;
    }
    return numbers; // Error! numbers can't escape this method.
}

Unificar tipos de memória

A introdução de System.Span<T> e System.Memory<T> fornece um modelo unificado para trabalhar com memória. System.ReadOnlySpan<T> e System.ReadOnlyMemory<T> fornecem versões somente leitura para acessar a memória. Todos eles fornecem uma abstração em um bloco de memória armazenando uma matriz de elementos semelhantes. A diferença é que Span<T> e ReadOnlySpan<T> são ref struct tipos, enquanto Memory<T> e ReadOnlyMemory<T> são struct tipos. Os intervalos contêm um ref field. Portanto, instâncias de um intervalo não podem deixar o contexto seguro. O contexto seguro de um ref struct é o contexto seguro de referência de sua ref field. A implementação de Memory<T> e ReadOnlyMemory<T> remove essa restrição. Você usa esses tipos para acessar diretamente buffers de memória.

Melhorar o desempenho com segurança de ref

Usar esses recursos para melhorar o desempenho envolve estas tarefas:

  • Evite alocações: quando você altera um tipo de um class para um struct, você altera como ele é armazenado. As variáveis locais são armazenadas na pilha. Os membros são armazenados de forma integrada quando o objeto de contêiner é alocado. Essa alteração significa menos alocações e isso diminui o trabalho que o coletor de lixo faz. Também pode diminuir a pressão de memória, fazendo com que o coletor de lixo seja executado com menos frequência.
  • Preservar semântica de referência: alterar um tipo de um class para um struct altera a semântica de passar uma variável para um método. O código que modificou o estado de seus parâmetros precisa de modificação. Agora que o parâmetro é um struct, o método está modificando uma cópia do objeto original. Você pode restaurar a semântica original passando esse parâmetro como um ref parâmetro. Após essa alteração, o método modifica o original struct novamente.
  • Evite copiar dados: copiar tipos maiores struct pode afetar o desempenho em alguns caminhos de código. Você também pode adicionar o ref modificador para passar estruturas de dados maiores aos métodos por referência, em vez de por valor.
  • Restringir modificações: quando um struct tipo é passado por referência, o método chamado pode modificar o estado do struct. Você pode substituir o modificador ref pelos modificadores ref readonly ou in para indicar que o argumento não pode ser modificado. Prefira ref readonly quando o método capta o parâmetro ou o retorna por referência somente leitura. Você também pode criar tipos readonly struct ou struct com membros readonly para fornecer mais controle sobre quais membros de um struct podem ser modificados.
  • Manipular diretamente a memória: alguns algoritmos são mais eficientes ao tratar estruturas de dados como um bloco de memória que contém uma sequência de elementos. Os tipos Span e Memory fornecem acesso seguro a blocos de memória.

Nenhuma dessas técnicas exige unsafe código. Usado com sabedoria, você pode obter características de desempenho do código seguro que antes só era possível usando técnicas não seguras. Você pode experimentar as técnicas por conta própria no tutorial sobre como reduzir as alocações de memória.