Compartir a través de


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 como Action<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 tipo struct 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 puntero this 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 como ImageSharp.
  • 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 de struct que se pasan a las API de ParallelHelper: se trata de IAction, IAction2D, IRefAction<T> y IInAction<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.