[.NET Jogando no Very Hard] Gerenciamento de Memória Parte II
- [.NET Jogando no Very Hard] Gerenciamento de Memória Parte II
**Garbage Collector — História**
Alocação/Liberação de memória é um desafio para qualquer linguagem de programação, atualmente a maior parte das linguagens possuem um Garbage Collector atuando nessa frente, porém vale ressaltar que nem todos são iguais e ou funcionam de forma similar, mais os conhecimentos obtidos nesta artigo vão lhe permitir ter uma visão geral desse em componente.
Em 1984 David Ungar se tornou pai da teoria geracional, ele apresentou em seu trabalho a universidade de Berkeley na California todo o embasamento ainda hoje utilizado, e maioria dos GC`s utiliza em suma seus conceitos.
Em seu trabalho Generation Scavenging: A Non-disruptive High Perfornmance Storage Reclamation Algorithm, Ungar entrega uma frase icônica ate hoje conhecida pela maioria de nós programadores.
Objetos Jovens(Novos) morrem jovens, portanto nosso algoritmo de recuperação não deve se preocupar e ou perder tempo com objetos Velhos(Antigos)
Toda teoria geracional nasceu desta e outras observações apresentadas por Ungar.
**Garbage Collector — Plataform .Net**
No ecossistema dotnet o GC é um dos principais componentes da plataforma dotnet, responsável por gerenciar a alocação e liberação de memória manipulada pela CLR.
Escrito em C++ o GC comumente atua de forma apartada do processo de nossas aplicações, basicamente ele é executado em um conjunto de thread`s, o que garante performance e controle em um alto nível de abstração, nos desenvolvedores muito pouco manipulamos o GC e na maioria esmagadora das vezes nem se preocupamos com ele.
Você encontra o todo o código fonte do GC juntamente com a runtime da plataforma dotnet em: https://github.com/dotnet/runtime/blob/main/src/coreclr/gc/unix/cgroup.cpp
Neste repositório já podemos observar que temos 2 tipos de GC garantindo a interoperabilidade entre os sistemas operacionais unix & windows.
No host que hospeda o processo dotnet cada CPU física/logica possui um bloco de memória heap gerenciado exclusivo para cada processo sendo sendo executado, conforme observamos na imagem abaixo.

Obviamente essa imagem é bastante simplista e talvez você seu pergunte, como não há conflito de alocação em endereços iguais? isso nosso querido sistema operacional entrega pra gente de bandeja, para você desenvolvedor precisa ficar bastante claro que quase nunca manipulamos memória física de verdade, durante a criação e ou fork de um processo existe um mapeamento entre o endereço físicos e o virtuais utilizado pelo nosso GC totalmente controlado pelo nosso sistema operacional host.
**Garbage Collector — Gerações**
Como citado acima todo o embasamento teórico nasceu com o trabalho de Ungar, e ao longo do tempo e em diferentes plataformas (dotnet, java, golang etc…) houveram diversas melhorias.
Nosso GC tem ao todo 3 gerações:
- Gen 0: objetos efêmeros recém alocados pequenos, novas variáveis por exemplo.
- Gen 1: objetos efêmeros de curta duração, como uma requisição HTTP durante sua passada pelos middlewares/controllers do ASP .NET Core por exemplo.
- Gen 2: objetos de longa duração, podem ir para direto na Gen2 durante sua criação, uma lista com diversos itens que serão manipulados durante a execução do programa por exemplo.
Cada geração armazena objetos de diferentes tamanhos, comumente os objetos com mais de 85.000 bytes ficam alocados separadamente, ou seja em nossa memória heap também existem essas 3 subdivisões com intuito de otimizar a alocação e liberação dos objetos.
Durante a inicialização do nosso GC toda essa separação lógica ocorre, preparando e configurando estes espaços, esses espaços são:
- SOH (Small Objects Heap) bloco de memória heap gerenciada que detém objetos menores que 85.000 bytes (Gen0/ Gen1)
- LOH (*Large ***Objects Heap) bloco de memória heap gerenciada que detém objetos maiores que 85.000 bytes (Gen2) comumente listas por exemplo
- POH (Pinned Objets Heap) bloco de memória heap gerenciada que detém objetos fixo para otimizar sua recuperação, introduzido a partir do .NET5.0
Abaixo vamos entender os principais processos, Coleta, Alocação e Desfragmentação, construindo nosso conhecimento sobre o funcionamento do GC
**Garbage Collector — Coleta**
Durante a execução de nosso GC todas as threads gerenciadas pela CLR são suspensas, para que apenas o processo do GC esteja em execução, este comportamento não é sempre assim, depende de fatores como tipo GC que está sendo utilizado, a configuração do GC, bem como em qual geração a coleta ocorrerá.
Aqui vou adicionar um disclamer, por diversas vezes antes de entender o GC em meio a testes de stress/performance eu me perguntava, porque aleatoriamente a minha aplicação perde um pouco de performance, consegue tratar menos requisições e ou eventos em determinada janela de tempo, e após utilizar ferramentas de análise de memória(dotTrace, dotMemory, dotnet-counters) descobri que era nosso amigo GC atuando com seu trabalho de coleta.
Primeira etapa da coleta trata de montar uma árvore/lista de objetos com todas as suas referências, para que o GC possa tomar diversas decisões como quais objetos serão transferidos de geração, quais serão excluídos etc…

Segunda etapa é reorganizar toda essa árvore atualizando todas as referências necessárias, neste ponto é importante você entender que antes de uma coleta sua variável pode estar em um endereço e após isso em outro endereço, isso ocorre porque uma das tarefas mais importante do GC é desfragmentar os blocos de memória para otimizar espaço.
O GC executa de forma apartada a coleta para os objetos SOH e LOH otimizando assim seu algoritmo, com base na teoria objetos novos morrem cedo, objetos antigos não precisam ser verificados constantemente.
Uma coleta também pode ocorrer se utilizar o objeto GC, veja o exemplo abaixo
public class Program
{
public static void Main(string[] args)
{
try
{
// meu código
}
finally
{
GC.Collect();
}
}
}
Um comportamento bastante comum da maioria das aplicações, é que grande parte dos objetos já morrem na própria Gen0
Obviamente entre existe grandes diferenças entre os casos em que utilizamos compilações AOT(Ahead Of Time) e JIT(Just In Time) vamos falar disso em um tópico apartado.
**Garbage Collector — Alocação**
Como dito anteriormente uma das principais atividades do GC é realizar a alocação de objetos na memória gerenciada heap, ele possui este controle justamente porque durante a alocação de um objeto em Gen0 se nosso heap não possuir espação então uma coleta será realizada.
Dito isto devemos ter em mente que um dos segredos do GC é otimizar o espaço virtual alocar mais memória quando necessário e ainda realizar o menor número de coletas possíveis.
Para todas as gerações existe o conceito de orçamento que **permite o GC a aumentar virtualmente ate o limite permitido pelo S.O no bloco virtual de memória, este número não é fixo é dinâmico!
O orçamento se expande com um racional baseado no total de espaço sobrando após uma compactação conforme demonstrado na imagem abaixo.

Durante a alocação de um novo objeto os blocos de memória possuem 3 estados que podem gerar bastante confusão:
- [Free] bloco de memória sem referencias, disponível para alocação.
- [Reserved] bloco de memória sem referencias, porém disponível para o processo de commit.
- [Commited] bloco de memória com reservada confirmada, indisponível para uso.
Através do controle destes 3 estados que nosso GC garante a atomicidade das operações realizadas neste bloco.
**Garbage Collector — Desfragmentação**
Uma das principais funcionalidades do GC é garantir a otimização do espaço de memória heap gerenciado.
Por isso durante o processo de coleta após preparar a árvore de objetos um processo de compactação é realizado, ou seja podemos ter diversos objetos em diversos blocos distintos, e vários micro blocos, conforme demonstrado na imagem abaixo

Podemos observar na imagem que vários objetos foram alterados de bloco, neste momento a arvore de objetos também foi atualizada com os novos endereços, bem como temos muitos outros blocos livres após o processo de coleta
**Garbage Collector — Recursos Não Gerenciados**
Existes diversos tipos de objeto como clientes http, drivers de conexões com bases de dados, socket`s do sistema operacional, descritores de arquivos etc…
Comumente as api`s de alto nivel fornecem sua implementação utilizando a interface IDisposable, essa que por sua vez tem como funcionalidade notificar ao GC que objeto em questão além de sair da árvore de objetos possui um processo de encerramento/fechamento em outro local não gerenciado pelo processo do GC
Vejamos exemplos comuns:
// Na manipulação de acesso a bases de dados através do ADO .Net
using var sqlConnection = new SqlConnection(connectionString);
// Na manipulação de clientes http
using var httpClient = new _httpClientFactory.Create();
using var httpRequest = new HttpMessageRequest(uri);
await using var httpResponse = await httpClient.SendAsync(httpRequest);
// Na manipulação de arquivos
using var fileStream = new FileStram("./meu_arquivo.txt", FileMode.Open, FileAccess.Read)
A clausula using garante a invocação do método .Dispose() destes objetos
Em todos estes caso temos processos de I.O bloqueantes que precisam garantir sua devida liberação, fora do processo do GC
Uma outra alternativa é o método Destruidor da classe, que permite a execução de um código previamente a coleta pelo GC
public class Player
{
public string NickName { get; set; } = null!;
public int Score { get; set; }
public UnmangedResource UnmangedResource { get; set; }
// construtor
public Player()
{
}
// destrutor
~Person()
{
UnmangedResource.Destroy();
}
}
Aqui vale ressaltar que apenas o GC é capaz de invocar um destrutor, e comumente este objeto nem visto mais por nossa aplicação é.
Este fato é importante pois uma aplicação com diversos de milhares de objetos que dependam de destrutores para realizar sua limpeza podem sobrecarregar nosso GC
**Garbage Collector — Modo**
GC possui os modos de execução
- GC Workstation: é o padrão utilizado quando o processo dotnet será utilizado juntamente com outros processo, oferece um heap por processo dotnet
- GC Server: é principalmente utilizado quando o mesmo será o processo dominante na maquina, nesta versão temos 1 heap gerencia por unidade logica de CPU (modelo raramente utilizado devido ao advento das arquiteturas de microserviços)
Ainda é possível configurar o GC para ser ou não bloqueante, podendo este comportamento ser customizado através de variáveis de ambientes, a lista completa você encontra aqui