DEV Community

Cristiano Rodrigues for Unhacked

Posted on

Async/Await no .NET 10: Eliminando o Overhead da State Machine

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);
        }
    }
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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)
    };
}
Enter fullscreen mode Exit fullscreen mode

Em versões anteriores do .NET, este método geraria:

  • State machine alocada na heap
  • Array meses na 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 meses pode 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();
    }
}
Enter fullscreen mode Exit fullscreen mode

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!

Referências

Top comments (0)