Nota
O acesso a esta página requer autorização. Podes tentar iniciar sessão ou mudar de diretório.
O acesso a esta página requer autorização. Podes tentar mudar de diretório.
Por Sébastien Ros e Rick Anderson
O gerenciamento de memória é complexo, mesmo em uma estrutura gerenciada como o .NET. Analisar e entender problemas de memória pode ser um desafio. Este artigo:
- Foi motivado por muitos fuga de memória e problemas com GC a não funcionar. A maioria desses problemas foi causada por não entender como o consumo de memória funciona no .NET ou não entender como ele é medido.
- Demonstra o uso problemático da memória e sugere abordagens alternativas.
Como funciona a recolha de lixo (GC) no .NET
O GC aloca segmentos de monte onde cada segmento é um intervalo contíguo de memória. Os objetos colocados na pilha são categorizados em uma de 3 gerações: 0, 1 ou 2. A geração determina a frequência com que o GC tenta liberar memória em objetos gerenciados que não são mais referenciados pelo aplicativo. Gerações numeradas mais baixas são GC'd com mais frequência.
Os objetos são movidos de uma geração para outra com base no seu tempo de vida. À medida que os objetos vivem mais, eles são movidos para uma geração superior. Como mencionado anteriormente, as gerações mais altas são sujeitas a menos operações de recolha de lixo. Objetos de vida de curto prazo sempre permanecem 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 singletones ao nível da aplicação geralmente migram para a geração 2.
Quando uma aplicação ASP.NET Core é iniciada, o GC:
- Reserva algum espaço de memória para os segmentos iniciais do heap.
- Aloca 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.
GC. Ressalvas de recolha
Em geral, os aplicativos ASP.NET Core em produção não devem usar 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. 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.
Analisando o uso de memória de um aplicativo
Ferramentas dedicadas podem ajudar a analisar o uso da memória:
- Contando referências de objetos
- Medindo quanto impacto o GC tem no uso da CPU
- Medição do espaço de memória utilizado para cada geração
Use as seguintes ferramentas para analisar o uso da memória:
- dotnet-trace: Pode ser usado em máquinas de produção.
- Analisar o uso de memória sem o depurador do Visual Studio
- Uso da memória de perfil no Visual Studio
Deteção de 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 usada 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.
Exemplo de aplicativo de uso de memória de exibição
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.
- Não é uma ferramenta suportada, no entanto, pode ser usada para exibir padrões de uso de memória de aplicativos ASP.NET Core.
Executa o MemoryLeak. A memória alocada aumenta lentamente até ocorrer um GC. A memória aumenta porque a ferramenta aloca 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:
- Alocado: a quantidade de memória ocupada por objetos gerenciados
- Conjunto de trabalho: O conjunto de páginas no espaço de endereço virtual do processo que estão atualmente residentes na memória física. O conjunto de trabalho mostrado é o mesmo valor que o Gerenciador de Tarefas exibe.
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 a seguir é gerado com uma carga relativamente pequena para mostrar como as alocações de memória são afetadas pelo GC.
O gráfico anterior mostra:
- 4K RPS (Solicitações por segundo).
- As coleções GC da geração 0 ocorrem aproximadamente a cada dois segundos.
- O conjunto de trabalho é constante em aproximadamente 500 MB.
- A CPU é 12%.
- O consumo e liberação de memória (através de GC) é estável.
O gráfico a seguir é obtido na taxa de transferência máxima que pode ser manipulada pela máquina.
O gráfico anterior mostra:
- 22 mil pedidos por segundo (22K RPS)
- As coleções GC da geração 0 ocorrem várias vezes por segundo.
- As coleções da 1ª geração são acionadas porque o aplicativo alocou significativamente mais memória por segundo.
- O conjunto de trabalho é constante em aproximadamente 500 MB.
- A CPU é 33%.
- O consumo e liberação de memória (através de GC) é estável.
- A CPU (33%) não é sobreutilizada, o que permite que a recolha de lixo acompanhe um alto número de alocações.
GC da estação de trabalho vs. GC do servidor
O coletor de lixo .NET tem dois modos diferentes:
- Workstation GC: Otimizado para desktop.
- Server GC. O GC padrão para aplicativos ASP.NET Core. Otimizado para o servidor.
O modo GC pode ser definido explicitamente no arquivo de projeto ou no runtimeconfig.json arquivo do aplicativo publicado. 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.
Observação: A coleta de lixo do servidor não está disponível em máquinas com um único núcleo. Para obter mais informações, consulte 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 conjunto de trabalho cai de 500 MB para 70 MB.
- O GC faz a geração 0 de coleções várias vezes por segundo em vez de a cada dois segundos.
- 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 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ários aplicativos em contêineres são executados em uma máquina, o GC da estação de trabalho pode ter um desempenho maior do que o GC do servidor. Para obter mais informações, consulte Executando com GC de servidor em um contêiner pequeno e Executando com GC de servidor em um cenário de contêiner pequeno Parte 1 – Limite rígido para o heap de GC.
Referências de objeto persistentes
O GC não pode liberar objetos que são referenciados. Objetos referenciados, mas que não são mais necessários, resultam em um vazamento de memória. Se o aplicativo aloca objetos com frequência e não consegue liberá-los depois que eles não são mais necessários, o uso de memória aumentará 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 essa instância é referenciada por um membro estático, o que significa que ela nunca está disponível para coleção.
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 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.
Na imagem anterior:
- O teste de carga do endpoint
/api/staticstringcausa um aumento linear na memória. - O GC tenta liberar memória à medida que a pressão de memória cresce, chamando uma coleção de geração 2.
- O GC não pode liberar a memória vazada. Alocado e 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 de IMemoryCache utiliza WeakReference.
Memória nativa
Alguns objetos .NET dependem da memória nativa. A memória nativa não pode ser coletada pelo GC. O objeto .NET usando memória nativa deve liberá-lo usando código nativo.
O .NET fornece a interface para permitir que os desenvolvedores liberem memória IDisposable nativa. Mesmo que Dispose 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 gerenciada, portanto, qualquer instância será coletada no final da solicitação.
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 é um problema conhecido que está sendo rastreado nesta edição.
O mesmo vazamento pode acontecer no código do usuário, por um dos seguintes:
- Não liberando a classe corretamente.
- Esquecendo-se de invocar o método
Disposedos objetos dependentes que devem ser descartados.
Pilha de objetos grandes
Alocação frequente de memória/ciclos livres podem fragmentar a memória, especialmente ao alocar grandes pedaços 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 esse motivo, o GC cria uma zona de memória especial para objetos grandes, chamada de heap de grandes objetos (LOH). Os objetos com mais de 85.000 bytes (aproximadamente 83 KB) são:
- Colocado no LOH.
- Não compactado.
- Recolhidos durante os GCs da 2ª geração.
Quando o LOH estiver cheio, o GC acionará uma recolha de geração 2. Coleções da 2ª geração:
- São inerentemente lentos.
- Além disso, incorrem no custo de acionar uma coleção 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 a compactação do LOH.
Em contêineres que usam o .NET Core 3.0 ou posterior, o LOH é compactado automaticamente.
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:
Nota: A byte[] estrutura tem bytes de sobrecarga. É por isso que 84.976 bytes aciona o limite de 85.000.
Comparando os dois gráficos anteriores:
- O conjunto de trabalho é semelhante para ambos os cenários, cerca de 450 MB.
- As solicitações registadas sob LOH (84.975 bytes) apresentam principalmente coleções de geração 0.
- As solicitações over LOH geram coleções constantes de geração 2. As coleções da 2ª geração são caras. É necessária mais CPU e a taxa de transferência cai quase 50%.
Objetos grandes temporários são particularmente problemáticos porque causam GCs gen2.
Para obter o máximo desempenho, o uso de objetos grandes deve ser minimizado. Se possível, divida objetos grandes. Por exemplo, o middleware de Cache de Resposta no ASP.NET Core divide as entradas de cache em blocos com menos de 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
O uso HttpClient incorreto pode resultar em um vazamento de recursos. Recursos do sistema, como conexões de banco de dados, soquetes, identificadores de arquivos, etc.:
- São mais escassos que a memória.
- São mais problemáticos quando vazados do que a memória.
Desenvolvedores experientes .NET sabem chamar Dispose em objetos que implementam 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 HttpClient instâncias sejam descartadas, a conexão de rede real leva algum tempo para ser liberada pelo sistema operacional. Ao criar continuamente novas conexões, ocorre o esgotamento das portas . 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.
Consulte o seguinte para obter uma maneira melhor de lidar com o tempo de vida 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.
Agrupamento de objetos:
- Usa o modelo de reutilização.
- É projetado para objetos que são caros para criar.
Um pool é uma coleção de objetos pré-inicializados que podem ser reservados e liberados entre diferentes 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 o resultado de chamar a API anterior com uma carga moderada.
No gráfico anterior, as coletas da geração 0 acontecem aproximadamente uma vez por segundo.
O código anterior pode ser otimizado agrupando o byte buffer usando ArrayPool<T>. Uma instância estática é reutilizada entre solicitações.
O que é diferente com esta abordagem é que um objeto agrupado é retornado da API. Isto significa:
- 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 agrupado com HttpContext.Response.RegisterForDispose.
RegisterForDispose cuidará de chamar o método Dispose no objeto de destino, garantindo que ele só seja liberado 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;
}
A aplicação da mesma carga que a versão não agrupada resulta no seguinte gráfico:
A principal diferença são os bytes alocados e, como consequência, muito menos coleções de geração 0.