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:
- Las excepciones se pierden silenciosamente — si
SendConfirmationEmailAsynclanza una excepción, nunca lo sabrás - La operación puede cancelarse — en ASP.NET Core, si la solicitud finaliza, las operaciones en segundo plano pueden ser interrumpidas
- 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 sentidoGuía rápida de decisión
| Escenario | Usar |
|---|---|
| Consulta a base de datos | await dbContext.SomeAsync() |
| Llamada HTTP | await httpClient.GetAsync(url) |
| Lectura de archivo | await File.ReadAllTextAsync(path) |
| Procesamiento de imágenes | await Task.Run(() => ProcessImage(...)) |
| Cálculos intensivos | await Task.Run(() => Calculate(...)) |
| Biblioteca síncrona heredada | await Task.Run(() => legacyLib.Do(...)) |
| I/O en paralelo | await Task.WhenAll(tasks) |
| CPU en paralelo | await 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.Runmueve el trabajo idéntico a un subproceso distinto del grupo — justamente su razón de ser. - El anti-patrón de "async falso" (
Task.FromResulttras 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 esperaTask.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
BackgroundServicecumple 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.

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.