Async/Await: o que realmente acontece quando usamos await?
“await cria uma nova thread” é provavelmente a explicação mais repetida e mais imprecisa sobre async/await em C#. É compreensível: a palavra “assíncrono” puxa a mente para “roda em paralelo em outra thread”. Mas não é isso que acontece, e entender a diferença explica boa parte do comportamento que costuma confundir quem está começando com o assunto.
O que o compilador faz com um método async
Quando um método é marcado com async, o compilador reescreve esse método inteiro como uma máquina de estados, uma struct ou classe que implementa IAsyncStateMachine. Cada await dentro do método vira um ponto de pausa dessa máquina.
public async Task<string> BuscarNomeAsync(int id)
{
var usuario = await repositorio.ObterAsync(id);
return usuario.Nome;
}
Na prática, isso é reescrito em algo conceitualmente parecido com: execute até o primeiro await, registre uma continuação para rodar quando aquela operação terminar, e devolva o controle para quem chamou o método imediatamente, sem bloquear a thread atual esperando o resultado.
Task não é uma thread, é uma promessa de valor futuro
Um Task ou Task<T> representa uma operação que pode ainda não ter terminado, não uma unidade de execução paralela. Uma chamada de I/O assíncrona, como acesso a disco, rede ou banco de dados, geralmente nem usa uma thread dedicada enquanto espera. Ela registra um callback junto ao sistema operacional ou ao driver e libera a thread atual para fazer outra coisa.
É por isso que async/await é tão eficiente para código com muita espera de I/O: a thread não fica ociosa segurando lugar, ela volta para o pool disponível para atender outra requisição enquanto a operação original ainda está em andamento em outro lugar.
Onde a continuação realmente executa
Quando a operação aguardada termina, a continuação registrada precisa rodar em algum lugar. Esse lugar depende do SynchronizationContext capturado no momento do await. Numa aplicação com interface gráfica, isso normalmente significa voltar para a thread de UI. Em ASP.NET Core moderno, não existe um SynchronizationContext especial capturado por padrão, então a continuação geralmente roda em qualquer thread disponível do pool.
Isso explica por que ConfigureAwait(false) existe: ele diz para a máquina de estados não se preocupar em voltar ao contexto original, o que evita uma captura de contexto desnecessária em código de biblioteca que não precisa dela.
Por que o stack trace fica tão ruim de ler
Como cada método async vira uma máquina de estados separada, uma exceção lançada depois de um await aparece no stack trace associada à mecânica interna dessa máquina de estados, não à sequência linear e intuitiva de chamadas que o código original sugere. É por isso que depurar uma exceção em código assíncrono profundo costuma ser mais frustrante do que depurar código síncrono equivalente.
O que pode mudar com runtime async
Previews recentes do .NET vêm trabalhando em runtime async, uma proposta para o próprio runtime entender código assíncrono como conceito de primeira classe, em vez de depender inteiramente da reescrita feita pelo compilador. Ainda é trabalho em andamento, não algo já disponível de forma estável, mas a promessa inclui justamente melhorar esse tipo de stack trace ilegível. Vale acompanhar, mas não vale tratar como realidade atual antes de sair de preview.