async e await: Esperar Sem Bloquear

async e await: Esperar Sem Bloquear

async e await no C#: por que threads paradas custam caro, Task como promessa de resultado, a máquina de estados por trás do await, esperas sobrepostas, exceções que atravessam o await, as armadilhas de async void e .Result, e a Livraria consultando o ViaCEP com HttpClient.
Linguagem C#

• • 18 min de leitura

Enquanto o disco lê ou a rede responde, a thread não precisa ficar parada esperando: ela pode ir fazer outra coisa e voltar quando o resultado chegar.

"Arquivos, Streams e JSON" terminou com um desperdício escondido: enquanto o disco gira ou a rede entrega os bytes, a thread que pediu a operação fica bloqueada, sem fazer nada útil. Na aula de história vimos que o C# 5.0, em 2012, introduziu async e await, e que o modelo foi depois copiado por JavaScript, Python, Rust, Kotlin e muitas outras linguagens. Nesta aula entendemos o problema que ele resolve, como ele funciona por dentro e as regras para usá-lo sem cair nas armadilhas que derrubam sistemas em produção. E a Livraria dá um passo novo: pela primeira vez, ela vai conversar com um serviço real na internet.

O problema: threads que esperam

Toda operação de entrada e saída (ler um arquivo, consultar um banco, chamar uma API) tem uma característica em comum: quase todo o tempo é espera. O processador pede os dados e depois fica aguardando o disco, a placa de rede ou o servidor do outro lado. Com as chamadas síncronas que usamos até aqui, a thread que fez o pedido fica bloqueada durante toda essa espera:

string conteudo = File.ReadAllText(caminho);   // a thread fica parada aqui até o disco responder

Por que isso importa, se o programa não tem nada melhor para fazer? Porque, na maioria dos programas reais, ele tem:

  • Numa interface gráfica, a thread que espera é a mesma que desenha a janela e responde aos cliques. Enquanto ela espera, a janela congela, e o sistema operacional mostra "não está respondendo".
  • Num servidor, cada requisição em andamento ocupa uma thread. Uma thread reserva memória para a sua pilha (1 MB no Windows; no Linux, em geral 8 MB), e o conjunto de threads disponíveis é limitado. Se cada requisição passa 200 ms esperando o banco de dados, mil requisições simultâneas precisam de mil threads paradas. O servidor esgota as threads, as requisições seguintes entram numa fila, e o sistema fica lento sem estar usando o processador.

A analogia clássica é a do garçom. Um garçom síncrono anota o pedido da mesa 1, leva à cozinha e fica parado na porta da cozinha até o prato ficar pronto. Só então atende a mesa 2. Um garçom assíncrono anota o pedido, entrega à cozinha e vai atender a mesa 2. Quando a cozinha avisa que o prato está pronto, ele volta para levá-lo. Com o mesmo garçom, o restaurante atende muito mais mesas. O garçom é a thread, e a cozinha é o disco, o banco ou a rede.

Task: a promessa de um resultado

O .NET representa uma operação que vai terminar no futuro com o tipo Task, ou Task<T> quando ela produz um valor. Os métodos assíncronos da biblioteca padrão seguem a convenção de terminar em Async e devolver uma Task:

Task<string> tarefa = File.ReadAllTextAsync(caminho);

Essa linha não espera a leitura: ela inicia a operação e devolve imediatamente uma Task<string>, uma espécie de recibo, com a promessa de que um dia haverá ali uma string (ou uma exceção). Para obter o valor sem bloquear, usa-se await:

string conteudo = await File.ReadAllTextAsync(caminho);

Leia assim: "inicie a leitura e, enquanto ela não termina, libere esta thread para outras tarefas; quando terminar, continue daqui com o resultado". O código continua se lendo de cima para baixo, como se fosse síncrono, mas a thread não fica presa durante a espera.

Escrevendo métodos assíncronos

Um método que usa await precisa ser marcado com async, e seu tipo de retorno passa a ser uma Task:

static async Task<int> ContarLinhasAsync(string caminho)
{
    string[] linhas = await File.ReadAllLinesAsync(caminho);
    return linhas.Length;
}

static async Task SalvarNotaAsync(string caminho, string texto)
{
    await File.AppendAllTextAsync(caminho, texto + Environment.NewLine);
}

Repare que ContarLinhasAsync declara Task<int>, mas o return devolve um int: o compilador embrulha o valor na Task. Um método sem valor de retorno devolve Task, e não void. Quem chama usa await também: int total = await ContarLinhasAsync(caminho);. No programa principal, com instruções de nível superior, o await pode ser usado diretamente, e o compilador gera um Main assíncrono.

O que acontece por baixo

Em "Sequências Sob Demanda" vimos que o compilador transforma um método com yield return numa máquina de estados, uma classe que lembra em que ponto parou e continua dali quando alguém pede o próximo elemento. Com async acontece exatamente o mesmo. O compilador reescreve o método como uma máquina de estados. Cada await é um ponto de pausa. Quando a operação aguardada termina, a máquina é retomada a partir daquele ponto, com todas as variáveis locais preservadas.

A sequência, passo a passo:

  1. O método executa normalmente, de forma síncrona, até o primeiro await.
  2. Se a operação aguardada já terminou (um dado em cache, por exemplo), o método simplesmente continua, sem pausa.
  3. Se não terminou, o método registra "quando isso terminar, continue daqui" e devolve o controle a quem o chamou, entregando uma Task ainda incompleta. A thread fica livre.
  4. Quando a operação termina, a continuação é agendada, normalmente numa thread do pool de threads do .NET, e o método segue do ponto onde parou.

E aqui está o ponto mais contraintuitivo de todos: durante a espera por entrada e saída, não existe nenhuma thread esperando. A leitura de disco ou de rede é conduzida pelo hardware e pelo sistema operacional. Quando os dados chegam, o sistema notifica o .NET, que então usa uma thread para continuar o método. Stephen Cleary resumiu isso no título de um artigo famoso: "There Is No Thread", não há thread nenhuma. É por isso que o modelo escala: mil requisições esperando o banco de dados não ocupam mil threads. Não ocupam nenhuma.

Esperar é diferente de iniciar

await espera. Mas você pode iniciar várias operações antes de esperar por qualquer uma delas, e aí elas correm ao mesmo tempo:

// Sequencial: a segunda só começa quando a primeira termina (cerca de 2 segundos)
await Task.Delay(1000);
await Task.Delay(1000);

// Sobrepostas: as duas começam juntas e terminam juntas (cerca de 1 segundo)
Task primeira = Task.Delay(1000);
Task segunda = Task.Delay(1000);
await primeira;
await segunda;

Task.Delay é a versão assíncrona de uma pausa: ela espera o tempo pedido sem bloquear a thread, ao contrário de Thread.Sleep, que bloqueia. É perfeita para experimentos como este e para intervalos entre novas tentativas de uma operação que falhou. A ferramenta completa para aguardar várias tarefas de uma vez, e cancelar operações que demoram demais, é o assunto da próxima aula.

Exceções atravessam o await

Se a operação aguardada falha, a exceção é relançada no ponto do await, e o try/catch funciona exatamente como em código síncrono:

try
{
    string conteudo = await File.ReadAllTextAsync("nao-existe.json");
}
catch (FileNotFoundException)
{
    Console.WriteLine("Arquivo não encontrado.");
}

Isso tem uma consequência importante: uma Task que ninguém aguarda esconde a sua exceção. Se você chamar SalvarNotaAsync(...) sem await e ela falhar, o erro fica guardado dentro da Task descartada, e ninguém fica sabendo. Esse padrão, chamado fire and forget (dispare e esqueça), é uma fonte clássica de falhas silenciosas. A regra: toda Task deve ser aguardada por alguém, ou ter um tratamento explícito de erro.

As armadilhas que derrubam sistemas

async void. Um método async pode tecnicamente devolver void, mas quem o chama não recebe nenhuma Task: não tem como esperar o fim nem capturar exceções. Uma exceção num async void é relançada diretamente no contexto de execução e, em muitos cenários, derruba o processo. async void existe só para uma finalidade: tratadores de eventos de interfaces gráficas, cuja assinatura é imposta pelo framework. Em todo o resto, use async Task.

Bloquear sobre código assíncrono. A tentação é "sair" do mundo assíncrono com .Result ou .Wait():

string conteudo = File.ReadAllTextAsync(caminho).Result;   // NÃO faça isso

Isso bloqueia a thread até o fim da operação, jogando fora todo o benefício. E pode ser pior que isso: em ambientes com um contexto de sincronização, como interfaces gráficas e o ASP.NET antigo, a continuação da tarefa precisa voltar à thread original, que está bloqueada esperando justamente essa continuação. É um deadlock: as duas partes esperam uma pela outra para sempre, e o programa congela. Em servidores modernos o deadlock clássico não acontece, mas o bloqueio consome threads do pool e pode esgotá-lo sob carga. A regra, conhecida como "async de ponta a ponta": se um método chama algo assíncrono, ele também deve ser assíncrono, e quem o chama também, até o topo.

Confundir assincronia com paralelismo. async resolve espera por entrada e saída. Ele não torna um cálculo pesado mais rápido: um método que ordena um milhão de itens, marcado com async e sem nenhum await real, continua ocupando a thread do começo ao fim. Para trabalho de processador, a ferramenta é outra, e ela aparece na aula de concorrência. Numa interface gráfica, await Task.Run(() => CalculoPesado()) move o cálculo para outra thread e mantém a janela respondendo. Num servidor isso não ajuda em nada, porque só troca uma thread por outra.

Duas notas finais. Em bibliotecas, você vai ver await algo.ConfigureAwait(false), que diz "não preciso voltar ao contexto original". Isso evita os deadlocks descritos acima quando a biblioteca é usada numa interface gráfica. Em código de aplicação moderna, raramente é necessário. E a interface IAsyncDisposable, prometida em "Memória no .NET", é usada com await using: para recursos cuja liberação envolve esperar, como descarregar os últimos bytes de um stream de rede.

Juntando tudo: a Livraria consulta um CEP na internet

A Livraria passa a completar o endereço de entrega a partir do CEP, consultando o ViaCEP, um serviço público e gratuito muito usado no Brasil. Ele responde a https://viacep.com.br/ws/{cep}/json/ com um JSON contendo logradouro, bairro, cidade e UF. É exatamente o cenário para o qual async existe: uma espera longa por uma resposta de rede.

using System.Net.Http.Json;

public sealed record EnderecoViaCep(string? Cep, string? Logradouro, string? Bairro, string? Localidade, string? Uf);

public sealed class ConsultaCep(HttpClient http)
{
    public async Task<EnderecoViaCep?> BuscarAsync(Cep cep)
    {
        EnderecoViaCep? endereco = await http.GetFromJsonAsync<EnderecoViaCep>($"ws/{cep.Valor}/json/");
        return endereco?.Localidade is null ? null : endereco;   // CEP inexistente: resposta sem cidade
    }
}

HttpClient é a classe do .NET para requisições HTTP. GetFromJsonAsync faz a requisição, espera a resposta sem bloquear, verifica se o servidor respondeu com sucesso e desserializa o JSON num record, usando as convenções da web: nomes em camelCase, sem diferenciar maiúsculas. O Cep é o objeto de valor de "Source Generators", que garante um formato válido antes de gastar uma chamada de rede. Quando o CEP tem formato válido mas não existe, o serviço responde com um JSON de erro sem os campos de endereço, e por isso tratamos a cidade ausente como "não encontrado".

O programa:

using var http = new HttpClient
{
    BaseAddress = new Uri("https://viacep.com.br/"),
    Timeout = TimeSpan.FromSeconds(10),
};
var consulta = new ConsultaCep(http);

Console.Write("CEP de entrega: ");
string entrada = Console.ReadLine() ?? "";

try
{
    var cep = new Cep(entrada);

    // inicia a consulta de rede e a leitura do catálogo ao mesmo tempo
    Task<EnderecoViaCep?> tarefaEndereco = consulta.BuscarAsync(cep);
    Task<List<Produto>> tarefaCatalogo = Task.Run(() => ArquivoDeCatalogo.Carregar(caminhoCatalogo));

    EnderecoViaCep? endereco = await tarefaEndereco;
    List<Produto> catalogo = await tarefaCatalogo;

    Console.WriteLine(endereco is null
        ? $"CEP {cep} não encontrado."
        : $"Entrega em: {endereco.Logradouro}, {endereco.Bairro}, {endereco.Localidade}/{endereco.Uf}");
    Console.WriteLine($"{catalogo.Count} produto(s) disponíveis para envio.");
}
catch (ArgumentException ex)
{
    Console.WriteLine($"CEP inválido: {ex.Message}");
}
catch (HttpRequestException ex)
{
    Console.WriteLine($"Não foi possível consultar o CEP agora: {ex.Message}");
}
catch (TaskCanceledException)
{
    Console.WriteLine("O serviço de CEP demorou demais para responder.");
}

Para o CEP 01001-000, da Praça da Sé em São Paulo, o serviço devolve logradouro, bairro, cidade e UF, e o programa mostra o endereço completo. Algumas decisões merecem comentário.

  • Um HttpClient para o programa inteiro. Criar um novo a cada requisição é um erro clássico: cada instância abre suas próprias conexões, e sob carga o sistema pode esgotar as portas de rede disponíveis. A regra é reaproveitar. Na fase de API, o IHttpClientFactory vai gerenciar isso por nós.
  • Um tempo limite. Sem Timeout, uma requisição a um servidor que não responde poderia esperar indefinidamente. Quando o limite estoura, a tarefa é cancelada e lança TaskCanceledException.
  • As falhas de rede são esperadas. Sem internet, com o serviço fora do ar ou com o servidor devolvendo erro, surge uma HttpRequestException, e o programa trata cada caso com uma mensagem clara, em vez de quebrar.
  • Duas esperas sobrepostas. A consulta ao CEP e a leitura do catálogo começam juntas. O tempo total fica próximo da mais lenta das duas, e não da soma. Aqui o Carregar síncrono da aula anterior foi embrulhado em Task.Run só para ilustrar. Uma versão verdadeiramente assíncrona, com JsonSerializer.DeserializeAsync e File.OpenRead, é o exercício 1.

Juntar várias tarefas, porém, abre perguntas que esta aula deixou de lado. Como esperar dez consultas de uma vez, e não uma por uma? E se o usuário desistir no meio: como cancelar o que ainda está em andamento, em vez de deixá-lo terminar à toa? E se o resultado não chegar de uma vez, mas aos poucos, como as linhas de um arquivo enorme ou as páginas de uma API? A assincronia tem ferramentas específicas para cada uma dessas situações.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Reescreva o Carregar de "Arquivos, Streams e JSON" como CarregarAsync, usando File.OpenRead e JsonSerializer.DeserializeAsync, e ajuste o programa da aula para usá-lo sem Task.Run.

Ver resposta

✓ Resposta:

public static async Task<List<Produto>> CarregarAsync(string caminho)
{
    try
    {
        await using FileStream fluxo = File.OpenRead(caminho);
        List<ProdutoDto>? dtos = await JsonSerializer.DeserializeAsync<List<ProdutoDto>>(fluxo, Opcoes);
        return (dtos ?? []).Select(ParaDominio).ToList();
    }
    catch (FileNotFoundException)
    {
        return [];
    }
    catch (JsonException ex)
    {
        throw new InvalidOperationException($"O arquivo de catálogo \"{caminho}\" está corrompido.", ex);
    }
}

// no programa:
Task<List<Produto>> tarefaCatalogo = ArquivoDeCatalogo.CarregarAsync(caminhoCatalogo);

await using é a versão assíncrona do using: FileStream implementa IAsyncDisposable, e a liberação pode ser aguardada sem bloquear. DeserializeAsync lê o stream aos pedaços, liberando a thread a cada espera pelo disco. A estrutura de tratamento de erros é idêntica à versão síncrona, porque as exceções atravessam o await normalmente. Sem Task.Run, nenhuma thread extra é ocupada: a leitura e a consulta de rede ficam ambas em espera sem nenhuma thread bloqueada.

Exercício 2

Preveja a ordem exata do que é impresso e explique:

Console.WriteLine("1");
Task tarefa = TrabalharAsync();
Console.WriteLine("3");
await tarefa;
Console.WriteLine("5");

static async Task TrabalharAsync()
{
    Console.WriteLine("2");
    await Task.Delay(100);
    Console.WriteLine("4");
}
Ver resposta

✓ Resposta: A ordem é 1, 2, 3, 4 e 5. TrabalharAsync começa a executar de forma síncrona assim que é chamado e imprime "2". Ao chegar no await Task.Delay(100), a espera ainda não terminou, então o método devolve uma Task incompleta e o controle volta a quem chamou, que imprime "3". Em seguida, await tarefa espera a conclusão. Depois de cerca de 100 ms, o método é retomado de onde parou e imprime "4", completando a tarefa. Só então o programa principal continua e imprime "5". O ponto que costuma surpreender é o "2" antes do "3": um método async não começa numa outra thread. Ele roda normalmente até o primeiro await que realmente precisa esperar.

Exercício 3

Meça com Stopwatch o tempo de três chamadas a Task.Delay(1000) feitas de forma sequencial e de forma sobreposta (iniciando as três antes de aguardar). Explique os resultados.

Ver resposta

✓ Resposta:

using System.Diagnostics;

var relogio = Stopwatch.StartNew();
await Task.Delay(1000);
await Task.Delay(1000);
await Task.Delay(1000);
Console.WriteLine($"Sequencial: {relogio.ElapsedMilliseconds} ms");   // ~3000

relogio.Restart();
Task a = Task.Delay(1000), b = Task.Delay(1000), c = Task.Delay(1000);
await a; await b; await c;
Console.WriteLine($"Sobreposto: {relogio.ElapsedMilliseconds} ms");   // ~1000

Na versão sequencial, cada espera só começa depois que a anterior termina, e o total é a soma: cerca de 3 segundos. Na versão sobreposta, as três esperas são iniciadas praticamente no mesmo instante e correm em paralelo, e o total é o da mais longa: cerca de 1 segundo. Quando o primeiro await termina, as outras duas também já terminaram ou estão quase lá. E nenhuma thread ficou ocupada durante essas esperas: não é paralelismo de processador, é sobreposição de esperas. Esse é o ganho real que a assincronia traz a um servidor que precisa consultar um banco, uma API e um cache para montar uma resposta.

Exercício 4

O código abaixo nunca mostra a mensagem de erro: num programa que continua rodando depois dessa chamada, ele derruba o processo. Explique por quê e corrija-o.

try
{
    Salvar("dados.txt");
}
catch (IOException)
{
    Console.WriteLine("Falha ao salvar.");
}

static async void Salvar(string caminho)
{
    await File.WriteAllTextAsync(caminho, "x");
    throw new IOException("Disco cheio");
}
Ver resposta

✓ Resposta: Salvar é async void: quem chama não recebe uma Task, então não há nada a aguardar nem por onde a exceção voltar. A chamada Salvar("dados.txt") retorna assim que o método chega no primeiro await, e o try termina antes de a exceção ser lançada. Quando a exceção acontece, depois da escrita, não há mais nenhum catch esperando por ela. Ela é relançada diretamente no contexto de execução, sem tratador, e o processo termina com "Unhandled exception" (medido: código de saída 134 no Linux). Se o programa principal acabar antes, como neste trecho isolado, o resultado é ainda mais traiçoeiro: o processo encerra normalmente, com código 0, e o erro some sem deixar rastro (também medido). Nos dois casos, o catch nunca é alcançado. A correção:

try
{
    await Salvar("dados.txt");
}
catch (IOException)
{
    Console.WriteLine("Falha ao salvar.");
}

static async Task Salvar(string caminho)
{
    await File.WriteAllTextAsync(caminho, "x");
    throw new IOException("Disco cheio");
}

Com async Task, a exceção fica guardada na tarefa e é relançada no await, dentro do try, onde o catch a encontra. A única exceção legítima à regra "nunca use async void" são os tratadores de eventos de interfaces gráficas, e mesmo neles o corpo deve ter o seu próprio try/catch.

Exercício 5

Uma aplicação desktop tem, no clique de um botão, o código var total = CalcularTotalAsync().Result;, e a janela congela para sempre. Explique o deadlock e mostre a correção.

Ver resposta

✓ Resposta: Em interfaces gráficas, existe um contexto de sincronização que faz as continuações de um await voltarem à thread da interface, a única que pode mexer nos controles da janela. Dentro de CalcularTotalAsync, algum await inicia uma operação e registra que a continuação deve rodar na thread da interface. Enquanto isso, o .Result bloqueia a thread da interface, esperando a tarefa terminar. A operação termina, e a continuação precisa da thread da interface para prosseguir, mas ela está bloqueada esperando justamente essa continuação. Cada lado espera o outro, para sempre. A correção é "async de ponta a ponta":

private async void BotaoCalcular_Click(object? sender, EventArgs e)
{
    try
    {
        decimal total = await CalcularTotalAsync();
        rotuloTotal.Text = total.ToString("C");
    }
    catch (Exception ex)
    {
        MessageBox.Show($"Não foi possível calcular: {ex.Message}");
    }
}

O tratador do evento passa a ser async void, que é o único caso aceitável, com o próprio try/catch, e usa await no lugar de .Result. A thread da interface fica livre enquanto o cálculo espera, a janela continua respondendo, e a continuação encontra a thread disponível. Numa biblioteca usada por esse tipo de aplicação, acrescentar ConfigureAwait(false) aos await internos também evitaria o deadlock, mas a correção de verdade é não bloquear.

Comentários

Mais em Linguagem C#

Do Código-Fonte ao Processo: Instalando o .NET e Entendendo o que Acontece ao Rodar
Do Código-Fonte ao Processo: Instalando o .NET e Entendendo o que Acontece ao Rodar

Instalar o .NET 10 e entender o caminho do código até o processador: SDK e…

Propriedades e Encapsulamento: Deixar Ler, Controlar a Escrita
Propriedades e Encapsulamento: Deixar Ler, Controlar a Escrita

Propriedades em C#: get e set no lugar de getters e setters, propriedades…

Input, física e colisões: fazendo o jogo parecer justo
Input, física e colisões: fazendo o jogo parecer justo

O que faz um jogo parecer justo: ações no lugar de teclas, pulo com buffer…