Blog Post

Desenvolvedores BR
11 MIN READ

.NET deep dive - Recursos do CLR

ClaudioGodoy's avatar
ClaudioGodoy
Icon for Microsoft rankMicrosoft
Jan 09, 2025

O CLR facilita o gerenciamento de memória, carregamento de assemblies, segurança, tratamento de exceções e sincronização de threads.

Common Language Runtime (CLR) é uma plataforma de execução que suporta diversas linguagens de programação, oferecendo recursos essenciais como:

  • gerenciamento de memória
  • carregamento de assemblies
  • segurança
  • tratamento de exceções
  • sincronização de threads

Durante a execução, o CLR não distingue a linguagem de programação utilizada para escrever o código-fonte, permitindo que os desenvolvedores escolham a linguagem que melhor expressa suas intenções, desde que o compilador seja direcionado para o CLR. Diferentes linguagens de programação oferecem diferentes sintaxes, o que pode ser vantajoso dependendo do tipo de aplicação. Por exemplo, a sintaxe APL pode ser mais eficiente para aplicações matemáticas ou financeiras em comparação com a sintaxe Perl.

A Microsoft desenvolveu vários compiladores para o CLR:

  • C++/CLI
  • C#
  • Visual Basic
  • F#
  • Iron Python
  • Iron Ruby
  • Assembler de Linguagem Intermediária (IL).

JIT - Overview

O IL é uma linguagem de máquina independente de CPU criada pela Microsoft.  O IL é uma linguagem muito mais avançada do que a maioria das linguagens de máquina de CPU, onde pode acessar e manipular tipos de objetos e possui instruções para criar e inicializar objetos, chamar métodos virtuais em objetos e manipular elementos de arrays diretamente. Ele até possui instruções para lançar e capturar exceções para o tratamento de erros. Você pode pensar no IL como uma linguagem de máquina orientada a objetos.

Normalmente, os desenvolvedores programarão em uma linguagem de alto nível, como:

Os compiladores para essas linguagens de alto nível produzem IL. No entanto, como qualquer outra linguagem de máquina, o IL pode ser escrito em linguagem de montagem, e a Microsoft fornece um Assembler de IL, o ILAsm.exe. A Microsoft também fornece um Desassembler de IL, o ILDasm.exe.

Para executar um método, seu IL deve primeiro ser convertido em instruções nativas da CPU. Isso é feito pelo compilador JIT (just-in-time) do CLR.

 

  • Quando Main faz sua primeira chamada para WriteLine, a função JITCompiler é acionada. Ela é responsável por compilar o código IL de um método em instruções nativas da CPU. Como o IL está sendo compilado "na hora", esse componente do CLR é frequentemente chamado de JITter ou compilador JIT.

  • A função JITCompiler identifica o método chamado e o tipo que o define. Em seguida, busca no metadado da assembly pelo IL do método, verifica e compila o código IL em instruções nativas da CPU, que são salvas em um bloco de memória alocado dinamicamente.

  • O JITCompiler então atualiza a referência do método na estrutura de dados interna do tipo criada pelo CLR com o endereço do bloco de memória contendo as instruções nativas da CPU. Finalmente, a função JITCompiler salta para o código no bloco de memória, que é a implementação do método WriteLine (a versão que recebe um parâmetro de String). Quando esse código retorna, ele volta ao código em Main, que continua a execução normalmente.

  • Na segunda chamada de Main para WriteLine, o código já foi verificado e compilado. Portanto, a execução vai diretamente para o bloco de memória, ignorando completamente a função JITCompiler. Após a execução do método WriteLine, ela retorna para Main.

 

Sharplab é um site muito útil que permite inspecionar o código assembly gerado de um método em C#.

Native AOT

Native AOT (ahead-of-time), ou em português "compilação atecipada", é uma funcionalidade introduzida no .NET 7.

Com a compilação antecipada o código nativo é compilado ainda em tempo de publicação, isso significa que aplicações Native AOT não usam o JIT. Essa estratégia proporciona alguns benefícios, como o tempo de inicialização reduzido.

A documentação oficial aponta ter rodado um benchmark entre:

Embora a funcionalidade ofereça alguns benefícios, existe uma série de desvantagens em relação ao seu uso, apontadas pela documentação:

  • Dynamic loading: Não há carregamento dinâmico, por exemplo, Assembly.LoadFile.
  • Run-time code generation: Não há geração de código em tempo de execução, por exemplo, System.Reflection.Emit.
  • C++/CLI: Não há suporte para C++/CLI.
  • Windows: built-in COM: No Windows, não há COM embutido.
  • Trimming: Requer trimming, o que tem limitações.
  • Single file compilation: Implica na compilação em um único arquivo, o que tem incompatibilidades conhecidas.
  • System.Linq.Expressions: Sempre usam sua forma interpretada, que é mais lenta do que o código compilado gerado em tempo de execução.
  • Limitações em profiling e debugging.

Alocação de memória - Managed Heap

Todo programa utiliza recursos como arquivos, buffers de memória, espaço na tela, conexões de rede e recursos de banco de dados. Em um ambiente orientado a objetos, cada tipo identifica um recurso disponível para o programa. Para utilizar um recurso, é necessário seguir os seguintes passos:

  • Alocar memória para criar uma instância do tipo que representa o recurso (geralmente feito usando o operador new do C#).
  • Inicializar a memória para definir o estado inicial do recurso e torná-lo utilizável. O construtor de instância do tipo é responsável por isso.
  • Utilizar o recurso acessando os membros do tipo.
  • Desfazer o estado do recurso para limpeza.
  • Liberar a memória. O coletor de lixo é responsável por esta etapa.

O CLR mantém um ponteiro chamado NextObjPtr, que indica onde o próximo objeto será alocado no heap. Inicialmente, NextObjPtr é definido para o endereço base da região do espaço de endereçamento. À medida que a região é preenchida com objetos, o CLR aloca mais regiões até que o espaço de endereço virtual do processo esteja completo. Em um processo de 32 bits, você pode alocar cerca de 1,5 GB e em um processo de 64 bits, cerca de 8 TB.

O operador new do C# faz com que o CLR execute os seguintes passos:

  • Calcular os bytes necessários para os campos do tipo (e todos os campos herdados de seus tipos base).
  • Adicionar bytes para o overhead de um objeto. Cada objeto possui dois campos de overhead: um ponteiro de objeto de tipo e um índice de bloco de sincronização. Para um aplicativo de 32 bits, cada campo requer 32 bits, adicionando 8 bytes a cada objeto. Para um aplicativo de 64 bits, cada campo tem 64 bits, adicionando 16 bytes a cada objeto.
  • Verificar se há espaço livre suficiente no heap gerenciado. Se houver, o objeto se encaixará no endereço apontado por NextObjPtr, e esses bytes serão zerados. O construtor do tipo é chamado (passando NextObjPtr para o parâmetro this), e o operador new retorna uma referência para o objeto. Pouco antes da referência ser retornada, NextObjPtr avança além do objeto e aponta para o endereço onde o próximo objeto será colocado no heap.

Garbage Collection

Quando um aplicativo usa o operador new para criar um objeto, pode acontecer de não haver espaço suficiente na região de memória para alocar o objeto. Nesse caso, o CLR (Common Language Runtime) realiza uma coleta de lixo (GC).

O algoritmo de rastreamento de referência se concentra em variáveis de tipo de referência, que são aquelas capazes de referenciar objetos no heap. Já as variáveis de tipo de valor contêm diretamente a instância do tipo de valor. As variáveis de tipo de referência, chamadas de roots, podem ser utilizadas em diversos contextos, como campos estáticos e de instância em uma classe, argumentos de método ou variáveis locais.

Quando o CLR inicia uma coleta de lixo, ele suspende todos os threads no processo para evitar que eles acessem objetos e alterem seu estado enquanto são examinados. Em seguida, ele executa a fase de marcação da GC, na qual define um bit em cada objeto para indicar se ele deve ser excluído ou não. Durante essa fase, o CLR verifica todas as roots ativas para determinar quais objetos elas referenciam, seguindo um processo de rastreamento de referência.

Após marcar os objetos que devem ser mantidos, o CLR inicia a fase de compactação da GC. Nessa fase, ele rearranja a memória ocupada pelos objetos marcados no heap, colocando os objetos sobreviventes de forma contígua. Isso traz benefícios como a restauração da localidade de referência, reduzindo o tamanho do conjunto de trabalho da aplicação e melhorando o desempenho ao acessar esses objetos no futuro.

No entanto, ao compactar a memória, surge um problema: as roots que apontavam para objetos sobreviventes agora apontam para onde esses objetos estavam originalmente na memória, não para suas novas localizações. Para resolver isso, o CLR ajusta as roots, garantindo que elas continuem referenciando os objetos corretamente.

Após a compactação, o ponteiro NextObjPtr do heap é ajustado para apontar para a próxima localização após o último objeto sobrevivente.

Gerações

O algoritmo de coleta de lixo (GC) é baseado em várias considerações:

  • É mais rápido compactar a memória de uma parte do heap gerenciado do que do heap inteiro.
  • Objetos mais novos têm períodos de vida mais curtos, e objetos mais antigos têm períodos de vida mais longos.
  • Objetos mais novos tendem a estar relacionados entre si e acessados pela aplicação em momentos próximos.

Para otimizar o desempenho do GC, o heap gerenciado é dividido em três gerações: 0, 1 e 2, permitindo que ele lide separadamente com objetos de vida curta e longa. O GC armazena novos objetos na geração 0. Objetos criados no início da vida da aplicação que sobrevivem às coletas são promovidos e armazenados nas gerações 1 e 2. Essa abordagem permite ao GC liberar a memória em uma geração específica em vez de liberar a memória para todo o heap gerenciado a cada coleta.

Geração 0

A mais jovem, contendo objetos de vida curta, como variáveis temporárias. A coleta de lixo ocorre com maior frequência aqui.

Geração 1

Serve como um buffer entre objetos de vida curta e longa. Após uma coleta de geração 0, a memória é compactada e objetos alcançáveis são promovidos para esta geração.

Geração 2

Contém objetos de vida longa, como dados estáticos em aplicativos de servidor. Objetos que sobrevivem aqui permanecem até serem coletados em futuras coletas.

Coletas de lixo ocorrem em gerações específicas conforme necessário. Sobreviventes são promovidos para a próxima geração até serem determinados como inalcançáveis em futuras coletas. O GC ajusta dinamicamente os limiares de alocação para otimizar a eficiência das coletas, equilibrando o tamanho do conjunto de trabalho da aplicação com a frequência das coletas de lixo.

Clareza e facilidade de entendimento são prioridades. Aqui estão algumas considerações sobre threading e desempenho para a coleta de lixo:

Tipos de GC no CLR

O CLR fornece os seguintes tipos de coleta de lixo (GC):

Coleta de Lixo de Estação de Trabalho

A coleta de lixo de estação de trabalho é projetada para aplicativos cliente. É o tipo de coleta de lixo padrão para aplicativos autônomos. Para aplicativos hospedados, como os hospedados pelo ASP.NET, o host determina o tipo padrão de coleta de lixo.

A coleta de lixo de estação de trabalho pode ser concorrente ou não concorrente. A coleta de lixo concorrente (ou em segundo plano) permite que os threads gerenciadas continuem as operações durante uma coleta de lixo. A coleta de lixo em segundo plano substitui a coleta de lixo concorrente no .NET Framework 4 e em versões posteriores.

Coleta de Lixo de Servidor

A coleta de lixo de servidor é destinada a aplicativos de servidor que necessitam de alto throughput e escalabilidade.

No .NET Core, a coleta de lixo de servidor pode ser não concorrente ou em segundo plano.

No .NET Framework 4.5 e em versões posteriores, a coleta de lixo de servidor pode ser não concorrente ou em segundo plano. No .NET Framework 4 e versões anteriores, a coleta de lixo de servidor é não concorrente.

Modo de Servidor vs. Modo de Estação de Trabalho: Compreendendo as Diferenças

O modo de servidor e o modo de estação de trabalho são dois ambientes distintos que afetam a maneira como a coleta de lixo (GC) é realizada no .NET Framework. Aqui está uma descrição das diferenças entre os dois modos:

Modo de Servidor

  • A coleta de lixo no modo de servidor ocorre em várias threads dedicadas.
  • Essas threads de coleta de lixo rodam em um nível de prioridade mais alto (THREAD_PRIORITY_HIGHEST no Windows).
  • Cada CPU lógica tem seu próprio heap e thread dedicada para a coleta de lixo.
  • Todos os heaps são coletados simultaneamente.
  • A coleta de lixo de servidor é mais rápida porque as threads dedicadas trabalham juntas de forma eficiente.
  • Geralmente, há segmentos de tamanho maior na coleta de lixo de servidor, embora isso possa variar dependendo da implementação.

Modo de Estação de Trabalho

  • A coleta de lixo no modo de estação de trabalho ocorre na thread do usuário que acionou a coleta.
  • A coleta de lixo permanece na mesma prioridade que a thread do usuário.
  • Em computadores com apenas uma CPU lógica, o modo de estação de trabalho é sempre usado, independentemente da configuração definida.
  • Pode haver competição por recursos de CPU, pois as threads de coleta de lixo (que rodam em prioridade normal) competem com outras threads do sistema.

Em resumo, o modo de servidor é ideal para ambientes com alta demanda de recursos e várias CPUs lógicas, enquanto o modo de estação de trabalho é adequado para sistemas de uma única CPU lógica e uso moderado de recursos. A escolha entre os dois modos depende das necessidades específicas de desempenho e recursos do aplicativo .NET.

Conclusão

O .NET é uma plataforma robusta que oferece uma ampla gama de recursos essenciais para o desenvolvimento de aplicativos modernos. Com suporte para diversas linguagens de programação, o CLR facilita o gerenciamento de memória, carregamento de assemblies, segurança, tratamento de exceções e sincronização de threads. A flexibilidade proporcionada pelo CLR permite que desenvolvedores escolham a linguagem que melhor se adapta às suas necessidades, garantindo eficiência e desempenho.

O processo de compilação JIT (just-in-time) transforma o código IL em instruções nativas da CPU, otimizando a execução dos aplicativos. Além disso, a coleta de lixo (GC) gerencia eficientemente a memória, liberando recursos não utilizados e melhorando a performance geral do sistema. A divisão do heap em gerações permite uma coleta de lixo mais eficaz, focando em objetos de vida curta e longa de maneira diferenciada.

Compreender os modos de servidor e estação de trabalho é crucial para otimizar a coleta de lixo de acordo com as necessidades específicas de desempenho e recursos dos aplicativos. Em suma, o CLR é uma ferramenta poderosa que, quando bem utilizada, pode levar ao desenvolvimento de aplicativos robustos, eficientes e escaláveis.

Referências

Updated Jan 09, 2025
Version 1.0
No CommentsBe the first to comment