CodeGym /Cursos /C# SELF /Procesamiento paralelo de datos

Procesamiento paralelo de datos

C# SELF
Nivel 60 , Lección 3
Disponible

1. Introducción

Hoy veremos cómo procesar grandes volúmenes de datos lo más rápido posible usando todos los núcleos del procesador de tu equipo (o servidor). Para eso nos vendrán bien las clases del namespace System.Threading.Tasks.Parallel, concretamente los métodos Parallel.For y Parallel.ForEach.

¿Y si la tarea es puramente CPU-bound?

Un bucle clásico for o foreach procesa los elementos uno a uno. Simple y fiable. Pero si tienes un procesador multinúcleo, el bucle usa solo un núcleo y los demás se aburren. ¿Por qué no repartir partes del array entre núcleos para procesarlas simultáneamente?

Ejemplo:


// Calculamos la suma de los cuadrados de 1 a N
long sum = 0;
for (int i = 1; i <= 1_000_000; i++)
{
    sum += i * i;
}

Este código es sencillo, pero funciona de forma secuencial. ¿Y si repartimos las tareas entre núcleos?

Conoce la familia: Parallel.For y Parallel.ForEach

¿Qué son?

  • Parallel.For — funciona como un bucle for normal, pero divide el trabajo en partes y las asigna automáticamente a threads, usando todos los núcleos disponibles.
  • Parallel.ForEach — procesa una colección como un foreach normal, pero también en paralelo.

Documentación oficial:

¿Por qué es cómodo?

No necesitas crear, arrancar y controlar threads manualmente. El framework hace todo el trabajo pesado por ti. Escribes código parecido a un bucle normal y el paralelismo ocurre automáticamente, bajo el capó.

2. Sintaxis: ejemplos básicos

Parallel.For


long total = 0;
Parallel.For(1, 1_000_001, i =>
{
    // Esta lambda puede ser ejecutada simultáneamente por distintos threads
    Interlocked.Add(ref total, i * i); // Para evitar race conditions
});
Console.WriteLine($"Suma de cuadrados: {total}");

Atención: la variable total la actualizamos mediante Interlocked.Add — para evitar race conditions.

Parallel.ForEach


var numbers = Enumerable.Range(1, 10_000_000).ToArray();
long sum = 0;

Parallel.ForEach(numbers, num =>
{
    Interlocked.Add(ref sum, num * num); // Suma segura
});
Console.WriteLine($"Suma de cuadrados: {sum}");

Vista interna (Esquema visual)


+-------------------+
|Colección/rango    |
+---------+---------+
          |
          v
  +----------------------+
  |   Parallel.ForEach   |
  +----------+-----------+
             |
        +----+----+----+----+
        |         |         |
        v         v         v
  Task #1    Task #2    Task #3   ... (núcleos disponibles)
        |         |         |
     +--+----+  +--+-----+  +--+-----+
     |Procesar|  |Procesar|  |Procesar|
     +-------+  +--------+  +--------+
        \         |         /
         +--------+--------+
                  |
                  v
               Resultado

3. Análisis de archivos grandes (procesamiento CPU-bound)

Supongamos que tenemos un archivo de texto con decenas de miles de líneas — por ejemplo, cada línea contiene un número. Hay que leer el archivo, elevar cada número al cuadrado y sumar los cuadrados.

Versión síncrona


string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;

foreach (var line in lines)
{
    if (long.TryParse(line, out long n))
    {
        sum += n * n;
    }
}
Console.WriteLine($"Suma de cuadrados: {sum}");

Versión paralela con Parallel.For


string[] lines = File.ReadAllLines("numbers.txt");
long sum = 0;

Parallel.For(0, lines.Length, i =>
{
    if (long.TryParse(lines[i], out long n))
    {
        Interlocked.Add(ref sum, n * n);
    }
});
Console.WriteLine($"Suma de cuadrados: {sum}");

¿Qué cambió: reemplazamos el bucle normal por uno paralelo, y sum ahora lo incrementamos con Interlocked.Add — para evitar conflictos entre threads.

4. ¿Qué pasa bajo el capó?

Cuando llamas a Parallel.For o Parallel.ForEach, .NET divide automáticamente tu trabajo en fragmentos y los distribuye entre los núcleos disponibles, usando el thread pool. Cada fragmento se procesa de forma independiente en su propio thread.

Ventaja: si tienes 4 núcleos, el trabajo puede ir casi 4 veces más rápido (si la tarea no depende de recursos externos y no está limitada por otros cuellos de botella, por ejemplo memoria o velocidad de disco).

Comparación de tiempos de ejecución


var numbers = Enumerable.Range(1, 100_000_000).ToArray();
long sumSync = 0;
var sw = System.Diagnostics.Stopwatch.StartNew();

foreach (var n in numbers)
    sumSync += n * n;

sw.Stop();
Console.WriteLine($"Sync: {sw.ElapsedMilliseconds} ms, suma: {sumSync}");

long sumParallel = 0;
sw.Restart();

Parallel.ForEach(numbers, n =>
    Interlocked.Add(ref sumParallel, n * n)
);

sw.Stop();
Console.WriteLine($"Parallel: {sw.ElapsedMilliseconds} ms, suma: {sumParallel}");

¡Pruébalo tú mismo! En un equipo potente la aceleración puede ser de varios órdenes, pero todo depende de la tarea y de los cuellos de botella.

5. Matices útiles

Control del grado de paralelismo

A veces tiene sentido limitar el número de threads usados (por ejemplo, para no sobrecargar el sistema). Para eso usa MaxDegreeOfParallelism:


using System.Threading.Tasks;
long sum = 0;
var options = new ParallelOptions { 
    MaxDegreeOfParallelism = 2 
};
Parallel.For(0, 100, options, i =>
{
    Interlocked.Add(ref sum, i * i);
});
Console.WriteLine($"Suma de cuadrados: {sum}");

¿Cuándo es útil: si sabes que parte de los cálculos cargan mucho el disco y no la CPU — usa menos threads y mide el impacto en el rendimiento.

¿Cuándo usar bucles paralelos?

Bucle for normal Parallel.For/Parallel.ForEach
Procesadores Usa un solo núcleo Usa todos los núcleos
Orden Garantizado No garantizado
Velocidad Normalmente más lento A menudo considerablemente más rápido
Sencillez Muy simple Requiere considerar thread-safety
Mejor uso Pequeños volúmenes de datos, I/O-bound Grandes volúmenes de datos, CPU-bound

Extensión: ¿qué más hace Parallel?

Parallel.Invoke() — ejecuta varios métodos independientes a la vez:


static void DoTask1() => Console.WriteLine("Tarea 1 completada");
static void DoTask2() => Console.WriteLine("Tarea 2 completada");
static void DoTask3() => Console.WriteLine("Tarea 3 completada");

Parallel.Invoke(
    () => DoTask1(),
    () => DoTask2(),
    () => DoTask3()
);

Cada método se ejecutará en su núcleo si es posible.

Aplicaciones en la vida real

  • Procesamiento de imágenes: procesar bloques distintos simultáneamente (por ejemplo, aplicar un filtro).
  • Cálculos sobre arrays: cálculos financieros, modelado (evaluación de portafolios con escenarios).
  • Trabajo con logs grandes: búsqueda y agregación en varios núcleos.
  • Machine learning: dividir en tareas independientes (batches de datos, feature engineering).

Y, por supuesto, en una entrevista podrás no solo explicar qué son los bucles paralelos, sino también comentar honestamente sus pros y sus contras.

6. Errores típicos al trabajar con Parallel.For y Parallel.ForEach

Error nº1: Ignorar las race conditions.
Actualizar una variable compartida sin Interlocked o lock lleva a resultados incorrectos por accesos simultáneos desde varios threads.

Error nº2: Usarlo para tareas I/O-bound.
Los bucles paralelos no aceleran tareas dependientes de disco o red y pueden incluso ralentizarlas por la sobrecarga.

Error nº3: Suponer el orden de ejecución.
Los bucles paralelos no garantizan el orden de procesamiento de elementos, lo que puede romper la lógica si depende de secuencia.

Error nº4: Ignorar efectos secundarios.
Modificar estado compartido (por ejemplo, colecciones) dentro de bucles paralelos puede provocar errores si no se usan estructuras thread-safe.

Comentarios
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION