← voltar ao índice

· 31 min de leitura · por Tiago

.NET 10 por dentro: runtime, JIT, GC e SDK

.NET 10 por dentro: runtime, JIT, GC e SDK

Na aula anterior da nossa série, estudamos C# moderno como ferramenta para modelar software de forma mais segura e expressiva.

Agora precisamos separar duas coisas que muitas vezes aparecem misturadas na cabeça de quem está começando:

C# é uma linguagem. .NET é uma plataforma.

Essa distinção parece teórica até o dia em que uma aplicação começa a consumir memória demais, demora para iniciar, funciona localmente mas falha no servidor, usa uma versão diferente do SDK no pipeline, sofre com muitas alocações ou apresenta comportamento diferente entre Debug e Release.

Nesse momento, saber escrever C# já não é suficiente.

Precisamos entender o caminho completo:

Código C#
   |
   v
Compilador
   |
   v
IL + Metadata
   |
   v
Assembly
   |
   v
.NET Runtime
   |
   +--> Loader
   +--> Type System
   +--> JIT
   +--> Garbage Collector
   +--> Exception Handling
   +--> Threading
   |
   v
Código nativo
   |
   v
CPU

Esse modelo mental será uma das bases mais importantes de todo o roadmap.

Quando chegarmos a ASP.NET Core, programação assíncrona, containers, aplicações distribuídas e inteligência artificial, vários problemas aparentemente diferentes voltarão para os mesmos fundamentos: runtime, alocação de memória, compilação, threads, dependências e publicação.

O .NET 10 é uma versão LTS, com suporte de longo prazo por três anos, e trouxe melhorias no runtime, no JIT, no SDK e em várias bibliotecas da plataforma. Entre as evoluções do runtime estão otimizações de inlining, devirtualização, stack allocation e geração de código.

Nesta aula, porém, nosso objetivo não é fazer uma lista de novidades.

Vamos responder uma pergunta mais importante:

O que realmente acontece entre salvar um arquivo .cs e executar uma aplicação .NET?

Primeiro: o que é .NET?

É comum ouvir frases como:

"Eu programo em .NET."

Tecnicamente, isso pode significar várias coisas.

Você pode escrever aplicações .NET usando C#, F# ou Visual Basic. A plataforma fornece um runtime, bibliotecas, ferramentas, compiladores integrados ao ecossistema, modelos de aplicação e uma infraestrutura comum de execução.

O componente responsável pela execução gerenciada é o CLR, Common Language Runtime. Ele fornece serviços como gerenciamento de memória, execução de código, tratamento estruturado de exceções, integração de tipos, debugging e profiling. O código produzido por linguagens que têm como alvo esse ambiente é normalmente chamado de código gerenciado.

Uma forma útil de pensar é:

C#
|
| linguagem
v
Compilador
|
v
.NET
|
+--> Runtime
+--> Base Class Libraries
+--> SDK
+--> CLI
+--> Tooling
+--> Application frameworks

ASP.NET Core, por exemplo, utiliza a plataforma .NET para construir aplicações web.

Entity Framework Core utiliza .NET para acesso e mapeamento de dados.

Uma aplicação console utiliza o mesmo ecossistema, embora com um modelo de aplicação muito menor.

Isso explica por que conhecer apenas a sintaxe de C# não significa conhecer a plataforma.

SDK e Runtime não são a mesma coisa

Vamos começar por uma confusão extremamente comum.

Suponha que você execute:

dotnet --info

Você verá informações sobre SDKs, runtimes, sistema operacional e arquitetura disponíveis na máquina.

Também pode executar:

dotnet --list-sdks

e:

dotnet --list-runtimes

A própria CLI diferencia claramente essas duas categorias. O comando dotnet funciona tanto como driver das ferramentas do SDK quanto como host para executar aplicações .NET.

O Runtime contém o necessário para executar aplicações compatíveis.

O SDK, Software Development Kit, contém ferramentas para desenvolver, restaurar dependências, compilar, testar, empacotar e publicar aplicações.

Podemos simplificar assim:

Runtime
|
+--> executar aplicações

SDK
|
+--> criar projetos
+--> restaurar pacotes
+--> compilar
+--> testar
+--> publicar
+--> executar ferramentas

Uma máquina de desenvolvimento normalmente possui o SDK.

Um servidor pode precisar apenas do runtime, dependendo do modelo de publicação.

Isso explica um cenário clássico.

Você instala apenas o runtime em uma máquina e executa:

dotnet build

O comando não funciona como esperado porque compilar projetos é responsabilidade do SDK.

Por outro lado, dependendo de como a aplicação foi publicada, o servidor pode nem precisar de uma instalação separada do runtime.

Veremos isso mais adiante quando falarmos de aplicações self-contained.

Criando nosso laboratório de runtime

Vamos criar uma aplicação que usaremos durante a aula.

dotnet new console \n    -n Logby.RuntimeLab \n    --framework net10.0

cd Logby.RuntimeLab

O projeto terá um arquivo semelhante a:

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net10.0</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

Esse pequeno arquivo já contém várias decisões importantes.

<Project Sdk="Microsoft.NET.Sdk">

indica qual SDK de projeto será utilizado pelo MSBuild.

<OutputType>Exe</OutputType>

indica que queremos uma aplicação executável.

<TargetFramework>net10.0</TargetFramework>

define o Target Framework Moniker, TFM, da aplicação.

O TFM não significa simplesmente "qual SDK está instalado".

Ele define o framework que estamos usando como alvo de compilação e influencia as APIs e ativos selecionados para o projeto. A versão do SDK utilizada pela CLI e o target framework do projeto são conceitos independentes.

Essa distinção será muito importante em pipelines.

Você pode ter um SDK mais novo instalado e ainda compilar um projeto para um target framework compatível anterior.

O que acontece quando executamos dotnet build?

Vamos executar:

dotnet build -c Release

À primeira vista, a resposta parece simples:

C# entra
DLL sai

Mas existe bastante coisa acontecendo.

Uma visão simplificada é:

.cs
 |
 v
C# Compiler
 |
 +--> análise sintática
 +--> análise semântica
 +--> verificação de tipos
 +--> geração de código
 |
 v
CIL / IL
+
Metadata
 |
 v
Assembly

Quando um compilador que tem como alvo o CLR compila código gerenciado, ele normalmente produz Common Intermediate Language, também chamado de CIL ou simplesmente IL, além de metadata descrevendo os tipos, membros e referências. Esse material é armazenado em arquivos que o runtime consegue carregar e interpretar.

Isso significa que o compilador C# normalmente não transforma diretamente cada método do seu projeto em instruções finais específicas para o processador da máquina naquele momento.

Existe uma etapa intermediária.

Essa etapa é uma das razões que tornam o ecossistema .NET tão flexível.

C# não vira diretamente Assembly da CPU

Considere:

public static class Calculator
{
    public static int Sum(
        int left,
        int right)
    {
        return left + right;
    }
}

Nós escrevemos C#.

O compilador produz uma representação intermediária equivalente à lógica daquele método.

Conceitualmente:

C#
 |
 v
IL
 |
 v
Código nativo x64

ou:

C#
 |
 v
IL
 |
 v
Código nativo ARM64

A mesma ideia intermediária pode ser utilizada como base para diferentes arquiteturas suportadas, desde que o restante da aplicação e suas dependências também seja compatível.

O processo de execução gerenciada documentado pelo .NET passa pela escolha do compilador, geração de CIL e metadata, conversão para código nativo e execução pelo runtime.

Essa camada intermediária também explica por que olhar apenas para o arquivo .dll e pensar "isso é uma DLL nativa comum" pode levar a uma conclusão errada.

Uma assembly .NET carrega muito mais informação sobre os tipos e o código gerenciado.

Afinal, o que é uma assembly?

Quando fazemos build, normalmente encontramos algo como:

bin/
  Release/
    net10.0/
      Logby.RuntimeLab.dll
      Logby.RuntimeLab.deps.json
      Logby.RuntimeLab.runtimeconfig.json
      ...

A DLL principal é uma assembly .NET.

Uma assembly é uma unidade lógica que pode conter código, tipos, metadata e recursos. Assemblies são unidades fundamentais de implantação, versionamento e reutilização no ecossistema .NET, normalmente materializadas como arquivos .dll ou .exe.

Uma visão simplificada:

Logby.RuntimeLab.dll
|
+--> Manifest
+--> Metadata
+--> IL
+--> Resources

Manifest

O manifest descreve informações da assembly, incluindo identidade, versão, arquivos e referências relevantes.

Metadata

A metadata descreve tipos.

Por exemplo:

Type:
Logby.RuntimeLab.Calculator

Methods:
Sum(int, int) -> int

O runtime não precisa adivinhar o que existe dentro do arquivo.

A assembly é autodescritiva em vários aspectos.

IL

É o código intermediário produzido para os métodos gerenciados.

Resources

Uma assembly também pode conter recursos utilizados pela aplicação.

Essa organização é importante para reflection, carregamento, resolução de tipos, serialização, frameworks, tooling e várias outras capacidades do ecossistema.

Vamos inspecionar a aplicação em execução

Substitua o conteúdo do Program.cs por:

using System.Reflection;
using System.Runtime.InteropServices;

var assembly =
    Assembly.GetExecutingAssembly();

Console.WriteLine(
    $"Assembly: {assembly.GetName().Name}");

Console.WriteLine(
    $"Versão: {assembly.GetName().Version}");

Console.WriteLine(
    $"Framework: {RuntimeInformation.FrameworkDescription}");

Console.WriteLine(
    $"Sistema: {RuntimeInformation.OSDescription}");

Console.WriteLine(
    $"Arquitetura do processo: {RuntimeInformation.ProcessArchitecture}");

Console.WriteLine(
    $"GC Server: {System.Runtime.GCSettings.IsServerGC}");

Console.WriteLine(
    $"Latência do GC: {System.Runtime.GCSettings.LatencyMode}");

Execute:

dotnet run

Esse pequeno programa mostra algo importante: uma aplicação consegue inspecionar informações sobre a própria assembly e sobre o runtime no qual está executando.

Reflection consegue trabalhar com essa metadata e descobrir tipos, métodos, atributos e outras informações.

Esse poder será utilizado mais adiante por frameworks de serialização, Dependency Injection, ORMs e diversos mecanismos que parecem "mágicos" quando não entendemos metadata e reflection.

Mas existe uma consequência.

Recursos muito dinâmicos, especialmente os que dependem de descobrir código apenas em runtime, precisam ser considerados com cuidado quando utilizamos trimming ou Native AOT.

Voltaremos a isso na seção de publicação.

O CLR entra em cena

Até agora temos uma assembly com IL e metadata.

Mas a CPU não executa C#.

E ela também não executa IL diretamente como se IL fosse o conjunto de instruções nativo do processador.

Precisamos transformar o código para instruções adequadas à arquitetura em execução.

É aqui que entra o runtime.

De forma simplificada:

dotnet Logby.RuntimeLab.dll
        |
        v
.NET Host
        |
        v
Runtime
        |
        +--> carrega assemblies
        +--> resolve dependências
        +--> prepara tipos
        +--> gerencia execução
        |
        v
JIT
        |
        v
Código nativo
        |
        v
CPU

O CLR fornece o ambiente de execução gerenciada e utiliza metadata para localizar e carregar tipos, resolver chamadas, organizar instâncias e oferecer serviços ao código durante a execução.

Isso nos leva ao JIT.

O que é o JIT?

JIT significa Just-In-Time compiler.

Sua responsabilidade central é transformar IL em código nativo que a CPU possa executar.

Um modelo mental inicial:

Método em IL
    |
    | primeira necessidade de execução
    v
JIT
    |
    v
Código nativo
    |
    v
Execução

A documentação do processo de execução gerenciada descreve que o JIT converte CIL em código nativo sob demanda e que o código gerado pode ser reutilizado em chamadas posteriores dentro daquele processo.

Mas o runtime moderno é mais sofisticado do que a ideia simplificada de "compila uma vez e acabou".

Tiered Compilation: nem todo método precisa nascer totalmente otimizado

O .NET suporta tiered compilation.

A ideia central é equilibrar dois objetivos que competem entre si:

iniciar rápido
vs
gerar código altamente otimizado

Se o runtime gastar muito tempo otimizando cada método antes de executá-lo, o startup pode piorar.

Se gerar rapidamente código pouco otimizado para tudo, a aplicação pode perder desempenho depois de aquecida.

A compilação em tiers permite que métodos passem por níveis diferentes. Um primeiro nível pode priorizar velocidade de geração ou utilizar código pré-compilado, enquanto outro nível pode produzir código mais otimizado em background. O .NET também pode utilizar dados de perfil para otimizar caminhos e tipos utilizados com mais frequência.

Conceitualmente:

Método chamado
    |
    v
Tier inicial
compilação rápida
    |
    v
Método executa
    |
    +--> pouco utilizado
    |      |
    |      v
    |   permanece
    |
    +--> muito utilizado
           |
           v
       recompilação
       otimizada
           |
           v
       código melhor

Esse modelo ajuda a entender uma coisa importante em benchmarks.

A primeira execução pode não representar o estado estável

Imagine medir um método assim:

var start = DateTime.UtcNow;

var result =
    ExpensiveCalculation();

var elapsed =
    DateTime.UtcNow - start;

Uma única execução pode incluir efeitos de:

  • startup;
  • carregamento de assemblies;
  • inicialização estática;
  • JIT;
  • caches frios;
  • alocação inicial;
  • outras atividades do runtime.

Por isso, microbenchmarks sérios não deveriam ser implementados apenas com um Stopwatch ao redor de uma chamada isolada.

Mais adiante falaremos de BenchmarkDotNet.

O ponto desta aula é compreender o motivo.

Dynamic PGO: o runtime pode aprender com a execução

PGO significa Profile-Guided Optimization.

Em termos simples, o runtime pode observar quais caminhos e tipos são mais relevantes durante a execução e utilizar essas informações para produzir código melhor otimizado.

A configuração de compilação do .NET descreve Dynamic PGO trabalhando em conjunto com tiered compilation para otimizar código com base em informações coletadas durante a execução.

Imagine:

public static decimal Calculate(
    IPricingStrategy strategy,
    Order order)
{
    return strategy.Calculate(order);
}

Uma chamada via interface parece abstrata.

Mas se, em um determinado caminho quente, o runtime consegue observar um padrão suficientemente previsível de tipos, otimizações podem reduzir parte do custo dessas abstrações em determinados cenários.

Não devemos escrever código ruim esperando que o JIT conserte tudo.

Mas também não devemos assumir que abstrações de alto nível necessariamente se traduzem de forma literal em operações caras na máquina.

O runtime moderno faz bastante trabalho entre essas duas camadas.

O .NET 10 melhorou ainda mais o JIT

O runtime do .NET 10 trouxe melhorias em áreas como inlining, devirtualização, geração de código para structs, organização de blocos de código e escape analysis. Também ampliou cenários em que pequenas estruturas ou objetos podem evitar determinadas alocações no heap quando o JIT consegue provar que eles não escapam do escopo relevante.

Isso nos ensina uma lição importante:

O código C# que você lê não é uma representação direta de todas as instruções que a CPU executará.

Existe uma cadeia de transformação e otimização.

Source Code
    |
    v
Compiler
    |
    v
IL
    |
    v
Runtime + JIT
    |
    v
Optimized Native Code

Por isso, discussões de performance precisam de medições.

Dizer que uma construção "obviamente aloca", "obviamente é lenta" ou "sempre vira tal instrução" pode estar errado dependendo da versão do runtime, do contexto e das otimizações aplicadas.

E onde entra o Garbage Collector?

Vamos agora para outra parte fundamental do runtime.

Considere:

for (var i = 0; i < 1_000_000; i++)
{
    var message =
        new ChatMessage(
            Guid.NewGuid(),
            $"Mensagem {i}");
}

public sealed record ChatMessage(
    Guid Id,
    string Content);

Criamos muitos objetos.

Em linguagens com gerenciamento manual de memória, precisaríamos lidar explicitamente com a liberação de determinadas alocações.

Em código gerenciado .NET, o Garbage Collector atua como gerenciador automático de memória para o managed heap, alocando e recuperando memória de objetos conforme eles deixam de ser alcançáveis pela aplicação.

Mas existe um erro conceitual muito comum:

"Tenho Garbage Collector, então memória não é problema."

Isso é falso.

O GC automatiza gerenciamento de memória.

Ele não torna memória infinita.

Ele também não impede que sua aplicação mantenha referências desnecessárias.

Um objeto não é coletado apenas porque você terminou de usá-lo mentalmente

Considere:

public sealed class ConversationCache
{
    private readonly List<string>
        _messages = [];

    public void Add(string message)
    {
        _messages.Add(message);
    }
}

Agora:

var cache =
    new ConversationCache();

while (true)
{
    cache.Add(
        new string('x', 100_000));
}

O GC existe.

Mesmo assim, temos um problema enorme.

Por quê?

Porque cache continua alcançável.

E ele continua mantendo referências para todas as strings adicionadas.

O GC determina objetos vivos a partir de raízes e das referências alcançáveis a partir delas. Objetos que continuam alcançáveis não são considerados lixo simplesmente porque nós, como desenvolvedores, achamos que "não vamos mais precisar deles".

Podemos visualizar:

GC Root
   |
   v
cache
   |
   v
List
   |
   +--> string
   +--> string
   +--> string
   +--> string
   +--> ...

Enquanto existir um caminho válido de uma raiz até os objetos, eles continuam vivos.

Esse é um tipo comum de memory leak em aplicações gerenciadas.

Não é necessariamente memória "esquecida sem free".

É memória ainda referenciada sem necessidade.

Managed Heap e alocação

De forma simplificada, objetos de reference types normalmente vivem no managed heap.

A alocação gerenciada é desenhada para ser eficiente, e o runtime mantém estruturas que permitem disponibilizar espaço para novos objetos rapidamente. O GC entra em ação quando precisa recuperar espaço e reorganizar memória conforme suas estratégias.

Isso explica algo contraintuitivo:

alocar um objeto

não é necessariamente a operação mais cara do processo.

O custo aparece no conjunto:

alocar
+
manter
+
percorrer
+
coletar
+
compactar quando aplicável

Uma aplicação que produz uma enorme quantidade de objetos temporários pode aumentar a pressão sobre o GC.

Por isso, em aplicações de alto throughput, reduzir alocações desnecessárias pode ter impacto significativo.

Mas novamente:

primeiro meça.

Não transforme cada objeto em struct, não reutilize buffers manualmente em todo lugar e não introduza pools sem evidência.

Otimizações de memória aumentam complexidade.

Gerações do GC: por que objetos jovens são tratados de forma diferente?

O GC utiliza uma estratégia geracional.

A intuição por trás disso é que muitos objetos têm vida curta.

Pense em uma requisição web:

Request
|
+--> DTO
+--> strings temporárias
+--> objetos de validação
+--> resultado intermediário
|
v
Response

Boa parte desses objetos pode deixar de ser necessária rapidamente.

Já outros objetos vivem muito mais:

configuração
singletons
caches
estruturas de longa duração

Por isso, o GC organiza objetos em gerações e consegue concentrar coletas frequentes nas áreas onde há maior probabilidade de encontrar objetos de curta duração.

Um modelo mental útil é:

Gen 0
objetos jovens
    |
    | sobreviveram
    v
Gen 1
    |
    | sobreviveram novamente
    v
Gen 2
objetos de vida longa

Essa visão é deliberadamente simplificada.

O que importa neste momento é entender que tempo de vida dos objetos influencia o comportamento da memória.

Uma aplicação que promove muitos objetos desnecessariamente para gerações mais antigas pode tornar coletas completas mais custosas.

Vamos observar o GC

Crie este programa:

Console.WriteLine(
    $"Gen0 antes: {GC.CollectionCount(0)}");

Console.WriteLine(
    $"Gen1 antes: {GC.CollectionCount(1)}");

Console.WriteLine(
    $"Gen2 antes: {GC.CollectionCount(2)}");

var messages =
    new List<string>();

for (var i = 0; i < 100_000; i++)
{
    messages.Add(
        new string('x', 1_000));
}

Console.WriteLine(
    $"Memória aproximada: {GC.GetTotalMemory(false):N0}");

Console.WriteLine(
    $"Gen0 depois: {GC.CollectionCount(0)}");

Console.WriteLine(
    $"Gen1 depois: {GC.CollectionCount(1)}");

Console.WriteLine(
    $"Gen2 depois: {GC.CollectionCount(2)}");

Esse código não deve ser utilizado como benchmark formal.

Ele serve como laboratório.

GC.CollectionCount permite observar quantas coletas foram registradas para uma geração durante a vida do processo.

GC.GetTotalMemory fornece uma estimativa da memória gerenciada.

Execute algumas vezes.

Mude:

var messages =
    new List<string>();

para uma coleção que você deixa sair de escopo.

Teste diferentes quantidades.

Observe o comportamento.

O objetivo é abandonar a ideia de que memória é uma caixa invisível.

Não chame GC.Collect() como solução padrão

Depois de descobrir o GC, surge uma tentação:

GC.Collect();

Parece lógico:

muita memória
    |
    v
forçar coleta
    |
    v
problema resolvido

Na maioria das aplicações, essa é uma conclusão ruim.

O próprio GC possui heurísticas para decidir quando realizar coletas. Forçar coletas indiscriminadamente pode introduzir pausas e trabalho desnecessário, além de mascarar a causa real do problema.

Pergunte primeiro:

Estamos mantendo referências demais?

Existe cache sem limite?

Há objetos grandes demais?

Estamos carregando tudo em memória?

Temos alto volume de alocações temporárias?

Estamos medindo corretamente?

GC.Collect() tem cenários específicos, mas não deve ser o botão de "liberar memória" aplicado por reflexo.

GC não substitui IDisposable

Outra confusão muito importante:

Garbage Collector
!=
liberação determinística de todo recurso

Imagine:

using var stream =
    File.OpenRead(
        "documento.pdf");

O objeto FileStream possui memória gerenciada, mas também representa acesso a um recurso do sistema operacional.

Esperar uma coleta futura para liberar recursos escassos ou externos pode ser inadequado.

Por isso existe o padrão IDisposable.

public sealed class DocumentReader
    : IDisposable
{
    private readonly FileStream _stream;

    public DocumentReader(
        string path)
    {
        _stream =
            File.OpenRead(path);
    }

    public void Dispose()
    {
        _stream.Dispose();
    }
}

Uso:

using var reader =
    new DocumentReader(
        "documento.pdf");

O ponto central é:

GC
gerencia memória gerenciada

Dispose
expressa liberação determinística de recursos

Eles podem se relacionar em implementações mais complexas, mas não são substitutos.

Mais adiante veremos IAsyncDisposable, muito importante para alguns recursos assíncronos.

Finalizers não são um Dispose automático

Outro erro comum é pensar:

"Se eu criar um finalizer, o runtime limpa tudo para mim."

Finalização adiciona custo e complexidade ao ciclo de vida do objeto.

Na maioria dos códigos de aplicação, você não deveria criar finalizers casualmente.

Eles são relevantes principalmente em cenários de baixo nível, quando um tipo possui responsabilidade direta sobre recursos não gerenciados e precisa participar corretamente do padrão de liberação.

Em código de negócio comum, prefira tipos já existentes que encapsulam esses detalhes e use using, IDisposable ou IAsyncDisposable adequadamente.

Stack e Heap: cuidado com explicações simplistas

Você provavelmente já ouviu:

value type fica na stack
reference type fica no heap

Essa frase é útil apenas como uma introdução muito simplificada e rapidamente se torna enganosa.

A localização real depende do contexto.

Um value type pode fazer parte de um objeto no heap.

Capturas, boxing, arrays, campos e otimizações do JIT mudam o cenário.

Além disso, o .NET 10 ampliou otimizações de escape analysis e stack allocation em alguns casos, permitindo que determinados objetos ou arrays que não escapam sejam tratados de forma mais eficiente.

Uma regra mental melhor é:

value type
tem semântica de valor

reference type
tem semântica de referência

Depois, quando performance realmente importar, investigamos onde e como o runtime alocou aquela construção específica.

Não modele seu domínio com base em um desenho simplificado de stack e heap.

O que são deps.json e runtimeconfig.json?

Depois de um build, além da DLL principal, você verá arquivos auxiliares.

Por exemplo:

Logby.RuntimeLab.deps.json

Logby.RuntimeLab.runtimeconfig.json

O arquivo .deps.json descreve informações de dependências utilizadas pelo processo de resolução da aplicação.

A documentação da CLI descreve esse arquivo como contendo dependências, dependências de compilação e informações de versão usadas na resolução de assemblies e conflitos.

Já o runtimeconfig.json contém configurações usadas para definir aspectos de execução e framework necessários.

Você normalmente não edita manualmente esses arquivos gerados no fluxo cotidiano.

Mas saber que eles existem ajuda a depurar problemas de:

runtime incompatível
dependência ausente
roll-forward
configuração de runtime
execução em servidor

Em vez de tratar a pasta bin como uma caixa preta, comece a olhar o que existe nela.

dotnet run não é o mesmo objetivo de dotnet publish

Durante desenvolvimento, executamos:

dotnet run

Esse comando é conveniente porque parte do código-fonte, utiliza o processo de build quando necessário e executa a aplicação.

Para preparar uma aplicação para distribuição, usamos:

dotnet publish -c Release

Publicar significa preparar os binários, dependências e arquivos relacionados para implantação fora do ambiente de desenvolvimento. O .NET suporta diferentes modos de publicação, e essa escolha define o que precisa existir na máquina de destino.

Isso nos leva a uma decisão arquitetural e operacional importante.

Framework-dependent deployment

No modo framework-dependent, a aplicação depende de um runtime .NET compatível disponível no ambiente de destino.

Conceitualmente:

Servidor
|
+--> .NET Runtime
|
+--> Minha aplicação
    +--> DLL
    +--> dependências

A publicação tende a não precisar carregar uma cópia completa do runtime junto da aplicação.

Esse modelo pode ser excelente quando você controla o ambiente e gerencia o runtime de forma centralizada.

A documentação oficial de publicação diferencia framework-dependent de self-contained justamente pela presença ou não do runtime necessário dentro do artefato publicado.

Exemplo:

dotnet publish \n    -c Release \n    --self-contained false

O trade-off é claro.

Você reduz a responsabilidade do pacote da aplicação, mas passa a depender da presença de um runtime adequado no ambiente.

Self-contained deployment

No modo self-contained, publicamos a aplicação junto com o runtime necessário para executá-la.

Conceitualmente:

Servidor
|
+--> Minha aplicação
    |
    +--> app
    +--> dependências
    +--> runtime necessário

A máquina não precisa ter uma instalação separada daquele runtime para iniciar a aplicação publicada dessa forma. O custo é um artefato maior e a necessidade de considerar plataforma e arquitetura de destino.

Exemplo para Linux x64:

dotnet publish \n    -c Release \n    -r linux-x64 \n    --self-contained true

Agora introduzimos outro conceito importante:

RID
Runtime Identifier

linux-x64, linux-arm64 e win-x64 são exemplos de identificadores usados para expressar uma plataforma de destino.

Isso importa quando há componentes específicos da plataforma.

Portable não significa "qualquer código funciona em qualquer lugar"

.NET é multiplataforma.

Isso não significa que qualquer programa que você escreva seja automaticamente multiplataforma.

Considere:

if (OperatingSystem.IsWindows())
{
    // usa uma API específica do Windows
}

Ou uma biblioteca nativa disponível apenas em determinado sistema.

Seu código pode depender de:

APIs do sistema operacional
bibliotecas nativas
drivers
fontes
comandos externos
paths
case sensitivity
certificados
configurações locais

Portabilidade da plataforma e portabilidade da aplicação são coisas diferentes.

Essa distinção será extremamente importante quando chegarmos a Docker.

Um container Linux não transforma magicamente uma dependência Windows em multiplataforma.

ReadyToRun: reduzindo parte do trabalho do JIT

Existe uma opção intermediária chamada ReadyToRun.

Com ReadyToRun, assemblies podem incluir código pré-compilado para reduzir parte do trabalho de JIT durante a inicialização e nos primeiros usos. Isso pode melhorar startup, mas aumenta o tamanho dos binários e envolve trade-offs próprios.

Exemplo:

dotnet publish \n    -c Release \n    -r linux-x64 \n    -p:PublishReadyToRun=true

Não pense nisso como:

ReadyToRun = sempre mais rápido

A pergunta correta é:

Qual métrica queremos melhorar?

Startup?

Throughput depois de aquecido?

Tamanho do artefato?

Memória?

Tempo de build?

Uma otimização pode melhorar uma dimensão e piorar outra.

Native AOT: compilando antes da execução

Native AOT muda bastante o modelo.

Em vez de depender do JIT para compilar os métodos durante a execução tradicional, a aplicação é compilada antecipadamente para código nativo.

Conceitualmente:

C#
 |
 v
Build / Publish
 |
 v
Native compilation
 |
 v
Executable nativo

A publicação Native AOT pode oferecer startup rápido e menor consumo de memória em determinados cenários, mas impõe restrições importantes a recursos dinâmicos. Reflection dinâmica, geração de código em runtime e bibliotecas que dependem de padrões que o processo de análise não consegue determinar completamente precisam de atenção especial.

Um projeto pode habilitar:

<PropertyGroup>
  <PublishAot>true</PublishAot>
</PropertyGroup>

e depois ser publicado para um RID específico:

dotnet publish \n    -c Release \n    -r linux-x64

Mas não adote Native AOT apenas porque "AOT é mais rápido".

Precisamos avaliar compatibilidade, bibliotecas, reflection, tamanho, build, debugging, startup e throughput.

Um guia rápido

Use o modelo tradicional com JIT quando:

você quer máxima compatibilidade
usa bastante dinamismo
startup não é o principal gargalo
quer aproveitar otimizações adaptativas do runtime

Considere ReadyToRun quando:

startup importa
você quer reduzir parte do custo inicial de JIT
o aumento do artefato é aceitável

Avalie Native AOT quando:

startup muito rápido é importante
footprint é relevante
o conjunto de dependências é compatível
o uso de reflection e geração dinâmica é controlado
você aceita builds específicos por plataforma

Não existe vencedor universal.

global.json: controlando o SDK da equipe e do CI

Imagine este cenário:

Seu notebook
SDK 10.0.x

Notebook do colega
SDK 10.0.y

Pipeline
SDK diferente

Mesmo quando todos conseguem compilar, pequenas diferenças de tooling podem introduzir comportamento inesperado ao longo do tempo.

O arquivo global.json permite controlar a seleção do SDK feita pela CLI independentemente do target framework definido pelo projeto. A documentação recomenda esse tipo de controle especialmente em cenários de CI, onde queremos builds previsíveis.

Exemplo:

{
  "sdk": {
    "version": "10.0.100",
    "rollForward": "latestFeature"
  }
}

A política exata deve refletir a estratégia do projeto.

O importante é entender que:

global.json
controla seleção do SDK

TargetFramework
controla o framework alvo do projeto

Não confunda os dois.

Um laboratório completo para enxergar a plataforma

Vamos criar um programa que mostra assembly, runtime, GC e ambiente em um único lugar.

using System.Reflection;
using System.Runtime;
using System.Runtime.InteropServices;

var assembly =
    Assembly.GetExecutingAssembly();

var gcInfo =
    GC.GetGCMemoryInfo();

Console.WriteLine(
    "=== Assembly ===");

Console.WriteLine(
    $"Nome: {assembly.GetName().Name}");

Console.WriteLine(
    $"Versão: {assembly.GetName().Version}");

Console.WriteLine();

Console.WriteLine(
    "=== Runtime ===");

Console.WriteLine(
    $"Framework: {RuntimeInformation.FrameworkDescription}");

Console.WriteLine(
    $"OS: {RuntimeInformation.OSDescription}");

Console.WriteLine(
    $"Arquitetura: {RuntimeInformation.ProcessArchitecture}");

Console.WriteLine();

Console.WriteLine(
    "=== Garbage Collector ===");

Console.WriteLine(
    $"Server GC: {GCSettings.IsServerGC}");

Console.WriteLine(
    $"Latency mode: {GCSettings.LatencyMode}");

Console.WriteLine(
    $"Memória disponível: {gcInfo.TotalAvailableMemoryBytes:N0}");

Console.WriteLine(
    $"Heap atual: {GC.GetTotalMemory(false):N0}");

Console.WriteLine();

Console.WriteLine(
    "=== Coletas ===");

for (var generation = 0;
     generation <= GC.MaxGeneration;
     generation++)
{
    Console.WriteLine(
        $"Gen {generation}: " +
        $"{GC.CollectionCount(generation)}");
}

Compile em Release:

dotnet build -c Release

Execute normalmente:

dotnet run -c Release

Depois publique framework-dependent:

dotnet publish \n    -c Release \n    --self-contained false

Agora publique self-contained para seu ambiente.

Por exemplo:

dotnet publish \n    -c Release \n    -r linux-x64 \n    --self-contained true

Compare:

quantidade de arquivos
tamanho da pasta
forma de execução
dependência do runtime instalado

Esse exercício ensina mais do que decorar definições.

Debug e Release não existem apenas para mudar uma palavra na pasta

Ao executar:

dotnet build

estamos usando uma configuração de build.

Durante desenvolvimento, é comum trabalhar com Debug.

Para produção, usamos normalmente Release.

Não devemos assumir que comportamento de performance observado em Debug representa a aplicação publicada para produção.

Compilador, otimizações, símbolos, instrumentação e estratégia de publicação podem produzir diferenças importantes.

Por isso:

"Está lento no meu debugger"

não é uma medição suficiente.

E:

"Na minha máquina ficou rápido"

também não é.

Performance precisa ser medida no contexto correto.

Mais adiante teremos uma aula específica sobre performance tuning.

Armadilhas comuns em projetos reais

Agora que temos o modelo geral, vamos revisar alguns erros frequentes.

1. Instalar o SDK em produção sem necessidade

Em muitos ambientes, o servidor não precisa compilar nada.

Ele precisa apenas executar o artefato publicado.

Instalar ferramentas extras aumenta superfície operacional sem benefício quando o modelo de deployment não exige isso.

A decisão depende do modelo de publicação, mas não trate "instalar o SDK inteiro" como padrão automático.

2. Fazer deploy da pasta de build como se fosse publish

dotnet build prepara o projeto para compilação e desenvolvimento.

dotnet publish prepara um conjunto de arquivos para implantação.

Em pipelines reais, prefira tratar publicação como uma etapa explícita.

3. Não fixar estratégia de SDK no CI

O projeto compila hoje.

O ambiente recebe outro SDK.

Algo muda.

O pipeline começa a produzir resultados diferentes.

Use uma estratégia deliberada de SDK e versionamento.

4. Tratar o GC como desculpa para ignorar memória

Cache ilimitado continua ilimitado.

Uma lista estática continua mantendo referências.

Carregar um arquivo de 5 GB inteiro em memória continua sendo potencialmente problemático.

GC não corrige arquitetura de memória ruim.

5. Forçar GC para esconder pressão de memória

Antes de chamar GC.Collect(), descubra por que existe pressão de memória.

Ferramentas de profiling valem mais do que superstição.

6. Criar finalizers sem necessidade

Finalização altera o ciclo de vida e adiciona complexidade.

Não use finalizers como substituto casual de Dispose.

7. Confundir Dispose com desalocar memória

Dispose não significa:

"apague este objeto da RAM agora"

Ele representa liberação determinística de recursos de acordo com o contrato do tipo.

A coleta da memória gerenciada é responsabilidade do GC.

8. Otimizar para AOT sem necessidade real

Native AOT é uma ferramenta poderosa.

Também possui trade-offs.

Antes de adotar, verifique compatibilidade das bibliotecas e o benefício mensurável.

9. Assumir que todo código C# vira exatamente a estrutura que você imaginou

JIT, inlining, devirtualização, escape analysis e outras otimizações podem transformar bastante a execução.

Meça o código real.

10. Ignorar arquitetura da CPU

Hoje é comum desenvolver em:

x64
ARM64

especialmente com notebooks modernos, servidores cloud e containers.

Dependências nativas e RIDs precisam ser tratados conscientemente.

Como esses fundamentos aparecem em uma API real?

Imagine um endpoint futuro:

POST /api/chat

Ele recebe uma mensagem e chama um modelo de IA.

Por baixo, podemos ter:

ASP.NET Core
    |
    +--> desserializa JSON
    |
    +--> cria objetos
    |
    +--> executa validações
    |
    +--> consulta banco
    |
    +--> cria request HTTP
    |
    +--> recebe streaming
    |
    +--> processa chunks
    |
    +--> serializa resposta

Tudo isso gera trabalho para o runtime.

Podemos ter:

alocações
threads
tasks
buffers
strings
arrays
sockets
assemblies
JIT
GC

Se a aplicação atende uma requisição por minuto, alguns problemas podem nunca aparecer.

Se atende milhares por segundo, pequenas decisões podem ser amplificadas.

É por isso que esta aula vem antes de ASP.NET Core.

Não queremos apenas aprender:

app.MapPost(
    "/chat",
    ...);

Queremos entender a máquina de execução que existe por baixo.

Como isso se conecta com aplicações de IA?

Aplicações de IA podem ser particularmente interessantes do ponto de vista de runtime porque frequentemente trabalham com:

payloads grandes
streaming
JSON
HTTP
arrays
buffers
texto
tokenização
embeddings
concorrência
processamento assíncrono

Imagine receber um documento grande:

PDF
 |
 v
byte[]
 |
 v
extração
 |
 v
string enorme
 |
 v
chunking
 |
 v
muitas strings
 |
 v
embeddings

Uma implementação ingênua pode copiar os mesmos dados várias vezes.

Em pequena escala, funciona.

Em produção, isso pode gerar pressão de memória e aumentar trabalho do GC.

Ou imagine uma aplicação que executa modelos locais.

Agora podemos ter:

CPU
GPU
memória nativa
buffers grandes
interop

Nesses cenários, entender a diferença entre memória gerenciada e recursos nativos deixa de ser curiosidade acadêmica.

O modelo mental que você precisa guardar

Quando escrever:

Console.WriteLine(
    "Hello, .NET");

não pense apenas:

C# -> executou

Pense:

Código C#
    |
    v
Compilador
    |
    v
IL + Metadata
    |
    v
Assembly
    |
    v
.NET Host
    |
    v
Runtime
    |
    +--> carrega dependências
    +--> entende metadata
    +--> gerencia tipos
    +--> gerencia memória
    |
    v
JIT ou estratégia de compilação
    |
    v
Código nativo
    |
    v
CPU

E dependendo da estratégia de publicação:

JIT tradicional
ReadyToRun
Native AOT

podem alterar partes desse caminho.

Esse modelo é muito mais útil do que decorar dezenas de flags.

Guia de decisão para projetos reais

Preciso instalar SDK no servidor?

Se o servidor apenas executará uma aplicação publicada, frequentemente não.

Use o SDK onde ocorre desenvolvimento e build.

Escolha a dependência de runtime de acordo com sua estratégia de publicação.

Framework-dependent ou self-contained?

Prefira framework-dependent quando você controla o runtime do ambiente e quer artefatos menores e gestão centralizada.

Considere self-contained quando quer empacotar o runtime necessário junto da aplicação e reduzir dependência de uma instalação prévia no destino.

Preciso de ReadyToRun?

Considere quando startup é uma métrica relevante e aceite o trade-off de artefatos maiores.

Meça antes e depois.

Preciso de Native AOT?

Considere quando startup, footprint e distribuição nativa justificarem as restrições.

Valide todas as dependências.

Preciso mexer em configurações do GC?

Na maioria das aplicações, comece com os defaults adequados ao modelo de aplicação.

Só altere configurações após medir e entender o problema.

Devo chamar GC.Collect()?

Quase nunca como reação automática a "memória alta".

Descubra primeiro por que os objetos continuam vivos ou por que existe tanta pressão de alocação.

Exercício da aula

Pegue o projeto Logby.RuntimeLab e complete quatro etapas.

Etapa 1: inspecione o ambiente

Faça o programa exibir:

nome da assembly
versão
framework
sistema operacional
arquitetura
Server GC
modo de latência
memória gerenciada aproximada
número de coletas por geração

Etapa 2: gere pressão de alocação

Crie:

static void AllocateMessages()
{
    var messages =
        new List<string>();

    for (var i = 0;
         i < 100_000;
         i++)
    {
        messages.Add(
            new string('x', 1_000));
    }
}

Meça os contadores antes e depois.

Depois altere o código para manter a coleção viva por mais tempo.

Observe como o comportamento muda.

Etapa 3: compare modelos de publicação

Publique:

framework-dependent
self-contained
ReadyToRun

Compare tamanho dos artefatos e forma de execução.

Não tente eleger um vencedor.

Documente os trade-offs.

Etapa 4: fixe uma política de SDK

Crie um global.json apropriado para seu ambiente e execute:

dotnet --version

Depois navegue para fora da árvore de diretórios onde o arquivo é encontrado e execute novamente.

Entenda como a resolução muda.

O objetivo é aprender por observação.

O que você deveria saber antes de seguir

Ao final desta aula, você deve conseguir explicar com suas próprias palavras:

  1. a diferença entre C# e .NET;
  2. a diferença entre SDK e Runtime;
  3. o que é um TFM;
  4. o que acontece em alto nível durante dotnet build;
  5. o que são IL e metadata;
  6. o que é uma assembly;
  7. qual é o papel do CLR;
  8. por que existe JIT;
  9. o que tiered compilation tenta equilibrar;
  10. por que a primeira execução não representa necessariamente steady state;
  11. qual é o papel do Garbage Collector;
  12. por que GC não impede memory leaks lógicos;
  13. por que IDisposable não é substituído pelo GC;
  14. a diferença conceitual entre framework-dependent e self-contained;
  15. onde ReadyToRun e Native AOT entram;
  16. por que global.json e TargetFramework resolvem problemas diferentes.

Se esses conceitos estiverem claros, as próximas aulas ficarão muito mais fáceis.

Conclusão

Um desenvolvedor .NET não deveria enxergar a plataforma como uma caixa preta que transforma .cs em "alguma coisa que roda".

Entre seu código e a CPU existe uma infraestrutura sofisticada.

O compilador produz IL e metadata.

Assemblies organizam código, tipos, recursos e informações necessárias ao runtime.

O CLR fornece o ambiente de execução gerenciada.

O JIT transforma código intermediário em código nativo e pode otimizar métodos ao longo da execução.

O Garbage Collector gerencia memória automaticamente, mas não elimina a necessidade de pensar em ciclo de vida, referências e pressão de alocação.

O SDK fornece as ferramentas para construir e publicar.

A estratégia de deployment define quanto do runtime acompanha sua aplicação e quais responsabilidades ficam para o ambiente.

Entender essas peças muda a forma como diagnosticamos problemas.

Em vez de dizer:

".NET está consumindo muita memória."

começamos a perguntar:

Quais objetos estão vivos?

Quem mantém as referências?

Qual é a taxa de alocação?

Que gerações estão sendo coletadas?

Existe pressão de objetos grandes?

Em vez de dizer:

"A primeira chamada está lenta."

perguntamos:

É startup?

JIT?

Inicialização?

I/O?

Carregamento de dependências?

Cache frio?

Esse tipo de raciocínio é o que diferencia uso de framework de engenharia de software.

Na próxima aula vamos subir uma camada.

Agora que sabemos o que existe por baixo da execução, vamos entrar no ASP.NET Core e acompanhar uma requisição HTTP desde a chegada ao servidor até a geração da resposta, entendendo Kestrel, middleware pipeline, routing, endpoints, configuração, logging e o ciclo de vida de uma aplicação web moderna.

Fontes

← voltar ao índice