Capítulo 4. Conceptos básicos del paralelo

Este trabajo se ha traducido utilizando IA. Agradecemos tus opiniones y comentarios: translation-feedback@oreilly.com

Este capítulo de trata de los patrones para la programación paralela. La programación paralela se utiliza para dividir trozos de trabajo ligados a la CPU y repartirlos entre varios subprocesos. Estas recetas de procesamiento paralelo sólo tienen en cuenta el trabajo ligado a la CPU. Si tienes operaciones naturalmente asíncronas (como trabajo ligado a E/S) que quieres ejecutar en paralelo, consulta el Capítulo 2, y en particular la Receta 2.4.

Las abstracciones de procesamiento paralelo que se tratan en este capítulo forman parte de la Biblioteca Paralela de Tareas (TPL). La TPL está integrada en el marco .NET.

4.1 Procesamiento paralelo de datos

Problema

En tienes una colección de datos, y necesitas realizar la misma operación en cada elemento de los datos. Esta operación está limitada por la CPU y puede llevar algún tiempo.

Solución

El tipo Parallel contiene un método ForEach diseñado específicamente para este problema. El siguiente ejemplo toma una colección de matrices y las gira todas:

void RotateMatrices(IEnumerable<Matrix> matrices, float degrees)
{
  Parallel.ForEach(matrices, matrix => matrix.Rotate(degrees));
}

Hay algunas situaciones en las que querrás detener el bucle antes de tiempo, como si encuentras un valor no válido. El siguiente ejemplo invierte cada matriz, pero si se encuentra una matriz no válida, abortará el bucle:

void InvertMatrices(IEnumerable<Matrix> matrices)
{
  Parallel.ForEach(matrices, (matrix, state) =>
  {
    if (!matrix.IsInvertible)
      state.Stop();
    else
      matrix.Invert();
  });
}

Este código utiliza ParallelLoopState.Stop para detener el bucle, impidiendo cualquier otra invocación del cuerpo del bucle. Ten en cuenta que se trata de un bucle paralelo, por lo que es posible que ya se estén ejecutando otras invocaciones del cuerpo del bucle, incluidas las invocaciones de elementos posteriores al elemento actual. En este ejemplo de código, si la tercera matriz no es invertible, el bucle se detiene y no se procesarán nuevas matrices, pero otras matrices (como la cuarta y la quinta) pueden estar ya procesándose.

Una situación más común es cuando quieres tener la posibilidad de cancelar un bucle paralelo. Esto es diferente de detener el bucle; un bucle se detiene desde dentro del bucle, y se cancela desde fuera del bucle. Para mostrar un ejemplo, un botón de cancelación puede cancelar un CancellationTokenSource, cancelando un bucle paralelo como en este ejemplo de código:

void RotateMatrices(IEnumerable<Matrix> matrices, float degrees,
    CancellationToken token)
{
  Parallel.ForEach(matrices,
      new ParallelOptions { CancellationToken = token },
      matrix => matrix.Rotate(degrees));
}

Una cosa a tener en cuenta es que cada tarea paralela puede ejecutarse en un hilo diferente, por lo que cualquier estado compartido debe estar protegido. El siguiente ejemplo invierte cada matriz y cuenta el número de matrices que no se han podido invertir:

// Note: this is not the most efficient implementation.
// This is just an example of using a lock to protect shared state.
int InvertMatrices(IEnumerable<Matrix> matrices)
{
  object mutex = new object();
  int nonInvertibleCount = 0;
  Parallel.ForEach(matrices, matrix =>
  {
    if (matrix.IsInvertible)
    {
      matrix.Invert();
    }
    else
    {
      lock (mutex)
      {
        ++nonInvertibleCount;
      }
    }
  });
  return nonInvertibleCount;
}

Debate

El método Parallel.ForEach permite el procesamiento paralelo sobre una secuencia de valores. Una solución similar a es Parallel LINQ (PLINQ), que proporciona muchas de las mismas capacidades con una sintaxis similar a LINQ. Una diferencia entre Parallel y PLINQ es que PLINQ asume que puede utilizar todos los núcleos del ordenador, mientras que Parallel reaccionará dinámicamente a las condiciones cambiantes de la CPU.

Parallel.ForEach es un bucle paralelo foreach. Si necesitas hacer un bucle paralelo for, la clase Parallel también admite un método Parallel.For. Parallel.For es especialmente útil si tienes varias matrices de datos que toman todas el mismo índice .

Ver también

La receta 4.2 trata de la agregación de una serie de valores en paralelo, incluyendo sumas y medias.

La receta 4.5 cubre los aspectos básicos de PLINQ.

El capítulo 10 trata de la anulación.

4.2 Agregación paralela

Problema

En la conclusión de una operación paralela, necesitas agregar los resultados. Ejemplos de agregación son sumar valores o hallar su media.

Solución

La clase Parallel admite la agregación mediante el concepto de valores locales, que son variables que existen localmente dentro de un bucle paralelo. Esto significa que el cuerpo del bucle puede acceder directamente al valor, sin necesidad de sincronización. Cuando el bucle está preparado para agregar cada uno de sus resultados locales, lo hace con el delegado localFinally. Ten en cuenta que el delegado localFinally necesita sincronizar el acceso a la variable que contiene el resultado final. He aquí un ejemplo de suma paralela:

// Note: this is not the most efficient implementation.
// This is just an example of using a lock to protect shared state.
int ParallelSum(IEnumerable<int> values)
{
  object mutex = new object();
  int result = 0;
  Parallel.ForEach(source: values,
      localInit: () => 0,
      body: (item, state, localValue) => localValue + item,
      localFinally: localValue =>
      {
        lock (mutex)
          result += localValue;
      });
  return result;
}

Paralelo LINQ tiene un soporte de agregación más natural que la clase Parallel:

int ParallelSum(IEnumerable<int> values)
{
  return values.AsParallel().Sum();
}

Vale, era un golpe bajo, ya que PLINQ tiene soporte incorporado para muchos operadores comunes (por ejemplo, Sum). PLINQ también soporta la agregación genérica mediante el operador Aggregate:

int ParallelSum(IEnumerable<int> values)
{
  return values.AsParallel().Aggregate(
      seed: 0,
      func: (sum, item) => sum + item
  );
}

Debate

Si ya estás utilizando la clase Parallel, puede que quieras utilizar su soporte de agregación. Por lo demás, en la mayoría de los casos, el soporte PLINQ es más expresivo y tiene código más corto.

Ver también

La receta 4.5 cubre los aspectos básicos de PLINQ.

4.3 Invocación paralela

Problema

En tienes varios métodos a los que llamar en paralelo, y estos métodos son (en su mayoría) independientes entre sí.

Solución

La clase Parallel contiene un miembro sencillo Invoke que está diseñado para este escenario. Este ejemplo divide una matriz por la mitad y procesa cada mitad de forma independiente:

void ProcessArray(double[] array)
{
  Parallel.Invoke(
      () => ProcessPartialArray(array, 0, array.Length / 2),
      () => ProcessPartialArray(array, array.Length / 2, array.Length)
  );
}

void ProcessPartialArray(double[] array, int begin, int end)
{
  // CPU-intensive processing...
}

También puedes pasar una matriz de delegados al método Parallel.Invoke si no se conoce el número de invocaciones hasta el momento de la ejecución:

void DoAction20Times(Action action)
{
  Action[] actions = Enumerable.Repeat(action, 20).ToArray();
  Parallel.Invoke(actions);
}

Parallel.Invoke admite la cancelación igual que los demás miembros de la clase Parallel:

void DoAction20Times(Action action, CancellationToken token)
{
  Action[] actions = Enumerable.Repeat(action, 20).ToArray();
  Parallel.Invoke(new ParallelOptions { CancellationToken = token }, actions);
}

Debate

Parallel.Invoke es una gran solución para una simple invocación paralela. Ten en cuenta que no encajará perfectamente si quieres invocar una acción por cada elemento de datos de entrada (utiliza en su lugar Parallel.ForEach ) o si cada acción produce alguna salida (utiliza en su lugar LINQ Paralelo).

Ver también

La receta 4.1 cubre Parallel.ForEach, que invoca una acción para cada dato.

La receta 4.5 trata sobre LINQ Paralelo.

4.4 Paralelismo dinámico

Problema

En tienes una situación paralela más compleja en la que la estructura y el número de tareas paralelas dependen de información que sólo se conoce en tiempo de ejecución.

Solución

La Biblioteca Paralela de Tareas (TPL) se centra en la clase Task. La clase Parallel y LINQ Paralelo son sólo envoltorios de conveniencia alrededor de la potente Task. Cuando necesites paralelismo dinámico, lo más fácil es utilizar directamente el tipo Task.

He aquí un ejemplo en el que es necesario realizar un procesamiento costoso para cada nodo de un árbol binario. La estructura del árbol no se conocerá hasta el momento de la ejecución, por lo que éste es un buen escenario para el paralelismo dinámico. El método Traverse procesa el nodo actual y, a continuación, crea dos tareas hijas, una para cada rama situada debajo del nodo (para este ejemplo, asumo que los nodos padre deben procesarse antes que los hijos). El método ProcessTree inicia el procesamiento creando una tarea padre de nivel superior y esperando a que se complete:

void Traverse(Node current)
{
  DoExpensiveActionOnNode(current);
  if (current.Left != null)
  {
    Task.Factory.StartNew(
        () => Traverse(current.Left),
        CancellationToken.None,
        TaskCreationOptions.AttachedToParent,
        TaskScheduler.Default);
  }
  if (current.Right != null)
  {
    Task.Factory.StartNew(
        () => Traverse(current.Right),
        CancellationToken.None,
        TaskCreationOptions.AttachedToParent,
        TaskScheduler.Default);
  }
}

void ProcessTree(Node root)
{
  Task task = Task.Factory.StartNew(
      () => Traverse(root),
      CancellationToken.None,
      TaskCreationOptions.None,
      TaskScheduler.Default);
  task.Wait();
}

La bandera AttachedToParent garantiza que el Task de cada rama esté vinculado al Task de su nodo padre. Esto crea relaciones padre/hijo entre las instancias Task que reflejan las relaciones padre/hijo en los nodos del árbol. Las tareas padre ejecutan su delegado y luego esperan a que se completen sus tareas hijo. Las excepciones de las tareas hijas se propagan de las tareas hijas a su tarea padre. Así, ProcessTree puede esperar las tareas de todo el árbol con sólo llamar a Wait en la única Task de la raíz del árbol.

Si no tienes una situación del tipo padre/hijo, puedes programar cualquier tarea para que se ejecute después de otra utilizando una continuación de tarea. La continuación es una tarea independiente que se ejecuta cuando finaliza la tarea original:

Task task = Task.Factory.StartNew(
    () => Thread.Sleep(TimeSpan.FromSeconds(2)),
    CancellationToken.None,
    TaskCreationOptions.None,
    TaskScheduler.Default);
Task continuation = task.ContinueWith(
    t => Trace.WriteLine("Task is done"),
    CancellationToken.None,
    TaskContinuationOptions.None,
    TaskScheduler.Default);
// The "t" argument to the continuation is the same as "task".

Debate

CancellationToken.None y TaskScheduler.Default se utilizan en el ejemplo de código anterior. Las fichas de cancelación se tratan en la Receta 10.2, y los programadores de tareas en la Receta 13.3. Siempre es una buena idea especificar explícitamente el TaskScheduler utilizado por StartNew y ContinueWith.

Esta disposición de tareas padre e hijo es habitual en el paralelismo dinámico, aunque no es necesaria. Es igualmente posible almacenar cada nueva tarea en una colección threadsafe y luego esperar a que se completen todas utilizando Task.WaitAll.

Advertencia

Utilizar Task para el procesamiento paralelo es completamente diferente a utilizar Task para el procesamiento asíncrono.

El tipo Task sirve para dos propósitos en la programación concurrente: puede ser una tarea paralela o una tarea asíncrona. Las tareas paralelas pueden utilizar miembros bloqueantes, como Task.Wait, Task.Result, Task.WaitAll, y Task.WaitAny. Las tareas paralelas también suelen utilizar AttachedToParent para crear relaciones padre/hijo entre tareas. Las tareas paralelas deben crearse con Task.Run o Task.Factory.StartNew.

Por el contrario, , las tareas asíncronas deben evitar los miembros bloqueantes y preferir await, Task.WhenAll y Task.WhenAny. Las tareas asíncronas no deben utilizar AttachedToParent, pero pueden formar un tipo implícito de relación padre/hijo esperando otra tarea.

Ver también

La receta 4.3 trata de la invocación de una secuencia de métodos en paralelo, cuando todos los métodos son conocidos al inicio del trabajo paralelo.

4.5 LINQ paralelo

Problema

En necesitas realizar un procesamiento paralelo de una secuencia de datos para producir otra secuencia de datos o un resumen de esos datos.

Solución

La mayoría de los desarrolladores están familiarizados con LINQ, que puedes utilizar para escribir cálculos basados en pull sobre secuencias. LINQ paralelo (PLINQ) amplía esta compatibilidad de LINQ con el procesamiento paralelo.

PLINQ funciona bien en escenarios de streaming, cuando tienes una secuencia de entradas y estás produciendo una secuencia de salidas. He aquí un ejemplo sencillo que simplemente multiplica por dos cada elemento de una secuencia (los escenarios del mundo real requerirán mucha más CPU que una simple multiplicación):

IEnumerable<int> MultiplyBy2(IEnumerable<int> values)
{
  return values.AsParallel().Select(value => value * 2);
}

El ejemplo puede producir sus salidas en cualquier orden; este comportamiento es el predeterminado para LINQ Paralelo. También puedes especificar el orden que debe conservarse. El siguiente ejemplo se sigue procesando en paralelo, pero conserva el orden original:

IEnumerable<int> MultiplyBy2(IEnumerable<int> values)
{
  return values.AsParallel().AsOrdered().Select(value => value * 2);
}

Otro uso natural de LINQ Paralelo es agregar o resumir los datos en paralelo. El código siguiente realiza una suma paralela:

int ParallelSum(IEnumerable<int> values)
{
  return values.AsParallel().Sum();
}

Debate

La clase Parallel es buena para muchos escenarios, pero el código PLINQ es más sencillo cuando se hace agregación o se transforma una secuencia en otra. Ten en cuenta que la clase Parallel es más amigable con otros procesos del sistema que PLINQ; esto es especialmente a tener en cuenta si el procesamiento paralelo se realiza en una máquina servidor.

PLINQ proporciona versiones paralelas de una amplia variedad de operadores, como el filtrado (Where), la proyección (Select) y diversas agregaciones, como Sum, Average y la más genérica Aggregate. En general, cualquier cosa que puedas hacer con LINQ normal puedes hacerla en paralelo con PLINQ. Esto hace que PLINQ sea una gran elección si tienes código LINQ existente que se beneficiaría de ejecutándose en paralelo.

Ver también

La receta 4.1 explica cómo utilizar la clase Parallel para ejecutar código para cada elemento de una secuencia.

La receta 10.5 explica cómo cancelar consultas PLINQ.

Get Libro de cocina de la concurrencia en C#, 2ª edición now with the O’Reilly learning platform.

O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.