Tareas en paralelo en C#: Task.WhenAll, Parallel.ForEachAsync y Parallel.For

Hacer varias cosas a la vez en C# es fácil desde que existen async y await, pero hay dos situaciones distintas con herramientas distintas: esperar a varias cosas que tardan (la red, el disco) y repartir un cálculo entre los núcleos del procesador.

Esperar varias cosas a la vez: Task.WhenAll

La mayor parte del tiempo que tarda un programa no la pasa calculando, sino esperando: a la red, al disco, a una base de datos. Si tienes que hacer tres consultas independientes, no hace falta esperar a que termine una para empezar la siguiente. Aquí Consultar simula una petición de un segundo:

using System.Diagnostics;

var reloj = Stopwatch.StartNew();
decimal precio = await Consultar("precio", 1000, 4.50m);
decimal stock = await Consultar("stock", 1000, 30);
decimal envio = await Consultar("envío", 1000, 2.99m);
Console.WriteLine($"Una tras otra: {reloj.Elapsed.TotalSeconds:F0} s");

reloj.Restart();
Task<decimal> tPrecio = Consultar("precio", 1000, 4.50m);
Task<decimal> tStock = Consultar("stock", 1000, 30);
Task<decimal> tEnvio = Consultar("envío", 1000, 2.99m);
decimal[] todo = await Task.WhenAll(tPrecio, tStock, tEnvio);
Console.WriteLine($"Las tres a la vez: {reloj.Elapsed.TotalSeconds:F0} s -> {string.Join(", ", todo)}");

// Simula una consulta a otro servicio que tarda «ms» milisegundos
static async Task<decimal> Consultar(string que, int ms, decimal valor)
{
    await Task.Delay(ms);
    return valor;
}
salidaUna tras otra: 3 s
Las tres a la vez: 1 s -> 4.50, 30, 2.99

Tres operaciones de un segundo cada una: con await una tras otra tardan tres segundos en total; lanzadas juntas y esperadas con Task.WhenAll tardan un segundo, porque las esperas se solapanTres esperas de un segundo: una tras otra o a la vezawait, una a unapreciostockenvíoTask.WhenAllpreciostockenvío0 s1 s2 s3 sMientras esperan a la red, las tareas no usan el procesador: esperar a la vez sale gratis.

Con await una a una, los tiempos se suman; con Task.WhenAll, las esperas se solapan y el total es el de la más lenta. Esquema: decodigo.com.

La clave está en no poner await al llamar: Consultar(...) devuelve enseguida un Task, una promesa de resultado, y la consulta sigue en marcha. Task.WhenAll espera a todas y devuelve sus resultados en un array. Los tiempos están redondeados a segundos.

El orden de los resultados

var tareas = new[] { Tardar("A", 600), Tardar("B", 200), Tardar("C", 400) };
string[] resultados = await Task.WhenAll(tareas);
Console.WriteLine($"WhenAll devuelve: {string.Join(", ", resultados)}");

static async Task<string> Tardar(string nombre, int ms)
{
    await Task.Delay(ms);
    Console.WriteLine($"  terminó {nombre}");
    return nombre;
}
salida  terminó B
  terminó C
  terminó A
WhenAll devuelve: A, B, C

Las tareas terminan en el orden que les toca (primero B, la más rápida), pero WhenAll devuelve los resultados en el orden en que se las pasaste. Puedes emparejar cada resultado con su petición por la posición. Y si una tarea lanza una excepción, await Task.WhenAll la relanza después de que terminen todas.

Muchas tareas, pero no todas a la vez

Lanzar mil peticiones a la vez contra el mismo servidor es una buena forma de que te bloquee. Parallel.ForEachAsync (desde .NET 6) recorre una colección con un máximo de tareas en marcha:

using System.Diagnostics;

string[] urls = ["/a", "/b", "/c", "/d"];

foreach (int maximo in new[] { 2, 4 })
{
    var reloj = Stopwatch.StartNew();
    await Parallel.ForEachAsync(urls, new ParallelOptions { MaxDegreeOfParallelism = maximo }, async (url, ct) =>
    {
        await Task.Delay(1000, ct);   // una descarga que tarda un segundo
    });
    Console.WriteLine($"De {maximo} en {maximo}: {reloj.Elapsed.TotalSeconds:F0} s");
}
salidaDe 2 en 2: 2 s
De 4 en 4: 1 s

Cuatro descargas de un segundo, de dos en dos, son dos rondas; de cuatro en cuatro, una. Si no indicas MaxDegreeOfParallelism, usa tantas como núcleos tenga el procesador.

Cálculos que sí usan el procesador: Parallel.For

Lo anterior es concurrencia: esperar a la vez. Cuando el trabajo es calcular, lo que ayuda es el paralelismo: repartir el cálculo entre los núcleos. Parallel.For divide el rango entre varios hilos:

const int Limite = 2_000_000;

int secuencial = 0;
for (int n = 2; n < Limite; n++)
{
    if (EsPrimo(n)) secuencial++;
}

int paralelo = 0;
Parallel.For(2, Limite, n =>
{
    if (EsPrimo(n)) Interlocked.Increment(ref paralelo);
});

Console.WriteLine($"Primos por debajo de {Limite:N0}: {secuencial:N0} y {paralelo:N0}");

static bool EsPrimo(int n)
{
    if (n % 2 == 0) return n == 2;
    for (int d = 3; d * d <= n; d += 2)
    {
        if (n % d == 0) return false;
    }
    return true;
}
salidaPrimos por debajo de 2,000,000: 148,933 y 148,933

Los dos recuentos coinciden, y en una máquina con varios núcleos la versión paralela tarda una fracción del tiempo (aquí no lo medimos porque depende de cada ordenador). Fíjate en Interlocked.Increment: con varios hilos sumando a la vez a la misma variable, hace falta.

La condición de carrera

int sinProteger = 0;
int conInterlocked = 0;

Parallel.For(0, 1_000_000, _ =>
{
    sinProteger++;                                  // leer, sumar y escribir: tres pasos
    Interlocked.Increment(ref conInterlocked);      // un solo paso indivisible
});

Console.WriteLine($"sin proteger: ¿llegó al millón? {sinProteger == 1_000_000}");
Console.WriteLine($"con Interlocked: {conInterlocked:N0}");
salidasin proteger: ¿llegó al millón? False
con Interlocked: 1,000,000
Un ++ no es una sola operación. contador++ lee el valor, le suma uno y lo escribe. Si dos hilos leen el mismo valor a la vez, los dos escriben el mismo resultado y se pierde una suma. El fallo no da ningún error y el número exacto cambia en cada ejecución, que es lo que lo hace tan difícil de encontrar. Con varios hilos tocando el mismo dato, usa Interlocked, un lock, o mejor aún, que cada hilo calcule lo suyo y se sumen al final.

Cuál usar

Si el trabajo es…Usa
Esperar a varias cosas (red, disco)Task.WhenAll
Esperar a muchas, con un límiteParallel.ForEachAsync con MaxDegreeOfParallelism
Calcular mucho sobre muchos datosParallel.For / Parallel.ForEach
Sumar desde varios hilosInterlocked o lock

Todo el código de esta página se compiló con el .NET SDK 10.0.403 (C# 14) y se ejecutó con .NET 10.0.14 en un contenedor limpio antes de publicarla; las salidas y los mensajes del compilador están copiados de esa ejecución.