Nota
O acesso a esta página requer autorização. Pode tentar iniciar sessão ou alterar os diretórios.
O acesso a esta página requer autorização. Pode tentar alterar os diretórios.
Por Sébastien Ros e Rick Anderson
O gerenciamento de memória é complexo, mesmo em uma estrutura gerenciada como o .NET. Analisar e resolver problemas de memória pode ser desafiante. Fugas de memória e problemas de recolha de lixo (GC) devem-se frequentemente à falta de compreensão sobre como funciona o consumo de memória em .NET, ou à falta de compreensão do processo de medição do uso.
Este artigo demonstra padrões comuns de uso da memória que podem ser problemáticos e sugere abordagens alternativas.
Explore a recolha de lixo (GC) em .NET
O processo GC aloca segmentos de heap onde cada segmento é um intervalo contíguo de memória. Os objetos colocados no heap são categorizados numa de três gerações: 0, 1 ou 2. A geração determina a frequência das tentativas do GC de libertar memória em objetos geridos que a aplicação já não referencia. O GC dirige-se mais frequentemente às gerações com números mais baixos.
Os objetos são movidos de uma geração para outra com base no seu tempo de vida. À medida que os objetos vivem mais tempo, são transferidos para uma geração superior. Como mencionado anteriormente, o GC executa-se menos frequentemente em gerações superiores. Objetos de vida curta permanecem sempre na Geração 0. Por exemplo, os objetos que são referenciados durante a vida de uma solicitação da Web têm vida curta. Os singletons em nível de aplicação geralmente migram para a Geração 2.
Quando uma aplicação ASP.NET Core inicia, o GC processa:
- Reserva algum espaço de memória para os segmentos iniciais do heap.
- Reserva 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 de desempenho resulta de segmentos do heap em memória contínua.
Reveja as advertências ao usar GC.Collect
Em geral, as aplicações ASP.NET Core em produção não devem usar o método GC.Collect explicitamente. Induzir recolhas de lixo em momentos subótimos pode diminuir significativamente o desempenho.
GC.Collect é útil ao investigar fugas de memória. A chamada GC.Collect() aciona 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 ao vivo acessíveis na pilha e acompanhar o crescimento do tamanho da memória ao longo do tempo.
Analisar o uso de memória de uma aplicação
Ferramentas dedicadas podem ajudar a analisar o uso de memória, incluindo:
- Contar referências a objetos.
- Medir o impacto do GC no uso do CPU.
- Medindo o espaço de memória utilizado para cada geração.
Use as seguintes ferramentas para analisar o uso da memória:
- Utilitário dotnet-trace (pode ser utilizado em máquinas de produção)
- Analisar o uso de memória sem o depurador do Visual Studio
- Medir o uso de memória no Visual Studio
Detetar problemas de memória
O Gestor de Tarefas pode ser utilizado para ter uma ideia da quantidade de memória que uma aplicação ASP.NET está a utilizar. O valor de memória do Gestor de Tarefas:
- Representa a quantidade de memória utilizada pelo processo ASP.NET.
- Inclui os objetos vivos do aplicativo e outros consumidores de memória, como o uso de memória nativa.
Se o valor de memória do gestor de tarefas aumentar indefinidamente e nunca estabilizar, a aplicação tem um vazamento de memória. As seções a seguir demonstram e explicam vários padrões de uso de memória.
Explore a aplicação de exemplo para utilização de memória de ecrã
O aplicativo de exemplo MemoryLeak está disponível no GitHub. A aplicação MemoryLeak:
- Inclui um controlador de diagnóstico que reúne memória em tempo real e dados de GC para o aplicativo.
- Tem uma página de índice que exibe a memória e os dados GC. A página Índice é atualizada a cada segundo.
- Contém um controlador de API que fornece vários padrões de carga de memória.
- Pode ser usado para mostrar padrões de utilização de memória de aplicações ASP.NET Core, mas não é uma ferramenta suportada.
Executa o MemoryLeak. A memória alocada aumenta lentamente até ocorrer um GC. A memória aumenta porque a ferramenta aloca um objeto personalizado para capturar dados. A imagem a seguir mostra a página MemoryLeak Index quando ocorre um GC de Geração 0. O gráfico mostra 0 RPS (Solicitações por segundo) porque nenhum dos endpoints da API no controlador de API foi chamado.
O gráfico exibe dois valores para o uso de memória:
- Alocada: A quantidade de memória ocupada por objetos geridos pelo sistema.
- Conjunto de Trabalho: O conjunto de páginas no espaço de endereçamento virtual do processo que atualmente residem na memória física. O conjunto de trabalho mostrado é o mesmo valor que o Gerenciador de Tarefas exibe. Para mais informações, consulte Conjunto de Trabalho.
Objetos transitórios
A API a seguir cria uma instância de String de 20 KB e a retorna ao 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 para que cada caractere tenha 2 bytes na memória.
[HttpGet("bigstring")]
public ActionResult<string> GetBigString()
{
return new String('x', 10 * 1024);
}
O gráfico seguinte é gerado com uma carga relativamente pequena que mostra como o GC afeta as alocações de memória.
O gráfico ilustra os seguintes detalhes:
- 4K RPS (Pedidos por segundo)
- As coleções de GC da Geração 0 ocorrem aproximadamente a cada 2 segundos
- Conjunto de Trabalho Constante, aproximadamente 500 MB
- A CPU está em 12%
- Consumo estável de memória e libertação (através do GC)
O gráfico seguinte é feito ao máximo de rendimento que a máquina pode suportar.
O gráfico ilustra os seguintes detalhes:
- 22 K RPS
- As coleções de GC da Geração 0 ocorrem várias vezes por segundo
- As coleções da Geração 1 são ativadas porque a aplicação aloca significativamente mais memória por segundo
- Conjunto de Trabalho Permanente, aproximadamente 500 MB
- A CPU está a 33%
- Consumo estável de memória e libertação (através do GC)
- O CPU (33%) não está sobreutilizado, por isso o GC consegue acompanhar um número elevado de alocações
GC de estação de trabalho versus GC de servidor
O coletor de lixo .NET tem dois modos diferentes:
- Workstation GC: Otimizado para desktop.
- Server GC: O GC padrão para ASP.NET Core aplicações. Otimizado para o servidor.
O modo GC pode ser definido explicitamente no ficheiro do projeto ou no ficheiroruntimeconfig.json da aplicação publicada. A marcação a seguir mostra a configuração ServerGarbageCollection no arquivo de projeto:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
Alterar ServerGarbageCollection no ficheiro do projeto requer que a aplicação seja reconstruída.
Note
A recolha de lixo do servidor não está disponível em máquinas com um único núcleo. Para obter mais informações, consulte a propriedade IsServerGC.
A imagem a seguir mostra o perfil de memória sob um RPS de 5K usando o GC da estação de trabalho.
As diferenças entre este gráfico e a versão do servidor são significativas:
- O Working Set desce de 500 MB para 70 MB
- A GC faz as coleções da Geração 0 várias vezes por segundo em vez de a cada 2 segundos
- O GC desce 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 a utilização da memória for alta e o uso da CPU for relativamente baixo, o GC da estação de trabalho poderá ter um desempenho mais eficiente. Por exemplo, hospedagem de alta densidade de vários aplicativos da web onde a memória é escassa.
GC usando Docker e pequenos contêineres
Quando várias aplicações containerizadas correm na mesma máquina, o Workstation GC pode ser mais eficiente do que o Server GC. Para mais informações, consulte Running with Server GC in a Small Container (blog) e Running with Server GC in a Small Container Scenario Part 1 – Hard Limit for the GC Heap (blog).
Referências de objeto persistentes
O GC não consegue libertar objetos que são referenciados. Objetos referenciados, mas que não são mais necessários, resultam em um vazamento de memória. Se a aplicação aloca frequentemente objetos e não os liberta depois de já não serem necessários, o uso de memória aumenta com o tempo.
A API a seguir cria uma instância de String de 20 KB e a retorna ao cliente. A diferença em relação ao exemplo anterior é que um membro estático faz referência a esta instância, o que significa que a instância nunca está disponível para recolha.
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:
- Demonstra uma fuga de memória típica.
- Com chamadas frequentes, faz com que a memória da aplicação aumente até que o processo falhe devido a uma
OutOfMemoryexceção.
O gráfico ilustra os seguintes detalhes:
- O teste de carga do
/api/staticstringendpoint provoca um aumento linear da memória - O GC tenta libertar memória à medida que a pressão de memória aumenta, chamando uma coleção Gen 2
- O GC não consegue libertar a memória vazada; O Conjunto Atribuído e o Conjunto de Trabalho aumentam com o tempo
Alguns cenários, como o cache, exigem que as referências de objeto sejam mantidas até que a pressão da memória as force a serem liberadas. A WeakReference classe pode ser usada para esse tipo de código de cache. Um WeakReference objeto é coletado sob pressões de memória. A implementação padrão da IMemoryCache interface utiliza WeakReference.
Memória nativa
Alguns objetos .NET dependem de memória nativa, mas a memória nativa não é colecionável pelo GC. O objeto .NET que utiliza memória nativa deve libertá-lo usando código nativo.
O .NET fornece a interface para permitir que os desenvolvedores liberem memória IDisposable nativa. Mesmo que o Dispose método não seja chamado, 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 gerida, pelo que qualquer instância é recolhida no final do pedido.
A imagem a seguir mostra o perfil de memória ao invocar a fileprovider API continuamente.
O gráfico anterior mostra um problema óbvio com a implementação dessa classe, pois ela continua aumentando o uso de memória. Este resultado é um problema conhecido acompanhado na issue dotnet/aspnetcore do GitHub #844.
O mesmo leak ocorre no código do utilizador nos seguintes cenários:
- Não libertar a classe corretamente
- Esquecer-se de invocar o
Disposemétodo dos objetos dependentes que devem ser descartados
Pilha de objetos grandes
A alocação frequente de memória/ciclos livres resulta em fragmentação da memória, especialmente ao alocar 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 memória, ele tenta desfragmentá-la. Este processo é chamado de compactação. A compactação envolve objetos em movimento. Mover objetos grandes provoca uma penalização de desempenho. Por esta razão, o GC cria uma zona de memória especial para objetos grandes , chamada grande pilha de objetos (LOH). Os objetos com mais de 85.000 bytes (aproximadamente 83 KB) são:
- Colocado no LOH
- Não compactado
- Processado durante a recolha da Geração 2
Quando o LOH está cheio, o GC desencadeia uma coleção da Geração 2.
- As coleções da Geração 2 são inerentemente lentas.
- Podem incorrer no custo de ativar uma cobrança em todas as outras gerações.
O código a seguir compacta o LOH imediatamente:
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
Para informações sobre a compactação do LOH, consulte a propriedade LargeObjectHeapCompactionMode.
Em contentores que utilizam .NET Core 3.0 ou posterior, o LOH é automaticamente compactado.
A seguinte API que 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 endpoint /api/loh/84975, sob carga máxima.
O gráfico a seguir mostra o perfil de memória da chamada do /api/loh/84976 endpoint, alocando apenas um byte a mais:
Note
A byte[] estrutura tem bytes de sobrecarga, razão pela qual 84.976 bytes acionam o limite de 85.000.
Comparando os dois gráficos anteriores:
- O Working Set é semelhante para ambos os cenários, cerca de 450 MB
- Os pedidos LOH (84.975 bytes) mostram principalmente coleções Geração 0.
- Os pedidos Over LOH geram coleções constantes de Gen 2, que são caras. É necessário adicionar mais capacidade de CPU e a taxa de transferência diminui aproximadamente 50%.
Objetos grandes temporários são problemáticos porque causam coleções da Geração 2.
Para um desempenho máximo, minimize o uso de objetos grandes. Se possível, divida objetos grandes. Por exemplo, Response Caching Middleware em ASP.NET Core divide as entradas de cache em blocos inferiores a 85.000 bytes.
Os links a seguir mostram a abordagem ASP.NET Core para manter objetos abaixo do limite de LOH:
Para obter mais informações, consulte:
HttpClient
Usar incorretamente a HttpClient classe pode resultar numa fuga de recursos.
Os recursos do sistema (como ligações a bases de dados, sockets de rede, handles de ficheiros, etc.) apresentam dois problemas:
- São mais escassos do que a memória.
- São mais problemáticas quando vazadas do que a memória.
Desenvolvedores experientes de .NET sabem chamar o método Dispose em objetos que implementam a interface IDisposable. Não descartar objetos que implementam IDisposable normalmente resulta em fuga de memória ou em desperdício de recursos do sistema.
HttpClient implementa IDisposable, mas não deve ser descartado em todas as invocações. Pelo contrário, HttpClient deve ser reutilizado.
O ponto de extremidade a seguir cria e descarta uma nova HttpClient instância 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 estejam descartadas, demora algum tempo até o sistema operativo libertar a ligação real à rede. O processo de criar continuamente novas ligações resulta no esgotamento dos portos. Cada conexão de cliente requer sua própria porta de cliente.
Uma maneira de evitar a exaustão de portas é reutilizar a 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 HttpClient instância é liberada quando o aplicativo é interrompido. Este exemplo mostra que nem todos os recursos descartáveis devem ser descartados após cada uso.
Os artigos seguintes descrevem uma melhor forma de gerir a vida útil de uma HttpClient instância:
Gestão de objetos
O exemplo anterior mostrou como a instância pode ser tornada HttpClient estática e reutilizada por todas as solicitações. A reutilização evita a falta de recursos.
O pooling de objetos é uma alternativa:
- Utiliza o padrão de reutilização.
- O design é ideal para objetos que são caros de criar.
Um pool é uma coleção de objetos pré-inicializados que podem ser reservados e libertados 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 instancia um byte buffer que é 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 seguinte mostra a chamada da API anterior com carga moderada:
O gráfico revela que as coleções da Geração 0 acontecem cerca de uma vez por segundo.
O código pode ser otimizado agrupando em pool o byte buffer usando a classe ArrayPool<T>. Uma instância estática é reutilizada entre solicitações.
O que é diferente nesta abordagem é que um objeto em pool é devolvido pela API:
- O objeto está fora de seu controle assim que você retorna do método.
- Não é possível liberar o objeto.
Para configurar a eliminação do objeto:
- Encapsular a matriz agrupada em um objeto descartável.
- Registe o objeto em pool usando o método HttpContext.Response.RegisterForDispose.
RegisterForDispose trata de chamar Dispose o objeto alvo, pelo que o objeto só é libertado após a conclusão do pedido HTTP.
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;
}
Aplicar a mesma carga da versão não agrupada resulta no seguinte gráfico:
A principal diferença são os bytes atribuídos e, como consequência, menos coleções Gen 0.