El objetivo final de los grupos de subprocesos y Fork / Join es similar: ambos quieren utilizar la potencia de CPU disponible lo mejor que puedan para obtener el máximo rendimiento. El rendimiento máximo significa que se deben completar tantas tareas como sea posible en un largo período de tiempo. ¿Qué se necesita para hacer eso? (Para lo siguiente asumiremos que no faltan las tareas de cálculo: siempre hay suficiente para hacer una utilización del 100% de la CPU. Además, uso "CPU" de manera equivalente para núcleos o núcleos virtuales en caso de hiperprocesamiento).
- Al menos debe haber tantos subprocesos en ejecución como CPU disponibles, porque ejecutar menos subprocesos dejará un núcleo sin usar.
- Como máximo, debe haber tantos subprocesos en ejecución como CPU disponibles, porque la ejecución de más subprocesos creará una carga adicional para el Programador que asigna CPU a los diferentes subprocesos, lo que hace que algo de tiempo de CPU vaya al programador en lugar de nuestra tarea computacional.
Por lo tanto, descubrimos que para obtener el máximo rendimiento necesitamos tener exactamente el mismo número de subprocesos que las CPU. En el ejemplo borroso de Oracle, ambos pueden tomar un grupo de subprocesos de tamaño fijo con el número de subprocesos igual al número de CPU disponibles o utilizar un grupo de subprocesos. No hará la diferencia, tienes razón!
Entonces, ¿cuándo te meterás en problemas con un grupo de subprocesos? Eso es si un hilo se bloquea , porque su hilo está esperando que se complete otra tarea. Supongamos el siguiente ejemplo:
class AbcAlgorithm implements Runnable {
public void run() {
Future<StepAResult> aFuture = threadPool.submit(new ATask());
StepBResult bResult = stepB();
StepAResult aResult = aFuture.get();
stepC(aResult, bResult);
}
}
Lo que vemos aquí es un algoritmo que consta de tres pasos A, B y C. A y B pueden realizarse independientemente uno del otro, pero el paso C necesita el resultado de los pasos A y B. Lo que hace este algoritmo es enviar la tarea A a el conjunto de subprocesos y realizar la tarea b directamente. Después de eso, el subproceso esperará a que se realice la tarea A también y continuará con el paso C. Si A y B se completan al mismo tiempo, entonces todo está bien. Pero, ¿qué pasa si A tarda más que B? Esto puede deberse a que la naturaleza de la tarea A lo dicta, pero también puede ser el caso porque no hay un hilo para la tarea A disponible al principio y la tarea A debe esperar. (Si solo hay una única CPU disponible y, por lo tanto, su grupo de subprocesos tiene solo un solo subproceso, esto incluso provocará un punto muerto, pero por ahora eso está fuera del punto). El punto es que el hilo que acaba de ejecutar la tarea Bbloquea todo el hilo . Como tenemos el mismo número de subprocesos que las CPU y un subproceso está bloqueado, eso significa que una CPU está inactiva .
Fork / Join resuelve este problema: en el framework fork / join escribirías el mismo algoritmo de la siguiente manera:
class AbcAlgorithm implements Runnable {
public void run() {
ATask aTask = new ATask());
aTask.fork();
StepBResult bResult = stepB();
StepAResult aResult = aTask.join();
stepC(aResult, bResult);
}
}
Se ve igual, ¿no? Sin embargo, la pista es que aTask.join
no se bloqueará . En cambio, aquí es donde entra en juego el robo de trabajo : el hilo buscará otras tareas que se bifurcaron en el pasado y continuará con ellas. Primero verifica si las tareas que se bifurcó se han comenzado a procesar. Entonces, si A no ha sido iniciado por otro hilo todavía, hará A a continuación, de lo contrario verificará la cola de otros hilos y robará su trabajo. Una vez que se haya completado esta otra tarea de otro subproceso, comprobará si A se ha completado ahora. Si es el algoritmo anterior puede llamar stepC
. De lo contrario, buscará otra tarea más para robar. Por lo tanto, los grupos fork / join pueden lograr un 100% de utilización de la CPU, incluso frente a acciones de bloqueo .
Sin embargo, hay una trampa: el robo de trabajo solo es posible para la join
llamada de ForkJoinTask
s. No se puede hacer para acciones de bloqueo externo como esperar otro subproceso o esperar una acción de E / S. Entonces, ¿qué pasa con eso, esperar a que se complete la E / S es una tarea común? En este caso, si pudiéramos agregar un hilo adicional al grupo Fork / Join que se detendrá nuevamente tan pronto como se complete la acción de bloqueo, será la segunda mejor opción. Y en ForkJoinPool
realidad puede hacer eso si estamos usando ManagedBlocker
s.
Fibonacci
En JavaDoc para RecursiveTask hay un ejemplo para calcular números de Fibonacci usando Fork / Join. Para una solución recursiva clásica ver:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
Como se explica en JavaDocs, esta es una forma bastante simple de calcular los números de Fibonacci, ya que este algoritmo tiene una complejidad O (2 ^ n), mientras que las formas más simples son posibles. Sin embargo, este algoritmo es muy simple y fácil de entender, por lo que nos atenemos a él. Supongamos que queremos acelerar esto con Fork / Join. Una implementación ingenua se vería así:
class Fibonacci extends RecursiveTask<Long> {
private final long n;
Fibonacci(long n) {
this.n = n;
}
public Long compute() {
if (n <= 1) {
return n;
}
Fibonacci f1 = new Fibonacci(n - 1);
f1.fork();
Fibonacci f2 = new Fibonacci(n - 2);
return f2.compute() + f1.join();
}
}
Los pasos en los que se divide esta Tarea son demasiado cortos y, por lo tanto, funcionarán horriblemente, pero puede ver cómo el marco generalmente funciona muy bien: los dos sumandos se pueden calcular de forma independiente, pero luego los necesitamos para construir el final resultado. Entonces la mitad se hace en otro hilo. Diviértete haciendo lo mismo con los grupos de subprocesos sin llegar a un punto muerto (posible, pero no tan simple).
Solo para completar: si realmente desea calcular los números de Fibonacci utilizando este enfoque recursivo, aquí hay una versión optimizada:
class FibonacciBigSubtasks extends RecursiveTask<Long> {
private final long n;
FibonacciBigSubtasks(long n) {
this.n = n;
}
public Long compute() {
return fib(n);
}
private long fib(long n) {
if (n <= 1) {
return 1;
}
if (n > 10 && getSurplusQueuedTaskCount() < 2) {
final FibonacciBigSubtasks f1 = new FibonacciBigSubtasks(n - 1);
final FibonacciBigSubtasks f2 = new FibonacciBigSubtasks(n - 2);
f1.fork();
return f2.compute() + f1.join();
} else {
return fib(n - 1) + fib(n - 2);
}
}
}
Esto mantiene las subtareas mucho más pequeñas porque solo se dividen cuando n > 10 && getSurplusQueuedTaskCount() < 2
es verdadero, lo que significa que hay significativamente más de 100 llamadas a métodos para hacer ( n > 10
) y no hay muchas tareas de hombre esperando ( getSurplusQueuedTaskCount() < 2
).
En mi computadora (4 núcleos (8 cuando se cuenta Hyper-threading), Intel (R) Core (TM) i7-2720QM CPU @ 2.20GHz) fib(50)
toma 64 segundos con el enfoque clásico y solo 18 segundos con el enfoque Fork / Join que es una ganancia bastante notable, aunque no tanto como teóricamente posible.
Resumen
- Sí, en su ejemplo, Fork / Join no tiene ninguna ventaja sobre los grupos de subprocesos clásicos.
- Fork / Join puede mejorar drásticamente el rendimiento cuando el bloqueo está involucrado
- Fork / Join evita algunos problemas de punto muerto