Programación Paralela Estructurada con C#

En versiones anteriores de C#, la manipulación de hilos mediante la clase Thread presentaba una complejidad considerable y un riesgo inherente. Afortunadamente, .NET ha evolucionado para ofrecer la Task Parallel Library (TPL), un conjunto de APIs que simplifican y aseguran la ejecución concurrente. La TPL incluye las poderosas herramientas Parallel y Task, que abordaremos en este artículo.

Comprendiendo la Paralelización

Ley de Amdahl

La Ley de Amdahl es fundamental para entender las limitaciones de la mejora de rendimiento mediante la paralelización. Establece que la aceleración máxima de un programa está limitada por la porción secuencial de su ejecución.

La fórmula de aceleración (s) para un número de procesadores (n) y una porción paralelizable (w) es:

s = 1 / ((1 - w) + w / n)

  • Si la porción secuencial (1-w) se acerca a cero, la aceleración tiende a 'n'.
  • Si la porción paralelizable (w) se acerca a cero, la aceleración tiende a 1, sin importar cuántos procesadores se añadan.
  • A medida que 'n' tiende a infinito, la aceleración se limita a 1 / (1 - w).

En resumen, la aceleración depende críticamente de la proporción de la tarea que puede ejecutarse en paralelo.

Tipos de Cargas de Trabajo

CPU-Bound (Intensivas en CPU

Estas tareas demandan una gran cantidad de procesamiento computacional y no presentan cuellos de botella significativos en I/O. La paralelización mediante múltiples hilos puede acelerar estas tareas en sistemas multinúcleo. Generalmente, el número de hilos óptimo se acerca al número de núcleos de CPU. En sistemas de un solo núcleo, la paralelización simulada no ofrecerá una mejora real de rendimiento.

I/O-Bound (Intensivas en I/O

Estas tareas pasan la mayor parte del tiempo esperando operaciones de entrada/salida (disco, red). El uso de la CPU suele ser bajo. Para optimizar estas cargas, a menudo se requiere un número de hilos considerablemente mayor que el número de núcleos de CPU para mantener la CPU ocupada mientras espera las operaciones de I/O.

PLINQ (Parallel Language Integrated Query)

PLINQ extiende LINQ para permitir la ejecución paralela de consultas. Añadiendo .AsParallel() a una fuente de datos LINQ, el framework se encarga de dividir el trabajo, ejecutarlo en paralelo y consolidar los resultados.

Ejemplo: Búsqueda de Números Primos

Para encontrar números primos entre 3 y 100,000 de forma paralela:


var numbers = Enumerable.Range(3, 100000 - 3);
var parallelQuery =
   from n in numbers.AsParallel()
   where Enumerable.Range(2, (int)Math.Sqrt(n)).All(i => n % i > 0)
   select n;
int[] primes = parallelQuery.ToArray();
   

Alternativamente, se puede usar una sintaxis de métodos:


var parallelQuery = numbers.AsParallel().Where(x => Enumerable.Range(2, (int)Math.Sqrt(x)).All(i => x % i > 0));
   

Importante: Es crucial aplicar AsParallel() al principio de la cadena de consulta. Aplicarlo después de operaciones de filtrado secuenciales limita la paralelización.

Consideraciones Clave de PLINQ

  • Aplicable a colecciones locales.
  • Las excepciones se agregan en un AggregateException.
  • Por defecto, los resultados son desordenados; use AsOrdered() para mantener el orden original (con un posible impacto en el rendimiento).
  • Las operaciones deben ser seguras para hilos.
  • La paralelización incurre en costos (división de tareas, gestión de hilos).
  • PLINQ puede optar por la ejecución secuencial si la paralelización no es beneficiosa.
  • Asume cargas de trabajo CPU-bound por defecto.

Comportamiento de Almacenamiento en Búfer (Buffering)

PLINQ utiliza un modelo de consulta diferida (lazy evaluation). Puede controlar el comportamiento de almacenamiento en búfer con WithMergeOptions:

  • Default (AutoBuffered): Equilibrio entre latencia y rendimiento.
  • NoBuffered: Los resultados están disponibles inmediatamente.
  • AutoBuffered: El sistema gestiona el tamaño del búfer.
  • FullyBuffered: Todos los resultados se almacenan en búfer antes de ser devueltos (útil para OrderBy, Reverse).

Control de Ordenación

Para mantener el orden de salida:


var parallelQuery =
   from n in numbers.AsParallel().AsOrdered()
   where Enumerable.Range(2, (int)Math.Sqrt(n)).All(i => n % i > 0)
   select n;
   

AsUnordered() puede revertir el efecto de AsOrdered().

Restricciones de PLINQ

  • El sobrecargo Aggregate con semilla no es paralelizable directamente.
  • PLINQ puede forzar la paralelización con WithExecutionMode(ParallelExecutionMode.ForceParallelism).
  • Operadores binarios (Join, Union, etc.) requieren que ambas secuencias sean ParallelQuery y pueden usar partición hash costosa.
  • Las operaciones que modifican el orden o eliminan elementos deben aplicarse al principio de la consulta.
  • Evite operaciones no seguras para hilos.

Caso de Uso CPU-Bound: Verificación de Ortografía

Este ejemplo verifica la ortografía de un gran conjunto de palabras utilizando PLINQ.


// Supongamos que wordLookup es un HashSet cargado con palabras válidas.
// wordsToTest es un array de palabras a verificar.

var errors = wordsToTest
   .AsParallel()
   .Select((word, index) => new { Word = word, Index = index })
   .Where(iword => !wordLookup.Contains(iword.Word))
   .OrderBy(iword => iword.Index);

foreach (var item in errors)
{
   Console.WriteLine($"Error: {item.Word} en índice {item.Index}");
}
   

Se utiliza ThreadLocal<Random> para generar datos de prueba de forma segura en paraello.

Caso de Uso I/O-Bound: Ping y Monitorización

Ping Paralelo

Para realizar pings a múltiples sitios simultáneamente, se puede forzar el paralelismo:


string[] sites = { "www.oreilly.com", "stackoverflow.com" };
sites.AsParallel()
   .WithDegreeOfParallelism(sites.Length) // Forzar el número de tareas
   .Select(site =>
   {
       var p = new Ping().Send(site);
       return new { site, Status = p.Status, Time = p.RoundtripTime };
   })
   .ForAll(res => // Usar ForAll para evitar la consolidación de resultados
   {
       Console.WriteLine($"{res.site} responded in {res.Time}ms with status: {res.Status}");
   });
   

Monitorización de Cámaras

Simulación de captura de fotogramas de múltiples cámaras en paralelo.


class Camera { /* ... */ public string GetNextFrame() { /* ... */ return $"Frame from camera {CameraID}"; } }

Camera[] cameras = Enumerable.Range(0, 4).Select(i => new Camera(i)).ToArray();

while (true)
{
   string[] data = cameras
       .AsParallel()
       .WithDegreeOfParallelism(4)
       .Select(c => c.GetNextFrame())
       .ToArray();
   Console.WriteLine(string.Join(", ", data));
}
   

Cancelación

PLINQ soporta CancellationToken para una cancelación cooperativa.


var numbers = Enumerable.Range(3, 1000000 - 3);
var cancelSource = new CancellationTokenSource();
var parallelQuery = numbers
   .AsParallel()
   .WithCancellation(cancelSource.Token)
   .Where(x => Enumerable.Range(2, (int)Math.Sqrt(x)).All(i => x % i > 0));

Task.Run(() => { Thread.Sleep(2); cancelSource.Cancel(); }); // Cancelar después de 2ms

try
{
   foreach (var prime in parallelQuery)
   {
       Console.WriteLine(prime); // Imprimir algunos para ver el progreso
   }
}
catch (OperationCanceledException)
{
   Console.WriteLine("La operación fue cancelada.");
}
   

La cancelación no interrumpe hilos inmediatamente, sino que espera a que completen la iteración actual.

Agregación

Las agregaciones simples como Sum, Average son manejadas eficientemente. Para agregaciones personalizadas con Aggregate, se puede usar una fábrica de semillas locales para mejorar el rendimiento.


// Agregación paralela con semilla local
int sum = Enumerable.Range(1, 1000000).AsParallel().Aggregate(
   () => 0, // Fábrica de semilla local
   (pre, cur) => pre + cur, // Agregación local
   (main, local) => main + local, // Combinación de agregados locales
   x => x); // Resultado final
   

Clase Parallel

La clase Parallel (en System.Threading.Tasks) ofrece métodos para paralelizar bucles y acciones:

  • Parallel.Invoke: Ejecuta múltiples acciones en paralelo.
  • Parallel.For: Versión paralela de un bucle for.
  • Parallel.ForEach: Versión paralela de un bucle foreach.
  • Parallel.ForEachAsync: Versión asíncrona de ForEach (introducida en .NET 6).

Estos métodos bloquean hasta que todas las tareas se completan y agrupan excepciones en AggregateException.

Parallel.Invoke

Ejecuta un conjunto de delegados en paralelo.


// Ejemplo simple
Parallel.Invoke(
   () => Console.WriteLine("Tarea 1"),
   () => Console.WriteLine("Tarea 2")
);

// Recolectando resultados (¡cuidado con la seguridad de hilos!)
var results = new ConcurrentBag<string>();
Parallel.Invoke(
   () => { var p = new Ping().Send("site1"); results.Add($"site1: {p.Status}"); },
   () => { var p = new Ping().Send("site2"); results.Add($"site2: {p.Status}"); }
);
   </string>

ParallelOptions

Permite configurar el comportamiento de paralelización, incluyendo:

  • CancellationToken: Para cancelación cooperativa.
  • MaxDegreeOfParallelism: Limita el número de tareas concurrentes.
  • TaskScheduler: Permite especificar un planificador de tareas personalizado.

Parallel.For y Parallel.ForEach

Permiten paralelizar bucles de forma nativa.


// Parallel.For
var keyPairs = new string[6];
Parallel.For(0, keyPairs.Length, i => keyPairs[i] = RSA.Create().ToXmlString(true));

// Parallel.ForEach con índice
Parallel.ForEach("Hello", (char c, ParallelLoopState state, long i) =>
{
   Console.WriteLine($"Index: {i}, Char: {c}");
   if (c == 'l') state.Stop(); // Detener el bucle
});
   

ParallelLoopState ofrece métodos como Stop() y Break() para controlar la ejecución del bucle.

Parallel.ForEachAsync

Introducido en .NET 6, facilita la ejecución asíncrona y controlada de bucles.


await Parallel.ForEachAsync(Enumerable.Range(1, 100), new ParallelOptions()
{
   MaxDegreeOfParallelism = 10
}, async (x, cancellationToken) =>
{
   await Task.Delay(1000); // Simular operación asíncrona
   Console.WriteLine($"Processed item {x} on thread {Thread.CurrentThread.ManagedThreadId}");
});
   

Etiquetas: C# PLINQ Parallel TPL Programación Paralela

Publicado el 8-19 01:00