//JorgenHoc
← Todos os artigos
Async C#Por Jorge CalderónAtualizado 9 min read

async/await vs Task.Run em C# — Quando usar cada um

Entenda a diferença fundamental entre async/await para operações limitadas por I/O e Task.Run para operações limitadas por CPU em C#, com exemplos práticos e erros comuns a evitar.

#csharp#async#dotnet

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:

  1. Exceções são silenciosamente descartadas — se SendConfirmationEmailAsync lançar uma exceção, você nunca saberá
  2. A operação pode ser cancelada — no ASP.NET Core, se a requisição terminar, operações em segundo plano podem ser interrompidas
  3. 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 aqui

Guia rápido de decisão

CenárioUse
Consulta ao banco de dadosawait dbContext.SomeAsync()
Chamada HTTPawait httpClient.GetAsync(url)
Leitura de arquivoawait File.ReadAllTextAsync(path)
Processamento de imagensawait Task.Run(() => ProcessImage(...))
Cálculos pesadosawait Task.Run(() => Calculate(...))
Biblioteca síncrona legadaawait Task.Run(() => legacyLib.Do(...))
I/O em paraleloawait Task.WhenAll(tasks)
CPU em paraleloawait 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.Run move o trabalho idêntico para uma thread diferente do pool — exatamente sua razão de existir.
  • O anti-padrão de "async falso" (Task.FromResult apó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 aguarda Task.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 BackgroundService cumpre 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.
Saída de console do sample async vs Task.Run: 13 verificações que passam. O trabalho de CPU roda na própria thread do chamador quando chamado diretamente, e em uma thread diferente do pool via Task.Run. Um método async falso retorna uma task já concluída cujo corpo rodou de forma síncrona na thread chamadora, enquanto um método async real que aguarda Task.Yield retorna antes de concluir. Uma task fire-and-forget falha sem o chamador perceber até ser aguardada, e ContinueWith no escalonador padrão observa a falha. Um loop com Channel continua processando após uma exceção. Task.WhenAll de oito Task.Run distribui o trabalho em oito threads distintas do pool, nenhuma delas a do chamador.
As 13 verificações, direto do console: o mesmo trabalho de CPU na thread do chamador versus uma do pool, async falso vs real, falhas fire-and-forget, e oito tarefas paralelas em oito threads distintas.
💡

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.

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