O .NET 10 trouxe melhorias significativas na programação assíncrona. Algumas são diretamente relacionadas ao async/await, enquanto outras otimizam o runtime de maneiras que beneficiam especialmente o código assíncrono. Vamos conhecer as principais.
Runtime-Async (Experimental)
Uma das melhorias mais interessantes do .NET 10 é o runtime-async. A ideia é implementar async/await diretamente no runtime/JIT, em vez de depender apenas da transformação do compilador C#.
Como funciona hoje
Quando você escreve um método async, o compilador do C# gera uma máquina de estados. Essa classe contém campos para todas as variáveis locais, o estado atual e o awaiter. A cada await, o método pode "pausar", salvando o estado, e "continuar", restaurando-o.
public async Task<string> BuscarDadosAsync(string url)
{
var cliente = new HttpClient();
var resposta = await cliente.GetStringAsync(url);
return resposta.ToUpper();
}
// máquina de estado
private sealed class <BuscarDadosAsync>d__1 : IAsyncStateMachine
{
public int <>1__state;
public AsyncTaskMethodBuilder<string> <>t__builder;
public string url;
public C <>4__this;
private HttpClient <cliente>5__1;
private string <resposta>5__2;
private string <>s__3;
[Nullable(new byte[] { 0, 1 })]
private TaskAwaiter<string> <>u__1;
private void MoveNext()
{
int num = <>1__state;
string result;
try
{
if (num != 0)
{
<cliente>5__1 = new HttpClient();
}
try
{
TaskAwaiter<string> awaiter;
if (num != 0)
{
awaiter = <cliente>5__1.GetStringAsync(url).GetAwaiter();
if (!awaiter.IsCompleted)
{
num = (<>1__state = 0);
<>u__1 = awaiter;
<BuscarDadosAsync>d__1 stateMachine = this;
<>t__builder.AwaitUnsafeOnCompleted(ref awaiter, ref stateMachine);
return;
}
}
else
{
awaiter = <>u__1;
<>u__1 = default(TaskAwaiter<string>);
num = (<>1__state = -1);
}
<>s__3 = awaiter.GetResult();
<resposta>5__2 = <>s__3;
<>s__3 = null;
result = <resposta>5__2.ToUpper();
}
finally
{
if (num < 0 && <cliente>5__1 != null)
{
((IDisposable)<cliente>5__1).Dispose();
}
}
}
catch (Exception exception)
{
<>1__state = -2;
<cliente>5__1 = null;
<resposta>5__2 = null;
<>t__builder.SetException(exception);
return;
}
<>1__state = -2;
<cliente>5__1 = null;
<resposta>5__2 = null;
<>t__builder.SetResult(result);
}
void IAsyncStateMachine.MoveNext()
{
//ILSpy generated this explicit interface implementation from .override directive in MoveNext
this.MoveNext();
}
[DebuggerHidden]
private void SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine)
{
}
void IAsyncStateMachine.SetStateMachine([Nullable(1)] IAsyncStateMachine stateMachine)
{
//ILSpy generated this explicit interface implementation from .override directive in SetStateMachine
this.SetStateMachine(stateMachine);
}
}
O compilador transforma isso em uma classe com campos, um método MoveNext(), e toda a infraestrutura para gerenciar estados. Funciona bem, mas tem overhead.
O que muda com Runtime-Async
Com runtime-async, o JIT entende nativamente o conceito de "suspender" e "retomar" execução. Em vez de gerar state machines complexas, o código pode ser mais direto, com o runtime gerenciando a suspensão.
Benefícios esperados do Runtime-Async:
- Redução significativa no tempo de execução para chamadas async encadeadas (multi-tier)
- Redução nas alocações de memória por eliminar objetos de state machine
- Melhor performance em cenários com muitas camadas async (Controller → Service → Repository)
- Suspensão e retomada gerenciadas nativamente pelo runtime/JIT
Imagine uma requisição típica de API:
// Controller
public async Task<IActionResult> Get(int id)
{
var dados = await _service.BuscarAsync(id);
return Ok(dados);
}
// Service
public async Task<Dados> BuscarAsync(int id)
{
var entidade = await _repository.GetByIdAsync(id);
return MapearParaDados(entidade);
}
// Repository
public async Task<Entidade> GetByIdAsync(int id)
{
return await _context.Entidades.FindAsync(id);
}
Hoje, cada camada gera sua própria state machine. Com runtime-async, o JIT pode otimizar toda a cadeia, reduzindo o overhead de cada "salto" entre métodos async.
Como habilitar
Para habilitar (ainda experimental), você precisa:
<!-- No .csproj -->
<PropertyGroup>
<EnablePreviewFeatures>true</EnablePreviewFeatures>
<Features>runtime-async=on</Features>
</PropertyGroup>
E a variável de ambiente: DOTNET_RuntimeAsync=1
Limitação atual: As bibliotecas do framework ainda não foram recompiladas com essa feature. Os ganhos são visíveis principalmente em código de aplicação.
ThreadPool Mais Inteligente
O .NET 10 traz melhorias no ThreadPool para prevenir deadlocks em cenários de sync-over-async.
O problema que resolve
Considere este cenário problemático:
public void MetodoSincrono()
{
// Thread A pega um work item da fila local
var resultado = MetodoAsync().Result; // Bloqueia Thread A
// A continuação do MetodoAsync está na fila local de Thread A
// Mas Thread A está bloqueada esperando...
// Deadlock potencial!
}
Quando uma thread bloqueia esperando uma Task, ela pode estar segurando work items na sua fila local que são justamente as continuações necessárias para completar essa Task.
A solução do .NET 10
Antes de uma thread bloquear esperando uma Task, ela agora move work items da sua fila local para a fila global. Isso permite que outras threads processem esses work items, incluindo as continuações que vão desbloquear a Task.
// Internamente, algo como:
void BloquearEsperandoTask(Task task)
{
// NOVO: Move work items locais para fila global
MoverWorkItemsParaFilaGlobal();
// Agora outras threads podem processar as continuações
task.Wait();
}
Como isso ajuda no async
Mesmo que você siga todas as boas práticas e evite .Result e .Wait(), bibliotecas de terceiros ou código legado podem fazer isso. O ThreadPool mais inteligente reduz as chances de deadlock nesses cenários, tornando as aplicações async mais robustas.
Além disso, em cenários de alta carga onde o ThreadPool está saturado, essa melhoria ajuda a evitar situações em que threads ficam bloqueadas esperando trabalho que está "preso" em filas locais de outras threads.
Stack Allocation para Arrays Pequenos
O JIT do .NET 10 pode alocar arrays pequenos diretamente na stack quando detecta que não escapam do método.
O problema que resolve
Em código async, frequentemente criamos arrays temporários:
public async Task ProcessarAsync(Stream stream)
{
byte[] buffer = new byte[4096]; // Alocação na heap
int bytesLidos = await stream.ReadAsync(buffer);
// Processar buffer...
}
// buffer vai para o GC
Cada chamada a esse método aloca um array na heap. Em um servidor processando milhares de requisições por segundo, isso gera pressão significativa no GC.
A solução do .NET 10
O JIT analisa se o array "escapa" do método (é passado para outro lugar, retornado ou armazenado em um campo). Se não escapa, pode alocar na stack:
void ProcessarIds()
{
int[] ids = { 1, 2, 3, 4, 5 }; // Stack allocation!
foreach (var id in ids)
ProcessarId(id);
// Array destruído automaticamente ao sair do método
}
Como isso ajuda no async
Nos métodos async, a situação é mais complexa. A state machine captura variáveis locais, como campos e arrays que cruzam um await, então arrays que cruzam um await geralmente não podem ser stack-allocated. Porém, arrays usados apenas antes do primeiro await ou apenas depois do último podem se beneficiar:
public async Task<int> CalcularAsync()
{
// Este array pode ser stack-allocated (antes do await)
int[] pesos = { 1, 2, 3, 4, 5 };
int soma = pesos.Sum();
var dados = await BuscarDadosAsync();
// Este array também pode ser stack-allocated (depois do await, não escapa)
int[] resultados = { dados.A, dados.B, dados.C };
return resultados.Max() * soma;
}
A redução da pressão do GC significa menos pausas de coleta, o que melhora a latência das operações async, especialmente em cenários de alta throughput.
Escape Analysis para Delegates
Closures e delegates que não escapam do método também podem ser alocados na stack.
O problema que resolve
Closures são extremamente comuns em código async:
public async Task ProcessarListaAsync(List<int> numeros, int limite)
{
// Esta closure captura 'limite' e aloca na heap
var filtrados = numeros.Where(n => n > limite).ToList();
await ProcessarFiltradosAsync(filtrados);
}
Cada invocação cria um objeto closure na heap para capturar a variável limite. Em código frequentemente chamado, isso acumula.
A solução do .NET 10
O JIT agora pode detectar quando uma closure não escapa e alocá-la na stack:
void Filtrar(List<int> numeros)
{
int limite = 10;
// Esta closure pode ser stack-allocated
var grandes = numeros.Where(n => n > limite).ToList();
// Closure destruída ao sair do método
}
Como isso ajuda no async
Código async frequentemente usa lambdas para continuações, transformações e callbacks:
public async Task<List<ProdutoDto>> BuscarProdutosAsync(string categoria)
{
var produtos = await _repository.GetByCategoriaAsync(categoria);
// Estas closures são candidatas a stack allocation
var ativos = produtos.Where(p => p.Ativo).ToList();
var dtos = ativos.Select(p => new ProdutoDto
{
Nome = p.Nome,
Preco = p.Preco
}).ToList();
return dtos;
}
Combinado com a melhoria de arrays, código async que faz transformações de dados se beneficia duplamente: menos alocações de closures e menos alocações de arrays intermediários.
Impacto em Task.Run e continuações
Lambdas passadas para Task.Run ou ContinueWith geralmente escapam (são passados para o ThreadPool), então não se beneficiam diretamente. Porém, lambdas usadas localmente em operações LINQ antes ou depois de awaits podem ser otimizadas.
Juntando Tudo: O Impacto Combinado
Vejamos um exemplo que se beneficia de todas essas melhorias:
public async Task<RelatorioDto> GerarRelatorioAsync(int clienteId)
{
// 1. Stack allocation para array de IDs
int[] meses = { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 };
// 2. Runtime-async otimiza esta cadeia
var vendas = await _vendasService.BuscarPorClienteAsync(clienteId);
var pagamentos = await _pagamentosService.BuscarPorClienteAsync(clienteId);
// 3. Closures podem ser stack-allocated
var vendasPorMes = meses.Select(m => new
{
Mes = m,
Total = vendas.Where(v => v.Data.Month == m).Sum(v => v.Valor)
}).ToList();
// 4. ThreadPool inteligente previne deadlocks se alguém chamar .Result
return new RelatorioDto
{
ClienteId = clienteId,
VendasPorMes = vendasPorMes,
TotalPagamentos = pagamentos.Sum(p => p.Valor)
};
}
Em versões anteriores do .NET, este método geraria:
- State machine alocada na heap
- Array
mesesna heap - Múltiplas closures para os lambdas
- Possível risco de deadlock se chamado com .Result
No .NET 10:
- Runtime-async pode otimizar a cadeia de awaits
- Array
mesespode ir para a stack - Closures podem ir para a stack
- ThreadPool protege contra deadlocks
Diagnóstico: Verificando as Otimizações
Para verificar se as otimizações estão sendo aplicadas, você pode usar o BenchmarkDotNet:
[MemoryDiagnoser]
public class AsyncBenchmarks
{
[Benchmark]
public async Task<int> ProcessarComArrayAsync()
{
int[] valores = { 1, 2, 3, 4, 5 };
await Task.Delay(1);
return valores.Sum();
}
[Benchmark]
public async Task<int> ProcessarComClosureAsync()
{
int limite = 3;
var lista = new List<int> { 1, 2, 3, 4, 5 };
await Task.Delay(1);
return lista.Where(x => x > limite).Sum();
}
}
Compare os resultados entre .NET 9 e .NET 10 para ver a redução em alocações.
Conclusão
O .NET 10 trouxe um conjunto de otimizações que, combinadas, melhoram significativamente a performance de código async:
- Runtime-async reduz o overhead das state machines
- ThreadPool inteligente previne deadlocks em cenários de sync-over-async
- Stack allocation para arrays reduz pressão no GC
- Escape analysis para delegates elimina alocações de closures temporárias
Essas melhorias são especialmente impactantes em aplicações de alto throughput, como APIs web, onde código async é executado milhares de vezes por segundo.
Convido você a realizar seus próprios benchmarks comparando .NET 9 e .NET 10 e compartilhar suas descobertas. Aproveite e leia sobre escape analysis e pare de alocar lixo na heap sem perceber.
Até a próxima!
Top comments (0)