async/await e Task.Run envolvem tarefas, mas resolvem problemas completamente diferentes. Confundi-los leva a código que esgota o pool de threads ou bloqueia threads desnecessariamente. A distinção se resume a uma única pergunta: seu trabalho é limitado por I/O ou por CPU?
Cada afirmação deste artigo é verificada por
samples/async-await-vs-task-run
— 13 verificações que tornam a diferença observável através de ids de thread e estado das tasks, não de prosa (veja Verifique você mesmo).
A Diferença Fundamental
Trabalho limitado por I/O aguarda algo externo: uma resposta do banco de dados, uma chamada HTTP, a leitura de um arquivo. A CPU fica ociosa durante essa espera. async/await lida com isso suspendendo o método e liberando a thread.
Trabalho limitado por CPU realiza computação de fato: processamento de imagens, criptografia, cálculos complexos. A CPU fica ocupada o tempo todo. Task.Run lida com isso descarregando a execução para uma thread do pool, mantendo a thread chamadora livre.
// Limitado por I/O — use async/await diretamente (sem necessidade de Task.Run)
public async Task<User> GetUserAsync(int id)
{
return await _db.Users.FindAsync(id); // Aguardando o BD, sem uso de CPU
}
// Limitado por CPU — use Task.Run para descarregar no pool de threads
public async Task<byte[]> ResizeImageAsync(byte[] imageData, int width, int height)
{
return await Task.Run(() => DoResizeWork(imageData, width, height));
}
private byte[] DoResizeWork(byte[] imageData, int width, int height)
{
// O trabalho real de CPU acontece aqui — computação pesada
using var image = Image.Load(imageData);
image.Mutate(x => x.Resize(width, height));
using var ms = new MemoryStream();
image.Save(ms, JpegFormat.Instance);
return ms.ToArray();
}Por que você não precisa de Task.Run para I/O
Um erro comum é envolver chamadas assíncronas de I/O em Task.Run:
// INCORRETO — duplo empacotamento, desperdiça uma thread do pool sem nenhum benefício
public async Task<List<Order>> GetOrdersAsync(int userId)
{
return await Task.Run(async () =>
{
return await _db.Orders
.Where(o => o.UserId == userId)
.ToListAsync();
});
}
// CORRETO — async/await lida com I/O nativamente sem bloquear threads
public async Task<List<Order>> GetOrdersAsync(int userId)
{
return await _db.Orders
.Where(o => o.UserId == userId)
.ToListAsync();
}A versão com Task.Run consome uma thread do pool, que em seguida fica imediatamente parada aguardando o banco de dados. Você consumiu uma thread apenas para... esperar. Isso vai contra todo o propósito do I/O assíncrono.
Quando Task.Run é a ferramenta certa
Trabalho intenso de CPU no ASP.NET Core
Em uma aplicação web, chamar código pesado de CPU diretamente na thread da requisição impede que essa thread atenda outras requisições:
// RUIM — bloqueia a thread da requisição por segundos
[HttpPost("process")]
public async Task<IActionResult> ProcessData([FromBody] DataRequest request)
{
var result = PerformHeavyCalculation(request.Data); // Bloqueia por 2+ segundos
return Ok(result);
}
// BOM — descarrega o trabalho de CPU, liberando a thread da requisição
[HttpPost("process")]
public async Task<IActionResult> ProcessData([FromBody] DataRequest request)
{
var result = await Task.Run(() => PerformHeavyCalculation(request.Data));
return Ok(result);
}Integrando código síncrono legado
Quando você precisa chamar código síncrono bloqueante a partir de um contexto assíncrono:
// Biblioteca síncrona legada que você não pode modificar
public string LegacyBlockingOperation(string input)
{
Thread.Sleep(2000); // Simula trabalho bloqueante
return $"processed: {input}";
}
// Integre-a sem bloquear a thread chamadora
public async Task<string> ProcessAsync(string input)
{
return await Task.Run(() => LegacyBlockingOperation(input));
}Usar Task.Run para envolver código síncrono é aceitável como solução temporária, mas tem um custo — cada Task.Run consome uma thread do pool. Em cenários de alta vazão, reescreva o código subjacente para ser verdadeiramente assíncrono.
Processamento paralelo de CPU
public async Task<IReadOnlyList<ReportResult>> GenerateReportsAsync(
IEnumerable<ReportRequest> requests)
{
var tasks = requests.Select(req =>
Task.Run(() => GenerateReport(req))); // Cada um em uma thread separada
return await Task.WhenAll(tasks);
}Assíncrono do início ao fim
O princípio de "assíncrono do início ao fim" significa que, uma vez que você adota o async, todos os chamadores na cadeia também devem ser async. Misturar síncrono e assíncrono causa problemas.
// Camada de serviço — assíncrona
public class OrderService
{
private readonly AppDbContext _db;
public async Task<Order> CreateOrderAsync(CreateOrderRequest request)
{
var order = new Order { /* ... */ };
_db.Orders.Add(order);
await _db.SaveChangesAsync();
return order;
}
}
// Controller — assíncrono (chama o serviço assíncrono)
[HttpPost]
public async Task<IActionResult> CreateOrder([FromBody] CreateOrderRequest request)
{
var order = await _orderService.CreateOrderAsync(request);
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}Se você interromper a cadeia no meio e usar .Result:
// RUIM — quebra a cadeia assíncrona
[HttpPost]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
var order = _orderService.CreateOrderAsync(request).Result; // BLOQUEIA + possível deadlock
return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}Perigos do "disparar e esquecer"
Às vezes você genuinamente quer iniciar uma operação assíncrona sem aguardá-la. Isso é o "fire-and-forget" e apresenta riscos sérios:
// "Disparar e esquecer" de forma PERIGOSA
public IActionResult SubmitOrder([FromBody] OrderRequest request)
{
_ = SendConfirmationEmailAsync(request.Email); // SEM await
return Ok("Order received");
}Problemas com esse padrão:
- Exceções são silenciosamente descartadas — se
SendConfirmationEmailAsynclançar uma exceção, você nunca saberá - A operação pode ser cancelada — no ASP.NET Core, se a requisição terminar, operações em segundo plano podem ser interrompidas
- Sem visibilidade — você não consegue rastrear se a operação foi concluída com sucesso
Alternativas mais seguras ao "disparar e esquecer"
// Opção 1: registrar exceções explicitamente
public IActionResult SubmitOrder([FromBody] OrderRequest request)
{
_ = SendConfirmationEmailAsync(request.Email)
.ContinueWith(t =>
{
if (t.IsFaulted)
_logger.LogError(t.Exception, "Failed to send confirmation email");
}, TaskScheduler.Default);
return Ok("Order received");
}
// Opção 2: usar IHostedService / BackgroundService para trabalho em segundo plano confiável
public class EmailBackgroundService : BackgroundService
{
private readonly Channel<EmailMessage> _queue;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await foreach (var message in _queue.Reader.ReadAllAsync(stoppingToken))
{
try
{
await SendEmailAsync(message);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to send email to {Email}", message.To);
}
}
}
}Para processamento em segundo plano confiável, use System.Threading.Channels para enfileirar o trabalho e um BackgroundService para processá-lo. Essa abordagem sobrevive a exceções, executa de forma confiável e é observável.
Erros comuns ao misturar async e sync
Erro 1: Async sobre sync (o falso async)
// ENGANOSO — parece assíncrono, mas é na verdade síncrono
public Task<int> CountProductsAsync()
{
var count = _db.Products.Count(); // Síncrono — bloqueia a thread
return Task.FromResult(count);
}
// CORRETO
public async Task<int> CountProductsAsync()
{
return await _db.Products.CountAsync();
}Erro 2: Task.Run em código de biblioteca
// RUIM em código de biblioteca — rouba threads do pool do consumidor
public static class ProductCalculator
{
public static Task<decimal> CalculateTotalAsync(List<Product> products)
{
return Task.Run(() => products.Sum(p => p.Price)); // Impõe decisões de threading aos consumidores
}
}
// MELHOR — deixe os chamadores decidirem sobre threading
public static class ProductCalculator
{
// Síncrono — simples, sem decisões de threading
public static decimal CalculateTotal(List<Product> products)
=> products.Sum(p => p.Price);
}
// O chamador usa Task.Run se precisar
var total = await Task.Run(() => ProductCalculator.CalculateTotal(products));Erro 3: ConfigureAwait em Task.Run
// Task.Run não tem SynchronizationContext, então ConfigureAwait(false) é redundante aqui
var result = await Task.Run(() => HeavyWork()).ConfigureAwait(false); // Inofensivo mas desnecessário
// ConfigureAwait(false) é relevante em awaits de I/O em código de biblioteca
var data = await httpClient.GetStringAsync(url).ConfigureAwait(false); // Faz sentido aquiGuia rápido de decisão
| Cenário | Use |
|---|---|
| Consulta ao banco de dados | await dbContext.SomeAsync() |
| Chamada HTTP | await httpClient.GetAsync(url) |
| Leitura de arquivo | await File.ReadAllTextAsync(path) |
| Processamento de imagens | await Task.Run(() => ProcessImage(...)) |
| Cálculos pesados | await Task.Run(() => Calculate(...)) |
| Biblioteca síncrona legada | await Task.Run(() => legacyLib.Do(...)) |
| I/O em paralelo | await Task.WhenAll(tasks) |
| CPU em paralelo | await Task.WhenAll(tasks.Select(t => Task.Run(() => ...))) |
A regra é simples: se você está aguardando I/O, use async/await sem Task.Run. Se está realizando trabalho de CPU que bloquearia uma thread, use Task.Run.
Verifique você mesmo
samples/async-await-vs-task-run
transforma cada afirmação acima em uma asserção que lança exceção se alguma vez for falsa.
Ao executá-lo, imprime 13 verificações que passam:
- Uma chamada síncrona direta executa o trabalho de CPU na própria thread do chamador;
Task.Runmove o trabalho idêntico para uma thread diferente do pool — exatamente sua razão de existir. - O anti-padrão de "async falso" (
Task.FromResultapós trabalho síncrono) retorna uma task já concluída cujo corpo rodou de forma síncrona na thread chamadora — não liberou nada. Um método que aguardaTask.Yield()retorna antes de concluir. - Uma task fire-and-forget pode falhar enquanto o chamador segue sem saber; a exceção só aflora quando algo finalmente a aguarda.
ContinueWith(TaskScheduler.Default)é o ponto de observação. - O loop com Channel no estilo
BackgroundServicecumpre a promessa: uma mensagem lança exceção no meio do caminho, e as outras duas são processadas mesmo assim. Task.WhenAll(Task.Run(...))realmente distribui oito tarefas de CPU em oito threads distintas do pool, nenhuma delas a do chamador.

dotnet run — sem banco de dados, sem configuração, totalmente determinista. A verificação
de paralelismo eleva o mínimo de threads do pool e usa um Barrier para forçar as oito
tarefas a rodarem ao mesmo tempo; sem isso, o pool poderia serializá-las silenciosamente em
uma thread reutilizada e a contagem de threads distintas seria uma mentira em vez de uma
demonstração.
Dois temas relacionados importam depois que a distinção entre I/O e CPU está clara. O
primeiro é o que acontece quando alguém bloqueia em uma chamada async mesmo assim — veja
deadlocks em async C# para entender por que .Result e
.Wait() transformam um método que funciona em um método travado. O segundo é
ConfigureAwait(false), que controla onde a sua
continuação retoma e costuma ser a correção depois que o deadlock é diagnosticado.