ParallelHelper
ParallelHelper
contiene API de alto rendimiento para trabajar con código paralelo. Contiene métodos orientados al rendimiento que se pueden usar para configurar y ejecutar rápidamente operaciones paralelas en un conjunto de datos determinado o un intervalo o área de iteración.
API de la plataforma:
ParallelHelper
,IAction
,IAction2D
,IRefAction<T>
,IInAction<T><T>
Funcionamiento
El tipo ParallelHelper
se basa en tres conceptos principales:
- Realiza el procesamiento por lotes automático a través del intervalo de iteración de destino. Esto significa que programa automáticamente el número correcto de unidades de trabajo en función del número de núcleos de CPU disponibles. Esto se hace para reducir la sobrecarga de invocar la devolución de llamada paralela una vez para cada iteración paralela única.
- Aprovecha en gran medida la forma en que se implementan los tipos genéricos en C#, y usa tipos
struct
que implementan interfaces específicas en lugar de delegados comoAction<T>
. Esto se hace para que el compilador JIT sea capaz de "ver" cada devolución de llamada individual que se está usando, lo que le permite insertarla por completo, cuando sea posible. Esto puede reducir considerablemente la sobrecarga de cada iteración paralela, especialmente cuando se usan devoluciones de llamada muy pequeñas, lo que tendría un costo trivial con respecto a la invocación del delegado solo. Además, usar un tipostruct
como devolución de llamada requiere que los desarrolladores controlen manualmente las variables que se están capturando en el cierre, lo que evita capturas accidentales del punterothis
de métodos de instancia y otros valores que podrían ralentizar considerablemente cada invocación de devolución de llamada. Este es el mismo enfoque que se usa en otras bibliotecas orientadas al rendimiento comoImageSharp
. - Expone 4 tipos de API que representan 4 tipos diferentes de iteraciones: bucles 1D y 2D, iteración de elementos con efecto secundario e iteración de elementos sin efecto secundario. Cada tipo de acción tiene un tipo
interface
correspondiente que debe aplicarse a las devoluciones de llamada destruct
que se pasan a las API deParallelHelper
: se trata deIAction
,IAction2D
,IRefAction<T>
yIInAction<T><T>
. Esto ayuda a los desarrolladores a escribir código más claro con respecto a su intención y permite que las API realicen más optimizaciones internamente.
Sintaxis
Supongamos que estamos interesados en procesar todos los elementos de alguna matriz float[]
y multiplicar cada uno de ellos por 2
. En este caso no necesitamos capturar ninguna variable: solo tenemos que usar IRefAction<T>
interface
y ParallelHelper
cargará cada elemento para alimentar nuestra devolución de llamada automáticamente. Todo lo que necesitamos es definir nuestra devolución de llamada, que recibirá un argumento ref float
y realizará la operación necesaria:
// Be sure to include this using at the top of the file:
using Microsoft.Toolkit.HighPerformance.Helpers;
// First declare the struct callback
public readonly struct ByTwoMultiplier : IRefAction<float>
{
public void Invoke(ref float x) => x *= 2;
}
// Create an array and run the callback
float[] array = new float[10000];
ParallelHelper.ForEach<float, ByTwoMultiplier>(array);
Con la API ForEach
, no necesitamos especificar los rangos de iteración: ParallelHelper
procesará por lotes la colección y procesará cada elemento de entrada automáticamente. Además, en este ejemplo concreto ni siquiera teníamos que pasar nuestro struct
como argumento: como no contenía ningún campo que tuviéramos que inicializar, solo teníamos que especificar su tipo como argumento de tipo al invocar ParallelHelper.ForEach
: la API creará después una nueva instancia de ese struct
por sí sola, y lo usará para procesar los distintos elementos.
Para introducir el concepto de cierres, supongamos que queremos multiplicar los elementos de matriz por un valor especificado en tiempo de ejecución. Para ello, es necesario "capturar" ese valor en nuestro tipo struct
de devolución de llamada. Podemos hacerlo así:
public readonly struct ItemsMultiplier : IRefAction<float>
{
private readonly float factor;
public ItemsMultiplier(float factor)
{
this.factor = factor;
}
public void Invoke(ref float x) => x *= this.factor;
}
// ...
ParallelHelper.ForEach(array, new ItemsMultiplier(3.14f));
Podemos ver que ahora struct
contiene un campo que representa el factor que queremos usar para multiplicar los elementos, en lugar de usar una constante. Y al invocar ForEach
, estamos creando explícitamente una instancia de nuestro tipo de devolución de llamada, con el factor que nos interesa. Además, en este caso, el compilador de C# también puede reconocer automáticamente los argumentos de tipo que estamos usando, por lo que podemos omitirlos juntos de la invocación del método.
Este enfoque de creación de campos para los valores a los que necesitamos acceder desde una devolución de llamada nos permite declarar explícitamente los valores que queremos capturar, lo que ayuda a que el código sea más expresivo. Esto es exactamente lo mismo que el compilador de C# hace en segundo plano cuando declaramos una función lambda o una función local que también tiene acceso a alguna variable local.
Este es otro ejemplo, esta vez con la API For
para inicializar todos los elementos de una matriz en paralelo. Observe cómo esta vez estamos capturando la matriz de destino directamente, y estamos usando el IAction
interface
para nuestra devolución de llamada, que da a nuestro método el índice de iteración paralela actual como argumento:
public readonly struct ArrayInitializer : IAction
{
private readonly int[] array;
public ArrayInitializer(int[] array)
{
this.array = array;
}
public void Invoke(int i)
{
this.array[i] = i;
}
}
// ...
ParallelHelper.For(0, array.Length, new ArrayInitializer(array));
Nota:
Dado que los tipos de devolución de llamada son struct
, se pasan por copia a cada subproceso que se ejecuta en paralelo, no por referencia. Esto significa que los tipos de valor que se almacenan como campos de un tipo de devolución de llamada también se copiarán. Un buen método para recordar ese detalle y evitar errores es marcar la devolución de llamada struct
como readonly
, para que el compilador de C# no nos deje modificar los valores de sus campos. Esto solo se aplica a los campos de instancia de un tipo de valor: si una devolución de llamada struct
tiene un campo static
de cualquier tipo, o un campo de referencia, ese valor se compartirá correctamente entre subprocesos paralelos.
Métodos
Estas son las 4 API principales expuestas por ParallelHelper
, correspondientes a las interfaces IAction
, IAction2D
, IRefAction<T>
y IInAction<T>
. El tipo ParallelHelper
también expone una serie de sobrecargas para estos métodos, que ofrecen varias maneras de especificar los intervalos de iteración o el tipo de devolución de llamada de entrada. For
y For2D
funcionan en instancias de IAction
y IAction2D
, y están diseñados para usarse cuando es necesario realizar algún trabajo paralelo que no sea necesario asignar a una colección subyacente a la que se pueda acceder directamente con los índices de cada iteración paralela. Las sobrecargas ForEach
funcionan en cambio en instancias de IRefAction<T>
y IInAction<T>
, y pueden usarse cuando las iteraciones paralelas asignan directamente elementos a una colección que puede ser indexada directamente. En este caso, también abstraen la lógica de indexación, de modo que cada invocación paralela solo tenga que preocuparse por el elemento de entrada en el que trabajar y no sobre cómo recuperar ese elemento también.
Ejemplos
Encontrará más ejemplos en las pruebas unitarias.
.NET Community Toolkit