AL.
🇺🇸 EN
Volver al blog
.NET y C# · 7 min de lectura

Async vs Parallel en .NET: Deja de Confundirlos

Aprende la diferencia entre I/O asincrono y ejecucion paralela real en C#, y cuando usar cada uno para apps escalables.


Uno de los malentendidos más comunes en .NET es confundir asynchronous (async/await) con parallelism (ejecución paralela). Los desarrolladores frecuentemente usan async esperando que acelere operaciones ligadas a CPU, o evitan paralelismo pensando que async es suficiente. Ambos están mal.

En este artículo, aclararemos la diferencia entre async y parallel en .NET, cuándo usar cada uno, y cómo combinarlos apropiadamente.

La Diferencia Central

  • Async (async/await): Permite que tu código espere operaciones I/O (network, disco, database) sin bloquear threads. No hace el trabajo más rápido, solo libera el thread para hacer otro trabajo mientras espera.

  • Parallel (Parallel, PLINQ, Task.Run): Distribuye trabajo ligado a CPU a través de múltiples threads para completar más rápido usando múltiples cores de CPU.

Analogía

  • Async es como un chef que prepara múltiples platos: mientras uno se cocina, trabaja en otro. Solo hay un chef, pero está ocupado todo el tiempo.

  • Parallel es como contratar múltiples chefs que cocinan diferentes platos al mismo tiempo. Más cocineros = más platos terminados en menos tiempo.

Async: Para Operaciones I/O-Bound

async/await es para operaciones que pasan la mayor parte del tiempo esperando algo externo: una respuesta de base de datos, una llamada HTTP, lectura de archivo, etc.

Ejemplo: Llamada HTTP Async

public async Task<string> FetchDataAsync()
{
    using var client = new HttpClient();
    var response = await client.GetStringAsync("https://api.example.com/data");
    return response;
}

Aquí, await libera el thread mientras espera la respuesta HTTP. El thread puede manejar otros requests mientras tanto. Cuando la respuesta llega, el código continúa.

Sin Async (Bloqueante)

public string FetchDataBlocking()
{
    using var client = new HttpClient();
    var response = client.GetStringAsync("https://api.example.com/data").Result; // ¡Bloquea!
    return response;
}

Esto bloquea el thread hasta que llega la respuesta. En una aplicación web, esto desperdicia threads del thread pool, reduciendo escalabilidad.

Por Qué Async NO Hace el Trabajo Más Rápido

Async no reduce el tiempo para recuperar los datos. Si la llamada HTTP toma 100ms, toma 100ms ya sea async o bloqueante. La diferencia es:

  • Bloqueante: Thread ocioso por 100ms (desperdiciado)
  • Async: Thread libre para manejar otros requests por 100ms (eficiente)

Parallel: Para Operaciones CPU-Bound

Paralelismo es para trabajo ligado a CPU que puede dividirse en chunks independientes y ejecutarse simultáneamente en múltiples cores.

Ejemplo: Procesamiento de Imágenes

public void ProcessImages(List<string> imagePaths)
{
    Parallel.ForEach(imagePaths, imagePath =>
    {
        var image = Image.Load(imagePath);
        image.Resize(800, 600);
        image.Save(imagePath.Replace(".jpg", "_resized.jpg"));
    });
}

Esto distribuye el procesamiento de imágenes a través de múltiples threads, usando todos los cores de CPU disponibles. Si tienes 8 cores, potencialmente procesas 8 imágenes simultáneamente.

Con PLINQ

var processedImages = imagePaths
    .AsParallel()
    .Select(imagePath =>
    {
        var image = Image.Load(imagePath);
        image.Resize(800, 600);
        return image;
    })
    .ToList();

PLINQ (Parallel LINQ) distribuye operaciones LINQ a través de múltiples threads.

Por Qué Parallelism Hace el Trabajo Más Rápido

Paralelismo reduce el tiempo de reloj de pared usando múltiples cores. Si procesar 100 imágenes toma 100 segundos en un solo thread, podría tomar ~12.5 segundos en 8 threads (ignorando sobrecarga).

Errores Comunes

Error 1: Usar Async para Trabajo CPU-Bound

// ¡MAL! Async no ayuda con trabajo CPU-bound
public async Task<int> CalculatePrimes(int max)
{
    await Task.Delay(0); // Esto no hace nada útil
    return CountPrimes(max); // Trabajo CPU-bound síncrono
}

Esto no acelera nada. await Task.Delay(0) es inútil. Si quieres paralelizar trabajo CPU-bound, usa Task.Run o Parallel.

Error 2: Usar Parallel para Trabajo I/O-Bound

// ¡MAL! Parallel desperdicia threads en operaciones I/O
Parallel.ForEach(urls, url =>
{
    var client = new HttpClient();
    var result = client.GetStringAsync(url).Result; // Bloquea threads
    ProcessResult(result);
});

Esto crea múltiples threads bloqueados esperando I/O. Es ineficiente. Usa async en su lugar.

Error 3: Mezclar Async y Bloqueante (.Result, .Wait)

// ¡MAL! Puede causar deadlocks
public string FetchData()
{
    return FetchDataAsync().Result; // ¡Peligro!
}

Llamar .Result o .Wait() en código async puede causar deadlocks en ciertos contextos (ej. aplicaciones UI, ASP.NET pre-Core). Siempre usa await o mantén todo el stack síncrono.

Combinando Async y Parallel

Puedes combinar async y parallel para máxima eficiencia: paralelizar múltiples operaciones async I/O-bound.

Ejemplo: Fetching Múltiples URLs en Paralelo

public async Task<List<string>> FetchMultipleUrlsAsync(List<string> urls)
{
    var tasks = urls.Select(url => FetchUrlAsync(url)).ToList();
    var results = await Task.WhenAll(tasks);
    return results.ToList();
}

private async Task<string> FetchUrlAsync(string url)
{
    using var client = new HttpClient();
    return await client.GetStringAsync(url);
}

Esto inicia todos los requests HTTP simultáneamente (concurrencia), pero sin bloquear threads (asynchrony). Task.WhenAll espera a que todas completen.

Con Límite de Concurrencia

Si tienes 10,000 URLs, no quieres 10,000 requests simultáneos. Usa SemaphoreSlim para limitar concurrencia:

public async Task<List<string>> FetchWithLimitAsync(List<string> urls, int maxConcurrency)
{
    var semaphore = new SemaphoreSlim(maxConcurrency);
    var tasks = urls.Select(async url =>
    {
        await semaphore.WaitAsync();
        try
        {
            return await FetchUrlAsync(url);
        }
        finally
        {
            semaphore.Release();
        }
    }).ToList();

    return (await Task.WhenAll(tasks)).ToList();
}

Esto ejecuta máximo maxConcurrency requests a la vez, procesando la cola conforme completan.

Cuándo Usar Qué

Usa Async Cuando

  • Haces llamadas a base de datos
  • Haces requests HTTP
  • Lees/escribes archivos
  • Cualquier operación I/O-bound

Beneficios: Escalabilidad (más requests concurrentes sin desperdiciar threads)

Usa Parallel Cuando

  • Procesas imágenes/video
  • Realizas cálculos pesados
  • Transformas grandes datasets en memoria
  • Cualquier operación CPU-bound que pueda dividirse

Beneficios: Velocidad (usa todos los cores de CPU)

Usa Ambos Cuando

  • Llamas múltiples APIs simultáneamente (async + concurrent)
  • Procesamiento por lotes de operaciones I/O (ej. subir 1000 archivos)

Task.Run: El Puente Entre Async y Parallel

Task.Run ejecuta código en un thread del thread pool. Es útil para offload de trabajo CPU-bound de un contexto async.

Ejemplo: Procesamiento CPU-Bound en un Endpoint ASP.NET

[HttpGet("calculate")]
public async Task<IActionResult> Calculate(int max)
{
    // Offload trabajo CPU-bound a un thread de background
    var result = await Task.Run(() => CountPrimes(max));
    return Ok(result);
}

private int CountPrimes(int max)
{
    // Trabajo CPU-bound pesado
    int count = 0;
    for (int i = 2; i <= max; i++)
    {
        if (IsPrime(i)) count++;
    }
    return count;
}

Esto mantiene el request thread libre mientras el trabajo CPU-bound corre en background.

Advertencia: No Abuses de Task.Run

No envuelvas cada llamada async en Task.Run:

// ¡MAL! Innecesario
var result = await Task.Run(() => dbContext.Users.ToListAsync());

ToListAsync ya es async. Envolverlo en Task.Run solo agrega sobrecarga.

Ejemplo del Mundo Real: API Image Processing

[ApiController]
[Route("api/images")]
public class ImageController : ControllerBase
{
    [HttpPost("process")]
    public async Task<IActionResult> ProcessImages(List<IFormFile> files)
    {
        // Paso 1: Guardar archivos asincronamente (I/O-bound)
        var saveTasks = files.Select(async file =>
        {
            var path = Path.Combine("uploads", file.FileName);
            await using var stream = new FileStream(path, FileMode.Create);
            await file.CopyToAsync(stream);
            return path;
        });

        var paths = await Task.WhenAll(saveTasks);

        // Paso 2: Procesar imágenes en paralelo (CPU-bound)
        await Task.Run(() =>
        {
            Parallel.ForEach(paths, path =>
            {
                var image = Image.Load(path);
                image.Resize(800, 600);
                image.Save(path.Replace(".jpg", "_resized.jpg"));
            });
        });

        return Ok("Images processed");
    }
}

Esto combina:

  • Async: Para guardar archivos (I/O)
  • Parallel: Para procesar imágenes (CPU)
  • Task.Run: Para offload de trabajo CPU-bound de ASP.NET request thread

Conclusión

Recuerda:

  • Async != Parallel

    • Async = Eficiencia (sin bloqueo) para I/O
    • Parallel = Velocidad (multi-core) para CPU
  • Cuándo usar async:

    • Database, HTTP, archivos, cualquier I/O
  • Cuándo usar parallel:

    • Cálculos, procesamiento de imágenes, transformaciones de datos
  • Combínalos sabiamente:

    • Múltiples operaciones async I/O: Task.WhenAll
    • Trabajo CPU-bound en contexto async: Task.Run

Deja de confundirlos. Usa la herramienta correcta para el trabajo correcto, y tus aplicaciones .NET serán tanto escalables como rápidas.