//JorgenHoc
← Todos los artículos
Async C#Por Jorge CalderónActualizado 10 min read

async/await vs Task.Run en C# — Cuando usar cada uno

Entender las diferencias clave entre async/await para procesos dependientes de I/O y Task.Run para procesos dependientes de CPU en C#, con ejemplos practicos y errores comunes a evitar.

#csharp#async#dotnet

async/await y Task.Run involucran tareas (tasks), pero resuelven problemas completamente distintos. Confundirlos suele producir código que agota innecesariamente el grupo de subprocesos o bloquea subprocesos sin necesidad. La diferencia se reduce a una sola pregunta: ¿tu trabajo es dependiente de I/O o dependiente de CPU?

Cada afirmación de este artículo está verificada por samples/async-await-vs-task-run — 13 comprobaciones que hacen la diferencia observable a través de ids de subproceso y estado de las tasks, no de prosa (ver Verifícalo tú mismo).

La Diferencia Fundamental

I/O-bound espera algo externo: una respuesta de la base de datos, una llamada HTTP, la lectura de un archivo. La CPU permanece inactiva durante esta espera. async/await gestiona esto suspendiendo el método y liberando el subproceso.

CPU-bound realiza los cálculos propiamente dichos: procesamiento de imágenes, criptografía, cálculos complejos. La CPU está ocupada todo el tiempo. Task.Run gestiona esto delegando la tarea a un subproceso del grupo de subprocesos, lo que mantiene libre al subproceso que realiza la llamada.

// I/O-bound — usamos directamente async/await (sin necesidad de Task.Run)
public async Task<User> GetUserAsync(int id)
{
    return await _db.Users.FindAsync(id); // Esperamos la BD, no usamos CPU
}
 
// CPU-bound — usamos Task.Run para delegar la tarea al grupo de subprocesos
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)
{
    // Aquí es donde se lleva a cabo el trabajo real de la CPU: cálculos intensivos
    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 qué no necesitas Task.Run para operaciones I/O (Entrada/Salida)

Un error habitual es envolver las llamadas asíncronas de I/O en Task.Run:

// INCORRECTO: el doble empaquetado desperdicia un subproceso (hilo) del grupo de subprocesos sin aportar ningún beneficio
public async Task<List<Order>> GetOrdersAsync(int userId)
{
    return await Task.Run(async () =>
    {
        return await _db.Orders
            .Where(o => o.UserId == userId)
            .ToListAsync();
    });
}
 
// CORRECTO: async/await gestiona las operaciones de I/O de forma nativa sin bloquear los subprocesos
public async Task<List<Order>> GetOrdersAsync(int userId)
{
    return await _db.Orders
        .Where(o => o.UserId == userId)
        .ToListAsync();
}

Task.Run toma un subproceso del grupo de subprocesos, que a continuación se queda inmediatamente a la espera de la base de datos. Has consumido un hilo solo para... esperar. Eso va en contra del propósito mismo de I/O asíncrona.

Cuándo Task.Run es la herramienta adecuada

Tareas que consumen muchos recursos de CPU en ASP.NET Core

En una aplicación web, llamar a código que consume muchos recursos de CPU directamente en el subproceso de solicitud impide que ese subproceso gestione otras solicitudes:

// MAL — bloquea el subproceso de la solicitud durante unos segundos
[HttpPost("process")]
public async Task<IActionResult> ProcessData([FromBody] DataRequest request)
{
    var result = PerformHeavyCalculation(request.Data); // Bloquea 2+ segundos
    return Ok(result);
}
 
// BIEN: descarga la carga de la CPU y libera el subproceso de la solicitud
[HttpPost("process")]
public async Task<IActionResult> ProcessData([FromBody] DataRequest request)
{
    var result = await Task.Run(() => PerformHeavyCalculation(request.Data));
    return Ok(result);
}

Integración de código síncrono heredado

Cuando sea necesario llamar a código síncrono bloqueante desde un contexto asíncrono:

// Biblioteca síncrona heredada que no se puede modificar
public string LegacyBlockingOperation(string input)
{
    Thread.Sleep(2000); // Simula una operación bloqueante
    return $"processed: {input}";
}
 
// Integrarla sin bloquear el subproceso de llamada
public async Task<string> ProcessAsync(string input)
{
    return await Task.Run(() => LegacyBlockingOperation(input));
}
⚠️

El uso de Task.Run para envolver código síncrono es una solución aceptable, pero tiene un costo: cada llamada a Task.Run consume un subproceso del grupo de subprocesos. En escenarios de alto rendimiento, reescribe el código subyacente para que sea verdaderamente asíncrono.

Procesamiento paralelo de la CPU

public async Task<IReadOnlyList<ReportResult>> GenerateReportsAsync(
    IEnumerable<ReportRequest> requests)
{
    var tasks = requests.Select(req =>
        Task.Run(() => GenerateReport(req)));  // Cada uno en un subproceso independiente
 
    return await Task.WhenAll(tasks);
}

Asíncrono de principio a fin

El principio de "asíncrono de principio a fin" significa que, una vez que se opta por el modo asíncrono, todas las llamadas de la cadena también deben ser asíncronas. Mezclar sincrónico y asíncrono provoca problemas.

// Capa de servicio — asíncrono
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;
    }
}
 
// Controlador — asíncrono (llama al servicio así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);
}

Si interrumpes la cadena a mitad y usas .Result:

// MAL — rompe la cadena asíncrona
[HttpPost]
public IActionResult CreateOrder([FromBody] CreateOrderRequest request)
{
    var order = _orderService.CreateOrderAsync(request).Result; // BLOQUEA + posible deadlock
    return CreatedAtAction(nameof(GetOrder), new { id = order.Id }, order);
}

Peligros de "disparar y olvidar"

A veces genuinamente quieres iniciar una operación asíncrona sin esperar su resultado. Esto se conoce como "fire-and-forget" y conlleva riesgos graves:

// "Disparar y olvidar" de forma PELIGROSA
public IActionResult SubmitOrder([FromBody] OrderRequest request)
{
    _ = SendConfirmationEmailAsync(request.Email); // SIN await
    return Ok("Order received");
}

Problemas con este patrón:

  1. Las excepciones se pierden silenciosamente — si SendConfirmationEmailAsync lanza una excepción, nunca lo sabrás
  2. La operación puede cancelarse — en ASP.NET Core, si la solicitud finaliza, las operaciones en segundo plano pueden ser interrumpidas
  3. Sin visibilidad — no puedes rastrear si la operación se completó con éxito

Alternativas más seguras a "disparar y olvidar"

// Opción 1: registrar las excepciones de forma explícita
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");
}
 
// Opción 2: usar IHostedService / BackgroundService para trabajos en segundo plano confiables
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 el procesamiento en segundo plano confiable, usa System.Threading.Channels para poner en cola el trabajo y un BackgroundService para procesarlo. Este enfoque sobrevive a las excepciones, se ejecuta de forma fiable y es observable.

Errores comunes al mezclar async y sync

Error 1: asíncrono sobre sincrónico (el falso async)

// ENGAÑOSO — parece asíncrono, pero en realidad es sincrónico
public Task<int> CountProductsAsync()
{
    var count = _db.Products.Count(); // Sincrónico — bloquea el subproceso
    return Task.FromResult(count);
}
 
// CORRECTO
public async Task<int> CountProductsAsync()
{
    return await _db.Products.CountAsync();
}

Error 2: Task.Run en código de biblioteca

// INCORRECTO en código de biblioteca — roba subprocesos del grupo al consumidor
public static class ProductCalculator
{
    public static Task<decimal> CalculateTotalAsync(List<Product> products)
    {
        return Task.Run(() => products.Sum(p => p.Price)); // Impone decisiones de threading a los consumidores
    }
}
 
// MEJOR — deja que los llamadores decidan sobre el threading
public static class ProductCalculator
{
    // Sincrónico — simple, sin decisiones de threading
    public static decimal CalculateTotal(List<Product> products)
        => products.Sum(p => p.Price);
}
 
// El llamador usa Task.Run si lo necesita
var total = await Task.Run(() => ProductCalculator.CalculateTotal(products));

Error 3: ConfigureAwait en Task.Run

// Task.Run no tiene SynchronizationContext, por lo que ConfigureAwait(false) es redundante aquí
var result = await Task.Run(() => HeavyWork()).ConfigureAwait(false); // Inofensivo pero innecesario
 
// ConfigureAwait(false) es relevante en awaits de I/O dentro del código de biblioteca
var data = await httpClient.GetStringAsync(url).ConfigureAwait(false); // Esto sí tiene sentido

Guía rápida de decisión

EscenarioUsar
Consulta a base de datosawait dbContext.SomeAsync()
Llamada HTTPawait httpClient.GetAsync(url)
Lectura de archivoawait File.ReadAllTextAsync(path)
Procesamiento de imágenesawait Task.Run(() => ProcessImage(...))
Cálculos intensivosawait Task.Run(() => Calculate(...))
Biblioteca síncrona heredadaawait Task.Run(() => legacyLib.Do(...))
I/O en paraleloawait Task.WhenAll(tasks)
CPU en paraleloawait Task.WhenAll(tasks.Select(t => Task.Run(() => ...)))

La regla es simple: si estás esperando I/O, usa async/await sin Task.Run. Si estás realizando trabajo de CPU que bloquearía un subproceso, usa Task.Run.

Verifícalo tú mismo

samples/async-await-vs-task-run convierte cada afirmación de arriba en una aserción que lanza excepción si alguna vez es falsa. Al ejecutarlo imprime 13 comprobaciones que pasan:

  • Una llamada síncrona directa ejecuta el trabajo de CPU en el propio subproceso del llamador; Task.Run mueve el trabajo idéntico a un subproceso distinto del grupo — justamente su razón de ser.
  • El anti-patrón de "async falso" (Task.FromResult tras trabajo síncrono) devuelve una task ya completada cuyo cuerpo corrió de forma síncrona en el subproceso llamador — no liberó nada. Un método que espera Task.Yield() retorna antes de completarse.
  • Una task fire-and-forget puede fallar mientras el llamador sigue sin enterarse; la excepción solo aflora cuando algo finalmente la espera. ContinueWith(TaskScheduler.Default) es el punto de observación.
  • El bucle con Channel al estilo BackgroundService cumple su promesa: un mensaje lanza excepción a mitad de camino, y los otros dos se procesan igual.
  • Task.WhenAll(Task.Run(...)) reparte de verdad ocho tareas de CPU en ocho subprocesos distintos del grupo, ninguno el del llamador.
Salida de consola del sample async vs Task.Run: 13 comprobaciones que pasan. El trabajo de CPU corre en el propio subproceso del llamador cuando se llama directamente, y en un subproceso distinto del grupo mediante Task.Run. Un método async falso devuelve una task ya completada cuyo cuerpo corrió de forma síncrona en el subproceso llamador, mientras que un método async real que espera Task.Yield retorna antes de completarse. Una task fire-and-forget falla sin que el llamador se entere hasta que se espera, y ContinueWith en el planificador por defecto observa el fallo. Un bucle con Channel sigue procesando tras una excepción. Task.WhenAll de ocho Task.Run reparte el trabajo en ocho subprocesos distintos del grupo, ninguno el del llamador.
Las 13 comprobaciones, directo de la consola: el mismo trabajo de CPU en el subproceso del llamador frente a uno del grupo, async falso vs real, fallos fire-and-forget, y ocho tareas paralelas en ocho subprocesos distintos.
💡

dotnet run — sin base de datos, sin configuración, totalmente determinista. La comprobación de paralelismo eleva el mínimo de subprocesos del grupo y usa un Barrier para forzar a las ocho tareas a correr a la vez; sin eso, el grupo podría serializarlas en silencio sobre un subproceso reutilizado y el conteo de subprocesos distintos sería una mentira en vez de una demostración.

Dos temas relacionados importan una vez que tienes clara la distinción entre I/O y CPU. El primero es qué ocurre cuando alguien bloquea sobre una llamada async de todos modos — consulta deadlocks en async C# para ver por qué .Result y .Wait() convierten un método que funciona en uno colgado. El segundo es ConfigureAwait(false), que controla dónde se reanuda tu continuación y suele ser la solución una vez diagnosticado el deadlock.

Lecturas adicionales

Sobre el autor

Jorge Calderón

Ingeniero de software con más de una década construyendo y operando aplicaciones .NET en producción — capas de datos con EF Core, servicios intensivos en async y despliegues en Azure y contenedores. Cada benchmark y proyecto de ejemplo de estas guías está publicado en un repositorio público de GitHub para que puedas reproducirlo.

Perfil de GitHubLinkedIn ↗Benchmarks y código de ejemplo

Artículos relacionados