【2023】 Marco de unión/horquilla multiproceso

1. ¿Qué es Fork/Join?

El marco Fork/Join es un procesador multiproceso que implementa la interfaz ExecutorService y está diseñado para tareas que se pueden descomponer en tareas más pequeñas mediante recursión, maximizando el uso de procesadores multinúcleo para mejorar el rendimiento de la aplicación.

Al igual que otras implementaciones relacionadas con ExecutorService, el marco Fork/Join asigna tareas a subprocesos en el grupo de subprocesos. La diferencia es que el marco Fork/Join utiliza un algoritmo de robo de trabajo al ejecutar tareas.

Fork significa bifurcar en inglés y join significa conectar o combinar en inglés. Como sugiere el nombre, fork es descomponer una tarea grande en varias tareas pequeñas, y unirse es finalmente combinar los resultados de cada tarea pequeña para obtener el resultado de la tarea grande.

El proceso de ejecución de Fork/Join es aproximadamente el siguiente:
Insertar descripción de la imagen aquí
Cabe señalar que las subtareas secundarias en la imagen se pueden dividir hasta que sean lo suficientemente pequeñas. Expresado en pseudocódigo de la siguiente manera:

solve(任务):
    if(任务已经划分到足够小):
        顺序执行任务
    else:
        for(划分任务得到子任务)
            solve(子任务)
        结合所有子任务的结果到上一层循环
        return 最终结合的结果

Como se puede ver en el pseudocódigo anterior, obtenemos el resultado final mediante cálculos anidados recursivos, que incorporan la idea algorítmica de divide y vencerás.

2. Algoritmo de robo de trabajo

El algoritmo de robo de trabajo se refiere al proceso en el que varios subprocesos ejecutan diferentes colas de tareas. Una vez que un subproceso termina de ejecutar las tareas en su propia cola, roba tareas de las colas de tareas de otros subprocesos para su ejecución.

El proceso de robo de trabajo se muestra en la siguiente figura:
Insertar descripción de la imagen aquí

Vale la pena señalar que cuando un hilo roba otro hilo, para reducir la competencia entre los dos hilos de tareas, generalmente usamos una cola de doble extremo para almacenar tareas. Los subprocesos de tareas robadas ejecutan tareas desde el principio de la cola de doble extremo, mientras que los subprocesos que roban otras tareas ejecutan tareas desde el final de la cola de doble extremo.

Además, cuando un hilo roba una tarea y no hay otras tareas disponibles, el hilo entrará en un estado bloqueado esperando "funcionar" nuevamente.

Implementación específica de Fork/Join

Anteriormente dijimos que el marco Fork/Join es simplemente la división de tareas y la fusión de subtareas, por lo que para implementar este marco, primero debe tener tareas. La clase abstracta ForkJoinTask se proporciona en el marco Fork/Join para implementar tareas.

1. Tarea de unión de horquilla

ForkJoinTask es una entidad similar a un hilo normal, pero mucho más ligera que un hilo normal.

Método fork(): envía tareas de forma asincrónica utilizando subprocesos inactivos en el grupo de subprocesos

// 本文所有代码都引自Java 8
public final ForkJoinTask<V> fork() {
    
    
    Thread t;
    // ForkJoinWorkerThread是执行ForkJoinTask的专有线程,由ForkJoinPool管理
    // 先判断当前线程是否是ForkJoin专有线程,如果是,则将任务push到当前线程所负责的队列里去
    if ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread)
        ((ForkJoinWorkerThread)t).workQueue.push(this);
    else
         // 如果不是则将线程加入队列
        // 没有显式创建ForkJoinPool的时候走这里,提交任务到默认的common线程池中
        ForkJoinPool.common.externalPush(this);
    return this;
}

De hecho, fork() solo hace una cosa: enviar la tarea a la cola de trabajo del hilo de trabajo actual.

Método join(): espere a que el subproceso que procesa la tarea complete el procesamiento y obtenga el valor de retorno.

Mira el código fuente de join():

public final V join() {
    
    
    int s;
    // doJoin()方法来获取当前任务的执行状态
    if ((s = doJoin() & DONE_MASK) != NORMAL)
        // 任务异常,抛出异常
        reportException(s);
    // 任务正常完成,获取返回值
    return getRawResult();
}

/**
 * doJoin()方法用来返回当前任务的执行状态
 **/
private int doJoin() {
    
    
    int s; Thread t; ForkJoinWorkerThread wt; ForkJoinPool.WorkQueue w;
    // 先判断任务是否执行完毕,执行完毕直接返回结果(执行状态)
    return (s = status) < 0 ? s :
    // 如果没有执行完毕,先判断是否是ForkJoinWorkThread线程
    ((t = Thread.currentThread()) instanceof ForkJoinWorkerThread) ?
        // 如果是,先判断任务是否处于工作队列顶端(意味着下一个就执行它)
        // tryUnpush()方法判断任务是否处于当前工作队列顶端,是返回true
        // doExec()方法执行任务
        (w = (wt = (ForkJoinWorkerThread)t).workQueue).
        // 如果是处于顶端并且任务执行完毕,返回结果
        tryUnpush(this) && (s = doExec()) < 0 ? s :
        // 如果不在顶端或者在顶端却没未执行完毕,那就调用awitJoin()执行任务
        // awaitJoin():使用自旋使任务执行完成,返回结果
        wt.pool.awaitJoin(w, this, 0L) :
    // 如果不是ForkJoinWorkThread线程,执行externalAwaitDone()返回任务结果
    externalAwaitDone();
}

Hemos introducido antes que Thread.join() bloqueará el hilo y ForkJoinPool.join() evitará que el hilo se bloquee. El siguiente es el diagrama de flujo de ForkJoinPool.join(): diagrama de flujo de unión
Insertar descripción de la imagen aquí

Acción recursiva y tarea recursiva

Normalmente, al crear una tarea, generalmente no heredamos directamente ForkJoinTask, sino que heredamos sus subclases RecursiveAction y RecursiveTask .

Ambas son subclases de ForkJoinTask**: RecursiveAction puede considerarse como un ForkJoinTask sin valor de retorno, y RecursiveTask es un ForkJoinTask** con valor de retorno.

Además, ambas subclases tienen el método Compute() que realiza los cálculos principales. Por supuesto, Compute() de RecursiveAction devuelve void y Compute() de RecursiveTask tiene un valor de retorno específico.

2、BifurcaciónJoinPool

ForkJoinPool es un grupo de ejecución (subprocesos) que se utiliza para ejecutar tareas de ForkJoinTask.

ForkJoinPool administra los subprocesos y las colas de tareas en el grupo de ejecución. Además, aquí también se procesa si el grupo de ejecución aún acepta tareas y muestra el estado de ejecución del subproceso.

Echemos un vistazo al código fuente de ForkJoinPool:

@sun.misc.Contended
public class ForkJoinPool extends AbstractExecutorService {
    
    
    // 任务队列
    volatile WorkQueue[] workQueues;   

    // 线程的运行状态
    volatile int runState;  

    // 创建ForkJoinWorkerThread的默认工厂,可以通过构造函数重写
    public static final ForkJoinWorkerThreadFactory defaultForkJoinWorkerThreadFactory;

    // 公用的线程池,其运行状态不受shutdown()和shutdownNow()的影响
    static final ForkJoinPool common;

    // 私有构造方法,没有任何安全检查和参数校验,由makeCommonPool直接调用
    // 其他构造方法都是源自于此方法
    // parallelism: 并行度,
    // 默认调用java.lang.Runtime.availableProcessors() 方法返回可用处理器的数量
    private ForkJoinPool(int parallelism,
                         ForkJoinWorkerThreadFactory factory, // 工作线程工厂
                         UncaughtExceptionHandler handler, // 拒绝任务的handler
                         int mode, // 同步模式
                         String workerNamePrefix) {
    
     // 线程名prefix
        this.workerNamePrefix = workerNamePrefix;
        this.factory = factory;
        this.ueh = handler;
        this.config = (parallelism & SMASK) | mode;
        long np = (long)(-parallelism); // offset ctl counts
        this.ctl = ((np << AC_SHIFT) & AC_MASK) | ((np << TC_SHIFT) & TC_MASK);
    }

}

Cola de trabajo

Cola de doble extremo, ForkJoinTask se almacena aquí.

Cuando un subproceso de trabajo está procesando su propia cola de trabajos, tomará tareas del principio de la cola para su ejecución (FIFO); si roba tareas de otras colas, las tareas robadas se ubicarán al final de la cola de tareas a la que pertenece (LIFO).

La diferencia más significativa entre ForkJoinPool y los grupos de subprocesos tradicionales es que mantiene una variedad de colas de trabajo (WorkQueues volátiles [] workQueues, cada subproceso de trabajo en ForkJoinPool mantiene una cola de trabajo).

estado de ejecución

El estado de ejecución de ForkJoinPool. El estado APAGADO está representado por un número negativo y los demás están representados por potencias de 2.

4. Uso de bifurcación/unión

Dijimos anteriormente que ForkJoinPool es responsable de administrar subprocesos y tareas, y ForkJoinTask implementa operaciones de bifurcación y unión, por lo que estas dos clases son indispensables para usar el marco Fork/Join. Sin embargo, en el desarrollo real, a menudo usamos las subclases de ForkJoinTask, RecursiveTask y RecursiveAction. .ForkJoinTask.

Veamos el uso de Fork/Join con un ejemplo de cálculo del enésimo término de la secuencia de Fibonacci:

La secuencia de Fibonacci es una secuencia lineal recursiva, que comienza con el tercer término, donde el valor de cada término es igual a la suma de los dos términos anteriores:

1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89······

Si se supone que f(n) es el enésimo elemento de la secuencia (n∈N*), entonces existe: f(n) = f(n-1) + f(n-2).

public class FibonacciTest {
    
    

    class Fibonacci extends RecursiveTask<Integer> {
    
    

        int n;

        public Fibonacci(int n) {
    
    
            this.n = n;
        }

        // 主要的实现逻辑都在compute()里
        @Override
        protected Integer compute() {
    
    
            // 这里先假设 n >= 0
            if (n <= 1) {
    
    
                return n;
            } else {
    
    
                // f(n-1)
                Fibonacci f1 = new Fibonacci(n - 1);
                f1.fork();
                // f(n-2)
                Fibonacci f2 = new Fibonacci(n - 2);
                f2.fork();
                // f(n) = f(n-1) + f(n-2)
                return f1.join() + f2.join();
            }
        }
    }

    @Test
    public void testFib() throws ExecutionException, InterruptedException {
    
    
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        System.out.println("CPU核数:" + Runtime.getRuntime().availableProcessors());
        long start = System.currentTimeMillis();
        Fibonacci fibonacci = new Fibonacci(40);
        Future<Integer> future = forkJoinPool.submit(fibonacci);
        System.out.println(future.get());
        long end = System.currentTimeMillis();
        System.out.println(String.format("耗时:%d millis", end - start));
    }


}

El resultado del ejemplo anterior en esta máquina:

Número de núcleos de CPU: 4
Resultado del cálculo: 102334155
Consumo de tiempo: 9490 milisegundos

Cabe señalar que la complejidad del tiempo de cálculo anterior es O (2 ^ n), y la eficiencia del cálculo será cada vez menor a medida que aumente n, es por eso que n no se atreve a ser demasiado grande en el ejemplo anterior.

Además, no todas las tareas son adecuadas para el marco Fork/Join. Por ejemplo, la división de tareas en el ejemplo anterior es demasiado pequeña y no refleja eficiencia. Intentemos utilizar la recursividad ordinaria para encontrar el valor de f(n) para mira si es necesario. Más rápido que usar Fork/Join:

// 普通递归,复杂度为O(2^n)
public int plainRecursion(int n) {
    
    
    if (n == 1 || n == 2) {
    
    
        return 1;
    } else {
    
    
        return plainRecursion(n -1) + plainRecursion(n - 2);
    }
}

@Test
public void testPlain() {
    
    
    long start = System.currentTimeMillis();
    int result = plainRecursion(40);
    long end = System.currentTimeMillis();
    System.out.println("计算结果:" + result);
    System.out.println(String.format("耗时:%d millis",  end -start));
}

Ejemplo de salida de recursividad ordinaria:

Resultado del cálculo: 102334155
Consumo de tiempo: 436 milisegundos
Se puede ver claramente en el resultado que la eficiencia de usar la recursividad ordinaria es mucho mayor que usar el marco Fork/Join.

Aquí usamos otra forma de pensar para calcular:

// 通过循环来计算,复杂度为O(n)
private int computeFibonacci(int n) {
    
    
    // 假设n >= 0
    if (n <= 1) {
    
    
        return n;
    } else {
    
    
        int first = 1;
        int second = 1;
        int third = 0;
        for (int i = 3; i <= n; i ++) {
    
    
            // 第三个数是前两个数之和
            third = first + second;
            // 前两个数右移
            first = second;
            second = third;
        }
        return third;
    }
}

@Test
public void testComputeFibonacci() {
    
    
    long start = System.currentTimeMillis();
    int result = computeFibonacci(40);
    long end = System.currentTimeMillis();
    System.out.println("计算结果:" + result);
    System.out.println(String.format("耗时:%d millis",  end -start));
}

El resultado del ejemplo anterior en la computadora utilizada por el autor es:

Resultado del cálculo: 102334155
Consumo de tiempo: 0 milisegundos

El consumo de tiempo de 0 aquí no significa que no haya consumo de tiempo. Significa que el consumo de tiempo del cálculo aquí es casi insignificante. Puede probarlo en su propia computadora. Incluso si n es mucho mayor que los datos (nota El problema del desbordamiento int), el consumo de tiempo es muy alto y el tiempo también es muy corto, o puede usar System.nanoTime() para contar el tiempo en nanosegundos.

¿Por qué la recursividad ordinaria o el bucle son más rápidos aquí? Debido a que Fork/Join se calcula utilizando la cooperación de múltiples subprocesos, habrá una sobrecarga de comunicación y cambio de subprocesos.

Si la tarea a calcular es relativamente simple (como la secuencia de Fibonacci en nuestro caso), entonces, por supuesto, será más rápido usar un solo hilo directamente. Pero si las cosas a calcular son relativamente complejas y la computadora tiene múltiples núcleos, puede aprovechar al máximo la CPU de múltiples núcleos para aumentar la velocidad de cálculo.

Además, la operación paralela subyacente de Java 8 Stream utiliza el marco Fork/Join. En el próximo capítulo, presentaremos la operación paralela de Java 8 Stream desde el código fuente y los casos.

Supongo que te gusta

Origin blog.csdn.net/weixin_52315708/article/details/131771243
Recomendado
Clasificación