Gerenciamento da memória e coleta de lixo (GC) no ASP.NET Core

Por Sébastien Ros e Rick Anderson

O gerenciamento da memória é complexo, mesmo em uma estrutura gerenciada como o .NET. A análise e a compreensão dos problemas de memória podem ser um desafio. Este artigo:

  • Você foi motivado por muitos problemas de perda de memória e GC não funcionando. A maioria desses problemas foi causada pelo fato de você não entender o funcionamento do consumo de memória no .NET Core ou não entender como ele é medido.
  • Demonstra a utilização problemática da memória e sugere abordagens alternativas.

Como funciona a coleta de lixo (GC) no .NET Core

O GC aloca segmentos de heap nos quais cada segmento é um intervalo contíguo de memória. Os objetos colocados no heap são categorizados em uma das três gerações: 0, 1 ou 2. A geração determina a frequência com que o GC tenta liberar a memória nos objetos gerenciados não mais referenciados pelo aplicativo. As gerações com números mais baixos são submetidas ao GC com mais frequência.

Os objetos são movidos de uma geração para outra com base no seu tempo de vida. À medida que o tempo de vida dos objetos aumenta, eles são movidos para uma geração mais alta. Como mencionado anteriormente, as gerações mais altas são submetidas ao GC com menos frequência. Os objetos com vida útil curta sempre permanecem na geração 0. Por exemplo, os objetos referenciados durante a vida útil de uma solicitação da Web têm vida curta. Os singletons no nível do aplicativo geralmente migram para a geração 2.

Quando um aplicativo ASP.NET Core é iniciado, o GC:

  • Reserva alguma memória para os segmentos de heap iniciais.
  • Compromete uma pequena parte da memória quando o runtime é carregado.

As alocações de memória anteriores são feitas por motivos de desempenho. O benefício do desempenho vem dos segmentos de heap na memória contígua.

Limitações de GC.Collect

Em geral, os aplicativos ASP.NET Core em produção não devem usar o GC.Collect explicitamente. Induzir coletas de lixo em horários abaixo do ideal pode diminuir significativamente o desempenho.

GC.Collect é útil ao investigar vazamentos de memória. Chamar GC.Collect() dispara um ciclo de coleta de lixo de bloqueio que tenta recuperar todos os objetos inacessíveis do código gerenciado. É uma maneira útil de entender o tamanho dos objetos dinâmicos acessíveis no heap e acompanhar o crescimento do tamanho da memória ao longo do tempo.

Analisando o uso da memória de um aplicativo

Ferramentas dedicadas podem ajudar na análise do uso da memória:

  • Contagem de referências ao objeto
  • Medição do impacto que o GC tem sobre o uso da CPU
  • Medição do espaço de memória usado para cada geração

Utilize as seguintes ferramentas para analisar o uso da memória:

Detecção de problemas de memória

O Gerenciador de Tarefas pode ser utilizado para você ter uma ideia da quantidade de memória que um aplicativo ASP.NET está usando. O valor da memória do Gerenciador de Tarefas:

  • Representa a quantidade de memória utilizada pelo processo ASP.NET.
  • Inclui os objetos ativos do aplicativo e outros consumidores de memória, como o uso de memória nativa.

Se o valor da memória do Gerenciador de Tarefas aumentar indefinidamente e nunca se estabilizar, o aplicativo tem um perda de memória. As seções a seguir demonstram e explicam vários padrões de uso da memória.

Exemplo de aplicativo de exibição de uso da memória

O exemplo de aplicativo MemoryLeak está disponível no GitHub. O aplicativo MemoryLeak:

  • Inclui um controlador de diagnóstico que reúne os dados da memória e do GC em tempo real para o aplicativo.
  • Tem uma página de índice que exibe os dados da memória e do GC. A página de índice é atualizada a cada segundo.
  • Contém um controlador de API que fornece vários padrões de carga da memória.
  • Não é uma ferramenta suportada, no entanto, pode ser utilizada para exibir os padrões de uso de memória dos aplicativos ASP.NET Core.

Executar o MemoryLeak. A memória alocada aumenta lentamente até que ocorra um GC. A memória aumenta porque a ferramenta aloca um objeto personalizado para capturar os dados. A imagem a seguir mostra a página do índice do MemoryLeak quando um GC de geração 0 ocorre. O gráfico mostra 0 RPS (solicitações por segundo) porque nenhum ponto de extremidade da API do controlador da API foi chamado.

Chart showing 0 Requests Per Second (RPS)

O gráfico exibe dois valores para o uso da memória:

  • Alocada: a quantidade de memória ocupada pelos objetos gerenciados
  • Conjunto de trabalho: o conjunto de páginas no espaço do endereço virtual do processo que estão atualmente residindo na memória física. O conjunto de trabalho mostrado tem o mesmo valor exibido pelo Gerenciador de Tarefas.

Objetos transitórios

A API a seguir cria uma instância de cadeia de caracteres de 10 KB e a retorna para o cliente. Em cada solicitação, um novo objeto é alocado na memória e gravado na resposta. As cadeias de caracteres são armazenadas como caracteres UTF-16 no .NET, de modo que cada caractere ocupa 2 bytes na memória.

[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
    return new String('x', 10 * 1024);
}

O gráfico a seguir é gerado com uma carga relativamente pequena para mostrar como as alocações de memória são afetadas pelo GC.

Graph showing memory allocations for a relatively small load

O gráfico anterior mostra:

  • 4K RPS (Solicitações por segundo).
  • As coleções do GC de geração 0 ocorrem a cada dois segundos.
  • O conjunto de trabalho é constante em aproximadamente 500 MB.
  • A CPU está em 12%.
  • O consumo e a liberação de memória (por meio do GC) estão estáveis.

O gráfico a seguir é obtido com a taxa de transferência máxima que pode ser manipulada pelo computador.

Chart showing max throughput

O gráfico anterior mostra:

  • 22K RPS
  • As coletas do GC da Geração 0 ocorrem várias vezes por segundo.
  • As coletas da Geração 1 são disparadas porque o aplicativo alocou significativamente mais memória por segundo.
  • O conjunto de trabalho é constante em aproximadamente 500 MB.
  • A CPU está em 33%.
  • O consumo e a liberação de memória (por meio do GC) estão estáveis.
  • A CPU (33%) não está sendo utilizada em excesso, portanto, a coleta de lixo pode acompanhar um grande número de alocações.

O GC da estação de trabalho versus o GC do servidor

O Coletor de Lixo do .NET tem dois modos diferentes:

  • GC da Estação de Trabalho: otimizado para desktop.
  • GC do servidor. O GC padrão para aplicativos ASP.NET Core. Otimizado para o servidor.

O modo GC pode ser definido explicitamente no arquivo do projeto ou no arquivo runtimeconfig.json do aplicativo publicado. A marcação a seguir mostra a configuração ServerGarbageCollection no arquivo do projeto:

<PropertyGroup>
  <ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>

Se você alterar ServerGarbageCollection no arquivo do projeto, será necessário recriar o aplicativo.

Observação: a coleta de lixo do servidor não está disponível em computadores com um único núcleo. Para obter mais informações, consulte IsServerGC.

A imagem a seguir mostra o perfil de memória em um RPS de 5K usando o GC da estação de trabalho.

Chart showing memory profile for a Workstation GC

As diferenças entre esse gráfico e a versão do servidor são significativas:

  • O conjunto de trabalho cai de 500 MB para 70 MB.
  • O GC faz coletas de geração 0 várias vezes por segundo, em vez de a cada dois segundos.
  • O GC cai de 300 MB para 10 MB.

Em um ambiente típico de servidor Web, o uso da CPU é mais importante do que a memória, portanto, o GC do servidor é melhor. Se o uso da memória for alto e o uso da CPU for relativamente baixo, o GC da estação de trabalho poderá ter um desempenho melhor. Por exemplo, uma alta densidade hospedando vários aplicativos Web nos quais a memória é escassa.

GC usando o Docker e contêineres pequenos

Quando vários aplicativos em contêineres são executados em um computador, o GC da Estação de trabalho pode ser mais eficiente do que o GC do servidor. Para obter mais informações, consulte Executando o GC do servidor em um Contêiner Pequeno e Executando o GC do servidor em um Cenário de Contêiner Pequeno Parte 1 - Limite rígido para o Heap do GC.

Referências de objetos persistentes

O GC não pode liberar os objetos que são referenciados. Os objetos referenciados, mas que não são mais necessários, resultam em perda de memória. Se o aplicativo alocar objetos com frequência e não liberá-los depois que não forem mais necessários, o uso da memória aumentará com o tempo.

A API a seguir cria uma instância de cadeia de caracteres de 10 KB e a retorna para o cliente. A diferença em relação ao exemplo anterior é que essa instância é referenciada por um membro estático, o que significa que ela nunca está disponível para ser coletada.

private static ConcurrentBag<string> _staticStrings = new ConcurrentBag<string>();

[HttpGet("staticstring")]
public ActionResult<string> GetStaticString()
{
    var bigString = new String('x', 10 * 1024);
    _staticStrings.Add(bigString);
    return bigString;
}

O código anterior:

  • É um exemplo de uma perda de memória típica.
  • Com chamadas frequentes, faz com que a memória do aplicativo aumente até que o processo seja interrompido com uma exceção OutOfMemory.

Chart showing a memory leak

Na imagem anterior:

  • O teste de carga do ponto de extremidade /api/staticstring causa um aumento linear na memória.
  • O GC tenta liberar memória à medida que a pressão da memória aumenta, chamando uma coleção de geração 2.
  • O GC não consegue liberar a memória perdida. O conjunto alocado e o conjunto de trabalho aumentam com o tempo.

Alguns cenários, como o armazenamento em cache, exigem que as referências a objetos sejam mantidas até que a pressão da memória os force a serem liberados. A classe WeakReference pode ser utilizada para esse tipo de código de cache. Um objeto WeakReference é coletado sob pressão da memória. A implementação padrão de IMemoryCache usa WeakReference.

Memória nativa

Alguns objetos do .NET Core dependem da memória nativa. A memória nativa pode não ser coletada pelo GC. O objeto .NET que usa a memória nativa deve liberá-la usando o código nativo.

O .NET fornece a interface IDisposable para permitir que os desenvolvedores liberem a memória nativa. Mesmo que Dispose não seja chamada, as classes implementadas corretamente chamam Dispose quando o finalizador é executado.

Considere o seguinte código:

[HttpGet("fileprovider")]
public void GetFileProvider()
{
    var fp = new PhysicalFileProvider(TempPath);
    fp.Watch("*.*");
}

PhysicalFileProvider é uma classe gerenciada, portanto, qualquer instância será coletada no final da solicitação.

A imagem a seguir mostra o perfil da memória ao invocar a API fileprovider continuamente.

Chart showing a native memory leak

O gráfico anterior mostra um problema óbvio com a implementação dessa classe, pois ela continua aumentando o uso da memória. Esse é um problema conhecido que está sendo acompanhado em esta questão.

O mesmo vazamento pode ocorrer no código do usuário, por um dos seguintes motivos:

  • Não liberação correta da classe.
  • Deixar de invocar o método Dispose dos objetos dependentes que devem ser descartados.

Heap de Objetos Grandes

Os frequentes ciclos de alocação/liberação de memória podem fragmentar a memória, especialmente quando você aloca grandes blocos de memória. Os objetos são alocados em blocos contíguos de memória. Para mitigar a fragmentação, quando o GC libera a memória, ele tenta desfragmentá-la. Esse processo é chamado de compactação. A compactação envolve a movimentação de objetos. A movimentação de objetos grandes impõe uma penalidade ao desempenho. Por esse motivo, o GC cria uma zona de memória especial para objetos grandes, denominada heap de objetos grandes (LOH). Os objetos com mais de 85.000 bytes (aproximadamente 83 KB) são:

  • Colocados no LOH.
  • Não compactados.
  • Coletados durante os GCs da geração 2.

Quando o LOH estiver cheio, o GC disparará uma coleta de geração 2. As coletas da geração 2:

  • São inerentemente lentas.
  • Além disso, você tem o custo de disparar uma coleta em todas as outras gerações.

O código a seguir compacta o LOH imediatamente:

GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();

Consulte LargeObjectHeapCompactionMode para obter informações sobre como compactar o LOH.

Em contêineres que usam o .NET Core 3.0 e posterior, o LOH é compactado automaticamente.

A API a seguir ilustra esse comportamento:

[HttpGet("loh/{size=85000}")]
public int GetLOH1(int size)
{
   return new byte[size].Length;
}

O gráfico a seguir mostra o perfil de memória da chamada do ponto de extremidade /api/loh/84975, sob carga máxima:

Chart showing memory profile of allocating bytes

O gráfico a seguir mostra o perfil de memória ao chamar o ponto de extremidade /api/loh/84976, alocando apenas mais um byte:

Chart showing memory profile of allocating one more byte

Observação: a estrutura byte[] tem uma sobrecarga de bytes. É por isso que 84.976 bytes disparam o limite de 85.000.

Comparando os dois gráficos anteriores:

  • O conjunto de trabalho é semelhante em ambos os cenários, cerca de 450 MB.
  • As solicitações abaixo do LOH (84.975 bytes) mostram principalmente as coleções de geração 0.
  • As solicitações acima do LOH geram coleções constantes da geração 2. As coleções de geração 2 são caras. Mais CPU é necessária e a taxa de transferência cai para quase 50%.

Os objetos grandes temporários são particularmente problemáticos porque causam GCs de geração 2.

Para obter o máximo desempenho, o uso de objetos grandes deve ser minimizado. Se possível, divida os objetos grandes. Por exemplo, o Middleware do Cache de Resposta no ASP.NET Core dividiu as entradas do cache em blocos com menos de 85.000 bytes.

Os links a seguir mostram a abordagem do ASP.NET Core para manter os objetos abaixo do limite do LOH:

Para obter mais informações, consulte:

HttpClient

O uso incorreto de HttpClient pode resultar em um vazamento de recursos. Os recursos do sistema, como conexões de banco de dados, soquetes, manipuladores de arquivos etc., são mais escassos do que a memória:

  • São mais escassos do que a memória.
  • Quando vazados, são mais problemáticos do que a memória.

Os desenvolvedores experientes do .NET sabem que devem chamar Dispose em objetos que implementam IDisposable. Se você não descartar os objetos que implementam IDisposable, isso geralmente resultará em perda de memória ou de recursos do sistema.

HttpClient implementa IDisposable, mas não deve ser descartado em cada invocação. Em vez disso, HttpClient deve ser reutilizado.

O seguinte ponto de extremidade cria e descarta uma nova instância de HttpClient em cada solicitação:

[HttpGet("httpclient1")]
public async Task<int> GetHttpClient1(string url)
{
    using (var httpClient = new HttpClient())
    {
        var result = await httpClient.GetAsync(url);
        return (int)result.StatusCode;
    }
}

Sob carga, as seguintes mensagens de erro são registradas:

fail: Microsoft.AspNetCore.Server.Kestrel[13]
      Connection id "0HLG70PBE1CR1", Request id "0HLG70PBE1CR1:00000031":
      An unhandled exception was thrown by the application.
System.Net.Http.HttpRequestException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted --->
    System.Net.Sockets.SocketException: Only one usage of each socket address
    (protocol/network address/port) is normally permitted
   at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port,
    CancellationToken cancellationToken)

Mesmo que as instâncias HttpClient sejam descartadas, a conexão de rede real leva algum tempo para ser liberada pelo sistema operacional. Ao criar continuamente novas conexões, ocorre a exaustão das portas. Cada conexão de cliente exige sua própria porta de cliente.

Uma maneira de evitar o esgotamento da porta é reutilizar a mesma instância HttpClient:

private static readonly HttpClient _httpClient = new HttpClient();

[HttpGet("httpclient2")]
public async Task<int> GetHttpClient2(string url)
{
    var result = await _httpClient.GetAsync(url);
    return (int)result.StatusCode;
}

A instância HttpClient é liberada quando o aplicativo é interrompido. Esse exemplo mostra que nem todo recurso descartável deve ser descartado após cada uso.

Veja a seguir uma maneira melhor de lidar com o tempo de vida de uma instância HttpClient:

Pool de objetos

O exemplo anterior mostrou como a instância HttpClient pode se tornar estática e ser reutilizada por todas as solicitações. A reutilização evita que você fique sem recursos.

Agrupamento de objetos:

  • Usa o padrão de reutilização.
  • Foi projetado para objetos cuja criação é cara.

Um pool é uma coleção de objetos pré-inicializados que podem ser reservados e liberados entre threads. Os pools podem definir regras de alocação, como limites, tamanhos predefinidos ou taxa de crescimento.

O pacote NuGet Microsoft.Extensions.ObjectPool contém classes que ajudam a gerenciar esses pools.

O seguinte ponto de extremidade da API cria uma instância de um buffer byte preenchido com números aleatórios em cada solicitação:

        [HttpGet("array/{size}")]
        public byte[] GetArray(int size)
        {
            var random = new Random();
            var array = new byte[size];
            random.NextBytes(array);

            return array;
        }

O gráfico a seguir exibe a chamada à API anterior com carga moderada:

Chart showing calls to API with moderate load

No gráfico anterior, as coletas de geração 0 ocorrem aproximadamente uma vez por segundo.

O código anterior pode ser otimizado com o agrupamento do buffer byte usando ArrayPool<T>. Uma instância estática é reutilizada entre as solicitações.

O que é diferente com essa abordagem é que um objeto em pool é retornado da API. Isso significa que:

  • O objeto fica fora do seu controle assim que você retorna do método.
  • Você não pode liberar o objeto.

Para configurar o descarte do objeto:

RegisterForDispose se encarregará de chamar Dispose no objeto de destino para que ele seja liberado somente quando a solicitação HTTP for concluída.

private static ArrayPool<byte> _arrayPool = ArrayPool<byte>.Create();

private class PooledArray : IDisposable
{
    public byte[] Array { get; private set; }

    public PooledArray(int size)
    {
        Array = _arrayPool.Rent(size);
    }

    public void Dispose()
    {
        _arrayPool.Return(Array);
    }
}

[HttpGet("pooledarray/{size}")]
public byte[] GetPooledArray(int size)
{
    var pooledArray = new PooledArray(size);

    var random = new Random();
    random.NextBytes(pooledArray.Array);

    HttpContext.Response.RegisterForDispose(pooledArray);

    return pooledArray.Array;
}

Aplicando a mesma carga que a versão sem pool, você obtém o seguinte gráfico:

Chart showing fewer allocations

A principal diferença são os bytes alocados e, consequentemente, muito menos coleções de geração 0.

Recursos adicionais