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#



