//JorgenHoc
← Todos os artigos
Async C#Guia PrincipalPor Jorge CalderónAtualizado 17 min read

Guia completo de async/await em C# — Task, ValueTask, ConfigureAwait

Domine async/await em C# desde os fundamentos: Task, Task<T>, ValueTask, ConfigureAwait(false), armadilhas comuns e tratamento de erros com exemplos práticos executáveis.

#csharp#async#dotnet

A programação assíncrona em C# é enganosamente simples de escrever e enganosamente fácil de errar. As palavras-chave async/await lidam com a maior parte da complexidade, mas entender o que acontece por baixo separa o código que funciona do código que tem bom desempenho, trata erros corretamente e não entra em deadlock sob carga.

Cada tópico aqui tem um artigo dedicado com um projeto de console executável onde cada afirmação de comportamento é uma asserção que falha ruidosamente se um runtime futuro mudar — reunidos em Samples executáveis deste guia no final. Escrever esses samples foi uma lição de humildade: várias coisas em que eu acreditava havia anos se revelaram erradas quando um programa teve que prová-las (os números de alocação do ValueTask abaixo são o exemplo mais claro). Onde este guia cita um número, ele foi medido na minha máquina com o sample vinculado, para que você possa executá-lo de novo em vez de confiar nele.

⚡ async/await Execution Visualizer

Step 1 — Synchronous entry

The caller invokes the async method. Execution begins synchronously on the caller's thread until the first await is reached.

Main Thread
Running async method
Async Method
Running (synchronous entry)
I/O Task
Not started
// Caller thread
var result = await GetDataAsync();  // ← call starts here

async Task<string> GetDataAsync()
{
    Console.WriteLine("Before await"); // ← runs synchronously
    // ... not yet at an await
}
1 / 4

Por que o Async existe

O problema central: um servidor web lidando com 1.000 requisições concorrentes não pode se dar ao luxo de ter 1.000 threads bloqueadas aguardando respostas do banco de dados. Threads são caras — cada uma consome ~1 MB de espaço de pilha.

A E/S assíncrona resolve isso. Quando seu código faz await em uma chamada ao banco de dados, a thread retorna ao pool de threads para atender outras requisições. Quando o banco de dados responde, uma thread retoma seu método e continua. O resultado: um throughput drasticamente maior com menos threads.

// Síncrono — bloqueia uma thread durante toda a duração da chamada ao BD
public Product GetProduct(int id)
{
    return _db.Products.Find(id)!; // Thread presa aqui aguardando o BD
}
 
// Assíncrono — a thread é liberada enquanto aguarda o BD
public async Task<Product?> GetProductAsync(int id)
{
    return await _db.Products.FindAsync(id); // Thread retorna ao pool aqui
}

Os fundamentos de async/await

Todo método que contenha await deve ser marcado como async. O tipo de retorno muda assim:

Tipo de retorno síncronoTipo de retorno assíncrono
voidasync Task (ou async void somente para event handlers)
Tasync Task<T>
(caminho quente)async ValueTask<T>
// Método assíncrono que retorna um valor
public async Task<string> FetchUserNameAsync(int userId)
{
    var user = await _db.Users.FindAsync(userId);
    return user?.Name ?? "Unknown";
}
 
// Método assíncrono sem valor de retorno
public async Task SendWelcomeEmailAsync(string email)
{
    var message = BuildMessage(email);
    await _emailService.SendAsync(message);
}
 
// Chamando métodos assíncronos
var name = await FetchUserNameAsync(42);
await SendWelcomeEmailAsync("user@example.com");

Como o await funciona de verdade

await é açúcar sintático para uma máquina de estados. O compilador transforma seu método assíncrono em uma classe que implementa IAsyncStateMachine. Quando a operação aguardada é concluída, a máquina de estados retoma a execução no ponto após o await.

O ponto-chave: await suspende o método atual, não a thread atual. A thread fica livre para fazer outro trabalho.

Task vs Task<T>

Task representa uma operação assíncrona sem valor de retorno. Task<T> representa uma que retorna um valor do tipo T.

// Task — sem valor de retorno
public async Task ProcessOrderAsync(int orderId)
{
    var order = await _db.Orders.FindAsync(orderId)
        ?? throw new ArgumentException($"Order {orderId} not found");
 
    order.Status = OrderStatus.Processing;
    await _db.SaveChangesAsync();
    await _notificationService.NotifyAsync(order.UserId, "Order is processing");
}
 
// Task<T> — retorna um valor
public async Task<OrderSummary> GetOrderSummaryAsync(int orderId)
{
    var order = await _db.Orders
        .Include(o => o.Items)
        .FirstOrDefaultAsync(o => o.Id == orderId)
        ?? throw new KeyNotFoundException($"Order {orderId} not found");
 
    return new OrderSummary(order.Id, order.Items.Sum(i => i.Price), order.Status);
}

Executando múltiplas tarefas de forma concorrente

// Sequencial — cada uma aguarda antes que a próxima comece (lento)
var user = await GetUserAsync(userId);         // Aguarda isso...
var orders = await GetOrdersAsync(userId);     // Então aguarda isso
var recommendations = await GetRecsAsync(userId); // Então isso
 
// Concorrente — todas iniciam imediatamente, depois aguarda todas (rápido)
var userTask = GetUserAsync(userId);
var ordersTask = GetOrdersAsync(userId);
var recsTask = GetRecsAsync(userId);
 
await Task.WhenAll(userTask, ordersTask, recsTask);
 
var user = await userTask;
var orders = await ordersTask;
var recommendations = await recsTask;
💡

Use Task.WhenAll quando múltiplas operações assíncronas independentes puderem ser executadas em paralelo. Se três chamadas ao banco de dados levam 50 ms cada, a execução sequencial leva 150 ms; a concorrente leva ~50 ms.

Task.WhenAny

// Concluir quando qualquer tarefa terminar — útil para timeouts
var dataTask = FetchDataAsync();
var timeoutTask = Task.Delay(TimeSpan.FromSeconds(5));
 
var completed = await Task.WhenAny(dataTask, timeoutTask);
 
if (completed == timeoutTask)
    throw new TimeoutException("Data fetch timed out after 5 seconds");
 
var data = await dataTask; // Seguro para aguardar — já concluiu

ValueTask

ValueTask<T> é uma alternativa a Task<T> que economiza alocações para métodos que frequentemente são concluídos de forma síncrona (sem realmente aguardar).

// Usando Task<T> — sempre aloca um Task no heap
public async Task<string?> GetCachedValueAsync(string key)
{
    if (_cache.TryGetValue(key, out var cached))
        return cached;  // O caminho síncrono ainda aloca um Task
 
    var value = await _redis.GetAsync(key);
    _cache.Set(key, value);
    return value;
}
 
// Usando ValueTask<T> — sem alocação no caminho síncrono (cache hit)
public ValueTask<string?> GetCachedValueAsync(string key)
{
    if (_cache.TryGetValue(key, out var cached))
        return ValueTask.FromResult(cached);  // Zero alocações
 
    return FetchAndCacheAsync(key);  // O caminho assíncrono ainda usa Task internamente
}
 
private async ValueTask<string?> FetchAndCacheAsync(string key)
{
    var value = await _redis.GetAsync(key);
    _cache.Set(key, value);
    return value;
}
⚠️

Não use ValueTask em todo lugar. Só é benéfico em caminhos quentes onde o caminho síncrono é o caso comum (ex.: cache hits, operações já concluídas). Para a maior parte do código de aplicação, Task<T> é mais simples e o custo de alocação é insignificante.

Restrições do ValueTask

ValueTask tem restrições importantes em comparação com Task:

// INCORRETO — ValueTask só pode ser aguardado uma vez
var vt = GetCachedValueAsync("key");
var result1 = await vt;  // OK
var result2 = await vt;  // COMPORTAMENTO INDEFINIDO
 
// INCORRETO — Não é possível aguardar o mesmo ValueTask de múltiplos lugares
// INCORRETO — Não é seguro usar .Result em um ValueTask
 
// CORRETO — Aguardar imediatamente, ou converter para Task se precisar reutilizar
var task = GetCachedValueAsync("key").AsTask();
var result1 = await task;
var result2 = await task;  // Seguro com Task

O que o ValueTask realmente economiza — medido

O argumento de venda do ValueTask é "sem alocações", então medi exatamente isso com BenchmarkDotNet no .NET 10 x64, usando o formato de cache acima. O resultado mudou a forma como eu o uso:

CaminhoTask<T> alocadoValueTask<T> alocado
Cache hit (conclui de forma síncrona)72 B0 B
Cache miss (realmente aguarda)195 B229 B

A linha síncrona é a promessa cumprida: Task<T> paga 72 bytes por chamada, ValueTask<T> não paga nada. A linha assíncrona é a parte que ninguém coloca no discurso: quando o método realmente suspende, ValueTask<T> alocou mais que Task<T>, porque seu builder ainda precisa alocar no heap o estado da suspensão. Portanto, ValueTask é uma troca, não um upgrade gratuito — ele ganha exatamente na proporção da frequência com que seu método conclui de forma síncrona, e um método que normalmente suspende fica ligeiramente pior com ele.

Uma nota metodológica que vale a pena roubar: em três execuções na mesma máquina, as colunas de tempo variaram até 4%, enquanto as colunas de alocação voltaram idênticas byte a byte todas as vezes. Quando dois candidatos diferem por nanossegundos, as alocações são o sinal reproduzível. O artigo ValueTask vs Task traz a saída completa do BenchmarkDotNet com o cabeçalho de ambiente e o StdDev, além da mesma comparação executada contra uma consulta real do EF Core.

ConfigureAwait(false)

Por padrão, quando o código retoma após um await, ele tenta retomar no SynchronizationContext original (ex.: a thread de UI no WinForms, ou o contexto de requisição do ASP.NET Classic).

ConfigureAwait(false) diz ao runtime "não capture o contexto — retome em qualquer thread do pool de threads".

// Sem ConfigureAwait — captura e restaura o contexto de sincronização
public async Task<Data> GetDataAsync()
{
    var result = await httpClient.GetFromJsonAsync<Data>("/api/data");
    return result!;
}
 
// Com ConfigureAwait(false) — sem captura de contexto, ligeiramente mais rápido
public async Task<Data> GetDataAsync()
{
    var result = await httpClient.GetFromJsonAsync<Data>("/api/data")
                                 .ConfigureAwait(false);
    return result!;
}

Quando usar ConfigureAwait(false)

Código de biblioteca: Use sempre ConfigureAwait(false). Bibliotecas não possuem o SynchronizationContext e não devem tentar retomar nele. Isso previne deadlocks quando os consumidores da biblioteca usam .Result ou .Wait().

Código de aplicação ASP.NET Core: Geralmente não é necessário. O ASP.NET Core não possui SynchronizationContext, portanto não há nada a capturar. Adicionar ConfigureAwait(false) em todo lugar no código ASP.NET Core é inofensivo, mas cria ruído desnecessário.

Código de aplicação WinForms/WPF: Use com cuidado. Se você precisar atualizar a UI após um await, deve estar na thread de UI, portanto não use ConfigureAwait(false) antes de atualizações de UI.

// Biblioteca NuGet — use sempre ConfigureAwait(false)
public static class MyLibrary
{
    public static async Task<string> FetchAsync(string url)
    {
        using var client = new HttpClient();
        var response = await client.GetAsync(url).ConfigureAwait(false);
        var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
        return content;
    }
}

.NET 8: ConfigureAwaitOptions

O .NET 8 transformou o booleano em um enum de flags — ConfigureAwait(ConfigureAwaitOptions) — e duas das quatro opções fazem coisas que o booleano nunca conseguiu:

// None == ConfigureAwait(false); ContinueOnCapturedContext == ConfigureAwait(true)
await task.ConfigureAwait(ConfigureAwaitOptions.None);
 
// SuppressThrowing — o await conclui sem lançar mesmo se a task falhou
// ou foi cancelada. Útil para "aguardar a limpeza, ignorar seus erros".
await task.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
 
// ForceYielding — nunca continuar de forma síncrona, mesmo se já concluída.
await task.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
 
// Elas se combinam:
await task.ConfigureAwait(
    ConfigureAwaitOptions.SuppressThrowing | ConfigureAwaitOptions.ForceYielding);

SuppressThrowing é a que vale conhecer porque é a única exceção a uma regra na qual, fora isso, você pode confiar: exceções se propagam através do await de forma idêntica tenha você usado ConfigureAwait(false) ou não — o sample de ConfigureAwait verifica isso com uma asserção, capturando uma exceção pós-ConfigureAwait(false) com um try/catch comum no ponto de chamada. Esse sample também mede o que o ConfigureAwait realmente faz, instalando um SynchronizationContext contador e comparando as chamadas a Post com e sem ele — uma demonstração muito mais honesta do que "é ligeiramente mais rápido". O artigo completo percorre as contagens.

Armadilhas comuns

1. async void

// PERIGOSO — exceções não são observadas e derrubam o processo
public async void LoadData()
{
    var data = await FetchAsync(); // Uma exceção aqui mata a aplicação
    Process(data);
}
 
// CORRETO — retornar Task para que os chamadores possam observar as exceções
public async Task LoadDataAsync()
{
    var data = await FetchAsync();
    Process(data);
}
 
// async void é APENAS aceitável para event handlers
button.Click += async (sender, e) =>
{
    await DoWorkAsync(); // Event handlers devem ser void
};

2. Deadlocks com .Result e .Wait()

// DEADLOCK no ASP.NET Classic / WinForms / WPF
public string GetData()
{
    return FetchDataAsync().Result;  // Bloqueia a thread atual
    // FetchDataAsync tenta retomar nesta thread — DEADLOCK
}
 
// CORRETO — seja assíncrono do início ao fim
public async Task<string> GetDataAsync()
{
    return await FetchDataAsync();
}
⚠️

Nunca use .Result, .Wait(), ou .GetAwaiter().GetResult() em uma Task a partir de um contexto que tenha um SynchronizationContext (aplicações de UI, ASP.NET clássico). Isso causa deadlocks. No ASP.NET Core não gera deadlock, mas ainda assim bloqueia uma thread desnecessariamente.

Esse deadlock é frequentemente descrito, mas raramente demonstrado, o que deixa acreditar que ele é teórico. Não é: o sample de deadlock instala um SynchronizationContext single-thread de ~40 linhas — o mesmo formato que WinForms e WPF usam — e trava sob demanda, em toda execução, e depois destrava o código idêntico adicionando ConfigureAwait(false) no await certo. Reproduzi-lo de forma determinística também demonstra por que o ASP.NET Core é imune: sem SynchronizationContext, não há nada pelo que brigar. O artigo sobre deadlocks traça o ciclo passo a passo.

3. Esquecer o await

// BUG — fire-and-forget, exceções são silenciosamente descartadas
public async Task ProcessAsync()
{
    SaveToDatabase();  // Não aguardado! Executa, mas erros são perdidos
    LogActivity();     // Também não aguardado
}
 
// CORRETO
public async Task ProcessAsync()
{
    await SaveToDatabaseAsync();
    await LogActivityAsync();
}

4. Async em construtores

Construtores não podem ser async. Use o padrão de método de fábrica:

// INCORRETO — não é possível usar await no construtor
public class DataService
{
    public DataService()
    {
        _data = await LoadDataAsync(); // Erro de compilação
    }
}
 
// CORRETO — método de fábrica estático
public class DataService
{
    private readonly Data _data;
 
    private DataService(Data data) => _data = data;
 
    public static async Task<DataService> CreateAsync()
    {
        var data = await LoadDataAsync();
        return new DataService(data);
    }
}
 
// Uso
var service = await DataService.CreateAsync();

Tratamento de erros

try/catch básico

Exceções em métodos assíncronos se propagam naturalmente através do await:

public async Task ProcessOrderAsync(int orderId)
{
    try
    {
        var order = await _db.Orders.FindAsync(orderId)
            ?? throw new KeyNotFoundException($"Order {orderId} not found");
 
        await _paymentService.ChargeAsync(order.TotalAmount);
        await _emailService.SendConfirmationAsync(order.CustomerEmail);
    }
    catch (PaymentException ex)
    {
        _logger.LogError(ex, "Payment failed for order {OrderId}", orderId);
        throw; // Re-lançar para que o chamador trate
    }
    catch (Exception ex)
    {
        _logger.LogError(ex, "Unexpected error processing order {OrderId}", orderId);
        throw;
    }
}

Exceções com Task.WhenAll

await Task.WhenAll(...) relança apenas a primeira exceção, mesmo quando várias tarefas falham. A propriedade Exception da task combinada as contém todas como um AggregateException — então, para registrar cada falha, inspecione as tasks em vez de confiar na exceção lançada:

public async Task ProcessMultipleOrdersAsync(int[] orderIds)
{
    // Materialize com ToList() — isso é essencial. Select é preguiçoso, então
    // reenumerar `tasks` no catch chamaria ProcessOrderAsync DE NOVO, iniciando
    // cada pedido uma segunda vez e inspecionando tasks novas que ainda não
    // falharam. Enumere uma única vez, aqui.
    var tasks = orderIds.Select(id => ProcessOrderAsync(id)).ToList();
 
    try
    {
        await Task.WhenAll(tasks);
    }
    catch (Exception)
    {
        // await lançou apenas a primeira exceção. Inspecione a mesma lista
        // materializada de tasks para coletar TODAS.
        var allExceptions = tasks
            .Where(t => t.IsFaulted)
            .SelectMany(t => t.Exception!.InnerExceptions)
            .ToList();
 
        foreach (var ex in allExceptions)
            _logger.LogError(ex, "Order processing failed");
 
        throw;
    }
}
⚠️

O .ToList() não é cosmético. orderIds.Select(id => ProcessOrderAsync(id)) é uma consulta adiada — cada enumeração reinvoca ProcessOrderAsync. Aguardar Task.WhenAll(tasks) a enumera uma vez; reenumerar tasks no catch iniciaria cada operação uma segunda vez e inspecionaria tasks novas que ainda não falharam. Materialize a sequência exatamente uma vez. O artigo sobre tratamento de exceções assíncronas verifica com asserções o comportamento de primeira-exceção vs AggregateException do início ao fim.

Cancelamento

public async Task<List<Product>> GetProductsAsync(CancellationToken cancellationToken = default)
{
    return await _db.Products
        .Where(p => p.IsActive)
        .ToListAsync(cancellationToken);
}
 
// Com timeout
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
try
{
    var products = await GetProductsAsync(cts.Token);
}
catch (OperationCanceledException)
{
    _logger.LogWarning("GetProducts timed out after 10 seconds");
}

Uma distinção que vale memorizar porque muda o que seus blocos catch podem saber: quando o HttpClient.Timeout dispara, você recebe uma TaskCanceledException com uma TimeoutException interna (desde o .NET 5); quando é o seu token que cancela a mesma requisição, não há TimeoutException interna e a exceção carrega o seu token. Essa é a diferença entre "o servidor estava lento" e "desistimos de propósito", e dá para capturá-la:

catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException)
{
    // HttpClient.Timeout disparou — não é o cancelamento do seu chamador
}

O artigo sobre CancellationToken verifica isso com asserções contra um servidor local deliberadamente travado — junto com outros 15 comportamentos de cancelamento, incluindo os que eu entendi errado por anos (fontes de tokens vinculadas, qual token uma OperationCanceledException realmente carrega e a verificação cooperativa em loops apertados).

Canceled vs Faulted — são estados diferentes

Uma OperationCanceledException não deixa uma task em estado faulted — ela a cancela, e a assimetria é mais acentuada do que a maioria espera. O sample de tratamento de exceções (36 asserções) fixa a regra exata: um método assíncrono que lança OperationCanceledException termina Canceled mesmo que o token que ela carrega nunca tenha sido cancelado — mas um delegate síncrono passado ao Task.Run que lança a mesma exceção termina Faulted, a menos que o token corresponda ao que foi dado ao Task.Run. Código de monitoramento que só observa IsFaulted ignora silenciosamente tasks canceladas, e código que trata toda OperationCanceledException como "o usuário apertou cancelar" atribui erroneamente timeouts genuínos. O artigo sobre tratamento de exceções assíncronas cobre isso, além da regra de primeira-exceção do Task.WhenAll demonstrada acima.

Streams assíncronos (IAsyncEnumerable)

Para transmitir grandes conjuntos de resultados sem armazenar tudo em memória:

// Produzindo um stream assíncrono
public async IAsyncEnumerable<Order> GetOrdersStreamAsync(
    [EnumeratorCancellation] CancellationToken cancellationToken = default)
{
    await foreach (var order in _db.Orders.AsAsyncEnumerable().WithCancellation(cancellationToken))
    {
        yield return order;
    }
}
 
// Consumindo um stream assíncrono
await foreach (var order in GetOrdersStreamAsync(cancellationToken))
{
    await ProcessOrderAsync(order);
}

A diferença de memória não é sutil. Medi os dois formatos com 100.000 linhas e um produtor em memória para isolar o efeito do buffering: a List<T> bufferizada mantém 18,5 MB vivos — o grafo de objetos inteiro de uma vez — enquanto o pico da versão em streaming arredonda para 0 MB, porque cada linha vira lixo antes que a próxima exista. Linhas mais pesadas escalam o número bufferizado linearmente; o pico do streaming permanece estável. Mais dois fatos do sample de IAsyncEnumerable que contradizem conselhos comuns: actions de controller do ASP.NET Core podem retornar iteradores assíncronos (verificado com asserções contra um servidor in-process ao vivo), e a partir do .NET 10 os operadores LINQ para IAsyncEnumerable vêm na BCL — você não precisa mais do pacote System.Linq.Async. O artigo completo cobre transferência em chunks, backpressure com Channel e quais operadores bufferizam silenciosamente de qualquer forma (OrderBy não tem escolha).

Padrões de desempenho

Paralelismo com grau de paralelismo controlado

// Processar até 10 itens por vez
var semaphore = new SemaphoreSlim(10);
 
var tasks = orderIds.Select(async id =>
{
    await semaphore.WaitAsync();
    try
    {
        return await ProcessOrderAsync(id);
    }
    finally
    {
        semaphore.Release();
    }
});
 
await Task.WhenAll(tasks);

Cache com Lazy<Task>

// Inicializar uma vez, compartilhar entre requisições
public class ConfigService
{
    private readonly Lazy<Task<AppConfig>> _config;
 
    public ConfigService(IConfigLoader loader)
    {
        _config = new Lazy<Task<AppConfig>>(loader.LoadAsync);
    }
 
    public Task<AppConfig> GetConfigAsync() => _config.Value;
}

Samples executáveis deste guia

Cada seção acima é ampliada em um artigo focado com um projeto de console que você pode clonar e executar — cada um transforma suas afirmações em asserções que falham ruidosamente se o comportamento mudar. Todos estão em jorgenhoc-org/dotnet-samples.

Seção deste guiaArtigo dedicadoSample
ValueTask e sua regra de um único awaitValueTask vs Taskvaluetask-vs-task-csharp
ConfigureAwait(false)ConfigureAwait(false) explicadoconfigureawait-false-csharp
Deadlocks com .Result / .Wait()Como evitar deadlocks assíncronosasync-deadlocks-csharp
Tratamento de erros, exceções de Task.WhenAllTratamento de exceções assíncronasasync-exception-handling-csharp
CancelamentoPadrões de CancellationTokencancellationtoken-csharp
Streams assíncronos (IAsyncEnumerable)IAsyncEnumerable — streaming de dadosiasyncenumerable-csharp

Resumo

O modelo mental para async/await em C#:

  1. async marca um método como uma máquina de estados; await o suspende até que a operação aguardada seja concluída
  2. Use Task para fire-and-forget, Task<T> para resultados, ValueTask<T> somente em caminhos quentes comprovados
  3. Seja assíncrono do início ao fim — não bloqueie código assíncrono com .Result ou .Wait()
  4. Use ConfigureAwait(false) em código de biblioteca; aplicações ASP.NET Core geralmente não precisam
  5. Sempre passe e verifique CancellationToken para operações de longa duração
  6. Trate AggregateException de Task.WhenAll se precisar de todos os detalhes de falhas

Leituras adicionais

Sobre o autor

Jorge Calderón

Engenheiro de software com mais de uma década construindo e operando aplicações .NET em produção — camadas de dados com EF Core, serviços intensivos em async e implantações em Azure e contêineres. Todos os benchmarks e projetos de exemplo destes guias estão publicados em um repositório público no GitHub para que você possa reproduzi-los.

Perfil no GitHubLinkedIn ↗Benchmarks e código de exemplo

Artigos relacionados