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.
También te puede interesar
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
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
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ímite | Parallel.ForEachAsync con MaxDegreeOfParallelism |
| Calcular mucho sobre muchos datos | Parallel.For / Parallel.ForEach |
| Sumar desde varios hilos | Interlocked 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.