Introdução
Quando penso em binários nativos com .NET, muitos devs imaginam imediatamente executáveis compactos, ultra-rápidos no startup, prontos para serverless. A realidade é mais nuançada. Native AOT (Ahead-Of-Time compilation) é poderoso — mas nem sempre é a resposta. Você pode compilar C# para nativo, sim. Mas qual é o preço real? Como funciona internamente? E em quais cenários faz realmente sentido investir tempo de compilação, limitações de reflection e binários maiores?
Eu criei este artigo porque vi muitos devs tentando usar Native AOT sem entender o tradeoff: ganhar 200–300ms de startup time pode custar +30MB de binário e remover reflexão do seu código. Neste guia, vou levar você através da stack de compilação completa — desde C# interpretado por Roslyn até o native code saindo do ILCompiler — e mostrar exatamente quando (e quando não) usar Native AOT em produção.
Pré-requisitos
- Conhecimento de C# e .NET: experiência com .NET 8+ e familiaridade com conceitos como assemblies, IL (Intermediate Language) e garbage collection.
- .NET SDK 9.0+: o artigo foca em .NET 9 e .NET 10 (LTS).
- Noções de compilação: entender diferenças entre JIT (Just-In-Time) e compilação antecipada é útil, mas vou explicar os conceitos conforme necessário.
- Familiaridade com performance: interpretação de métricas básicas de startup, memory footprint e throughput.
O que é Native AOT
Native AOT significa compilar seu código C# em binário nativo antes de executar, não durante a execução. Contraste com o modelo tradicional:
JIT (Just-In-Time):
- Você escreve C#, compila para IL (Intermediate Language)
- No startup do app, o JIT toma a IL
- JIT transforma IL em machine code dinamicamente conforme o código roda
O .NET runtime (incluindo JIT, GC, todas as bibliotecas) rodam na memória
Native AOT:Você escreve C#, compila para IL
Na máquina de build, o NativeAOT toolchain pega a IL, faz análise estática, remove código desnecessário (trimming)
Transforma tudo em machine code nativo (x86-64, ARM, etc.) antes de você executar
-
Resultado é um executável standalone — sem JIT, sem interpreter, sem .NET runtime embutido
💡 Dica: Quando digo “sem .NET runtime”, não é 100% preciso. Você ainda tem partes da biblioteca padrão linkadas (GC, allocator, estruturas de dados). Mas é dramaticamente reduzido comparado ao JIT.
Variações de Compilação
Existe um espectro entre JIT puro e AOT puro:
- JIT Padrão (Tier 0): Compila tudo no startup; depois otimiza com Tier 1 conforme uso
- ReadyToRun (R2R): Compilação antecipada parcial; ainda precisa de runtime e JIT está disponível para código não pré-compilado
- Native AOT Completo: Tudo pré-compilado; sem JIT (restrito por trimming e análise estática) Native AOT é o mais agressivo em tradeoffs: ganhos extremos em startup, mas restrições rígidas em reflection.
Stack de Compilação: Roslyn até Binário Nativo
Aqui está o coração técnico. Vou descrever exatamente o que acontece quando você roda dotnet publish -c Release --self-contained -r win-x64 /p:PublishAot=true.
1. Análise Estática com Roslyn
Roslyn é o compilador C# da Microsoft. Ele:
- Lê
.csfiles - Monta uma árvore sintática (AST)
- Resolve tipos, namespaces, símbolos
- Gera IL (Intermediate Language) — um pseudocódigo agnóstico de plataforma Para Native AOT, Roslyn não faz nada especial nesta etapa — a emissão de IL é idêntica. A “mágica” acontece depois.
2. IL (Intermediate Language)
IL é como “bytecode universal” do .NET. Um exemplo simples:
public int Add(int a, int b) => a + b;
Vira IL assim:
.method public int32 Add(int32 a, int32 b) cil managed {
ldarg.0
ldarg.1
add
ret
}
Isso é agnóstico de plataforma. Qualquer processador pode executar essas instruções (com uma VM ou compilador).
3. ILCompiler & Trimming (Análise de Reachability)
ILCompiler é o núcleo da magia Native AOT. Ele:
- Lê a IL gerada por Roslyn
- Analisa reachability: Começa do entry point (Main), segue cada chamada, cada campo acessado. “Se Main chamar Foo(), e Foo() chamar Bar(), então Bar() é alcançável.”
- Remove código desnecessário — tree-shaking. Se MetodoX nunca é chamado, é deletado do executável final.
-
Detecta reflection problemática: Se você escreve
Type.GetType("MinhaClasse"), o compilador não consegue saber estaticamente qual tipo você quer. Isso é um problema. - Emite código nativo via crossgen2 ou diretamente
4. crossgen2 & RyuJIT
crossgen2 é o componente que pega IL otimizada e transforma em native code (x86-64, ARM64, etc.). Usa parte do RyuJIT (o otimizador do JIT do .NET).
Não é o JIT tradicional (que roda em tempo de execução). É um compilador offline, com mais tempo para otimização.
Output: .obj files (objetos nativos), depois linkados em executável final.
5. Linkedição & Executável Final
Os .obj files são linkados com:
- Runtime libraries (.NET GC, allocators, etc.)
- Stdlib linkada (Collections, I/O, etc.)
- Seu código compilado
Resultado:
app.exe(ou.sono Linux). Standalone. Nenhuma dependência externa do .NET runtime.
Fluxo Visual Completo
C# Code
↓
[Roslyn Compiler]
↓ (emite)
IL (Intermediate Language)
↓
[ILCompiler - Análise de Reachability & Trimming]
↓ (remove dead code)
Otimized IL
↓
[crossgen2 / RyuJIT]
↓ (transforma em)
Native Code (.obj files)
↓
[Linker]
↓ (linkado com runtime)
Standalone Executable (app.exe / app.so)
📝 Exemplo: Um app “Hello World” simples em JIT ocupa ~100MB (runtime + libs). Em AOT, o executável sozinho é ~5MB.
Trimming e Análise Estática: O Grande Obstáculo
Trimming é onde Native AOT brilha — e onde quebra.
O Problema da Reflection
Reflection permite código descobrir tipos, campos, métodos em tempo de execução:
// Reflection clássica
Type tipo = Type.GetType("MyApp.MyClass");
MethodInfo method = tipo.GetMethod("MyMethod");
object result = method.Invoke(instance, params);
Por que isso quebra AOT? O compilador estático não consegue saber em tempo de build qual tipo você vai pedir em Type.GetType(). Então, por segurança:
- Ou compila todos os tipos (aumenta o binário)
- Ou falha em runtime (crash)
Solução: TrimmerRootAssembly e Atributos
A Microsoft oferece atributos para “avisar” o compilador:
// Diga ao trimmer: não remova tipos deste assembly
[assembly: TrimmerRootAssembly]
// Ou, mais fino: diga que este método acessa esses tipos
[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)]
public void ProcessTypes(Type tipo) { ... }
Isso torna your código “AOT-compliant” mas exige esforço e disciplina.
Análise Conservative vs Aggressive
- Conservative: Trimmer presume que qualquer Type.GetType() pode ser usado; mantém tudo (maior binário, mas seguro)
- Aggressive: Trimmer remove tudo que não é provado alcançável (menor binário, mas pode quebrar em runtime) Artigos sobre Native AOT sempre recomendam “teste bastante” — e com razão.
Trade-offs de Performance
Native AOT não é uma bala de prata. Aqui estão os reais tradeoffs:
1. Startup Time (Vencedor: AOT)
| Cenário | JIT | AOT |
|---|---|---|
| Console App simples | 150–500ms | 20–100ms |
| Web API mínima | 300–800ms | 50–200ms |
| Função serverless (cold start) | 1–3s | 100–500ms |
AOT é claramente vencedor. Em serverless, onde você paga por latência do cold start, é transformador.
⚠️ Atenção: AOT coloca todo o tempo de compilação na máquina de build, não no cliente. Se você demora 15 minutos compilando sua app em AOT, bem… existem tradeoffs.
2. Memory Footprint
| Métrica | JIT | AOT |
|---|---|---|
| Runtime + Libs | ~60MB | ~2–5MB |
| App Binário | ~1MB (IL) | 5–50MB (native) |
| Total | ~61MB | ~7–55MB |
Aqui parece que JIT ganha. Mas há nuances:
- O runtime JIT cresce com uso (warm-up, optimizations)
- AOT é fixo — tudo linkado estaticamente
- Em containers, AOT binário é melhor (smaller image, faster pull/start)
3. Throughput e Otimização
| Aspecto | JIT | AOT |
|---|---|---|
| Tiering | Sim (Tier 0 → 1 otimização) | Não (fixo) |
| Profiling | Jit ajusta conforme uso | Estático (sem profiling) |
| Hotpath | Melhora com tempo | Fixo (sem warm-up) |
JIT vence em throughput puro, especialmente em long-running apps (servers, workers). JIT tem mais tempo para otimizar hotpaths. AOT é “congelado” em tempo de build.
Resumo: Quando Usar?
- Startup-bound (AOT wins): Serverless, containers, CLI tools, batch jobs
- Throughput-bound (JIT wins): Web servers, long-running workers, real-time systems
Exemplo Prático: CLI Tool em Native AOT
Vou mostrar um exemplo real: um String Analyzer CLI que conta frequência de caracteres em arquivos.
A razão de escolher CLI: reflection mínima, I/O puro, ideal para AOT.
Estrutura do Projeto
BlogSamples/
├── NativeAot/
│ ├── CliExample/
│ │ ├── StringAnalyzer.Console.csproj
│ │ ├── Program.cs
│ │ ├── AnalyzerService.cs
│ │ ├── README.md
Arquivo .csproj com AOT Enabled
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<PublishAot>true</PublishAot>
<TrimMode>link</TrimMode>
<RuntimeIdentifiers>win-x64;linux-x64;osx-arm64</RuntimeIdentifiers>
<SelfContained>true</SelfContained>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.CommandLine" Version="2.0.0-beta4.24416.1" />
</ItemGroup>
</Project>
Destaques:
-
PublishAot=trueativa o compilador Native AOT -
TrimMode=linkativa trimming agressivo -
RuntimeIdentifierslista plataformas alvo -
System.CommandLineé AOT-friendly (sem reflection para CLI parsing)
Código Exemplo: Program.cs
using System.CommandLine;
using StringAnalyzer.Console;
var fileArgument = new Argument<FileInfo>(
name: "file",
description: "Arquivo para análise");
var rootCommand = new RootCommand("Analisa frequência de caracteres");
rootCommand.AddArgument(fileArgument);
rootCommand.SetHandler((file) =>
{
if (file == null || !file.Exists)
{
Console.WriteLine("Arquivo não encontrado.");
return;
}
var analyzer = new AnalyzerService();
var result = analyzer.Analyze(file.FullName);
Console.WriteLine($"Arquivo: {file.Name}");
Console.WriteLine($"Total de caracteres: {result.TotalChars}");
Console.WriteLine("\nFrequência dos 10 mais comuns:");
foreach (var (ch, count) in result.TopFrequent.Take(10))
{
Console.WriteLine($" '{ch}': {count}");
}
}, fileArgument);
return await rootCommand.InvokeAsync(args);
Por que isso é AOT-friendly:
-
System.CommandLineé otimizado para AOT - Zero reflection dinamicamente (argumentos são resolvidos em compile-time)
- I/O simples, sem patterns avançados
Compilar e Executar
# Restore
dotnet restore
# Publish para AOT (windows x64)
dotnet publish -c Release -r win-x64 /p:PublishAot=true
# Executável sai em: bin/Release/net10.0/win-x64/publish/StringAnalyzer.Console.exe
# Tamanho típico: 8–12 MB
# Teste
./StringAnalyzer.Console.exe myfile.txt
📂 Código Fonte: O exemplo completo está disponível no repositório de exemplos do blog:
BlogSamples/NativeAot/CliExample/
Dicas e Boas Práticas
Use System.CommandLine para CLI parsing
System.CommandLineé otimizado para AOT e não usa reflection. Evite bibliotecas mais antigas que dependem de reflection para atributos de argumentos.Minimize reflection; use source generators quando possível
Se você precisa de serialização, useSystem.Text.Jsoncom source generators (JsonSourceGenerationContext). EviteNewtonsoft.Jsonou reflection pura em AOT.Teste AOT warnings durante desenvolvimento
Compile comdotnet builde cheque warnings. Não deixe para descobrir problemas em produção. Use-Werrorpara converter warnings em erros durante CI.Perfil o tamanho do binário
Usedotnet publish --analyze-warningsoudotnet sizepara entender onde o tamanho está indo. Às vezes, remover uma dependência economiza MB.Considere ReadyToRun como alternativa
Se você quer startup rápido mas não pode abrir mão de reflection,ReadyToRuné um meio-termo. Compile comPublishReadyToRun=trueem vez dePublishAot=true.Teste em ambientes similares ao produção
AOT binário compilado em Windows pode não rodar em Linux (arquivos Object linkam diferente). Sempre compile/teste para o target runtime específico.Prepare-se para binários maiores
AOT geralmente resulta em binários 5–50× maiores que IL puro. Se seu deployment é bandwidth-constrained (edge devices, IoT), considere R2R ou JIT.Documente dependências AOT-compatibility
Se você usa bibliotecas de terceiros, confirme explicitamente que são AOT-compatible. Mantenha isso em seu README de deployment.
Resumo Objetivo
Native AOT — compilação antecipada de C# em binário nativo (x86-64, ARM), eliminando JIT em runtime e criando executáveis standalone sem dependência de .NET runtime.
Stack de compilação — Roslyn (análise estática em C#) → IL (pseudocódigo universal) → ILCompiler (análise de reachability & trimming) → crossgen2 (transformação IL → nativo) → Linker (executável final).
Trimming — remoção automática de código não alcançável (tree-shaking); quebra com reflection dinâmica (
Type.GetType()), requerTrimmerRootAssemblyeDynamicallyAccessedMemberspara avisar o compilador.Startup time — ganho de 200–500ms em apps pequenas, 1–2s em serverless; vantagem crítica em cold starts e containers.
Throughput — JIT vence em long-running apps (warm-up, tiering); AOT é fixo e congelado no build.
Cenários ideais — serverless (AWS Lambda, Azure Functions), containers, CLI tools, batch jobs, IoT, edge computing.
Tradeoffs — startup 3–10× mais rápido, binário 5–50× maior, sem reflection, tempo de compilação substancial, análise estática rigorosa.
Leia Também
- Async/Await em .NET: Fundamentos de Programação Assíncrona
- Performance em .NET: Profiling, Benchmarking e Otimização
- Microservices em .NET: Padrões, Comunicação e Resiliência
Referências
- Microsoft Docs — Native AOT — documentação oficial, configuração e limitações
- RyuJIT and Native Code Generation — source code do compilador JIT/AOT
- Roslyn — C# Compiler — analysis e code generation
- IL Compiler & Trimming — NativeAOT toolchain
- System.CommandLine — AOT-Compatible CLI — library para argumentos sem reflection
- Trimming .NET Applications — guia de trimming e troubleshooting 📬
👉 Artigo completo com todos os exemplos de código: .NET Native AOT: Ecossistema de Compilação em C#
Top comments (0)