Le but ultime des pools de threads et de Fork / Join est le même: les deux veulent utiliser au mieux la puissance CPU disponible pour un débit maximal. Le débit maximal signifie que le plus grand nombre de tâches possible doit être achevé sur une longue période. Que faut-il pour cela? (Pour ce qui suit, nous supposerons que les tâches de calcul ne manquent pas: il y a toujours assez à faire pour une utilisation à 100% du processeur. De plus, j'utilise de manière équivalente "CPU" pour les cœurs ou les cœurs virtuels en cas d'hyper-threading).
- Au moins, il doit y avoir autant de threads en cours d'exécution que de processeurs disponibles, car exécuter moins de threads laissera un cœur inutilisé.
- Au maximum, il doit y avoir autant de threads en cours d'exécution qu'il y a de processeurs disponibles, car l'exécution de plus de threads créera une charge supplémentaire pour le planificateur qui attribue des CPU aux différents threads, ce qui fait passer du temps CPU au planificateur plutôt qu'à notre tâche de calcul.
Ainsi, nous avons compris que pour un débit maximal, nous devons avoir exactement le même nombre de threads que les processeurs. Dans l'exemple de flou d'Oracle, vous pouvez à la fois prendre un pool de threads de taille fixe avec le nombre de threads égal au nombre de processeurs disponibles ou utiliser un pool de threads. Cela ne fera aucune différence, vous avez raison!
Alors, quand aurez-vous des problèmes avec un pool de threads? C'est si un thread se bloque , car votre thread attend qu'une autre tâche se termine. Supposons l'exemple suivant:
class AbcAlgorithm implements Runnable {
public void run() {
Future<StepAResult> aFuture = threadPool.submit(new ATask());
StepBResult bResult = stepB();
StepAResult aResult = aFuture.get();
stepC(aResult, bResult);
}
}
Ce que nous voyons ici est un algorithme qui se compose de trois étapes A, B et C.A et B peuvent être exécutées indépendamment l'une de l'autre, mais l'étape C a besoin du résultat des étapes A ET B.Ce que cet algorithme fait est de soumettre la tâche A à le threadpool et exécutez la tâche b directement. Après cela, le thread attendra que la tâche A soit également effectuée et continue avec l'étape C. Si A et B sont terminés en même temps, tout va bien. Mais que faire si A prend plus de temps que B? Cela peut être dû au fait que la nature de la tâche A l'exige, mais cela peut également être le cas car il n'y a pas de thread pour la tâche A disponible au début et la tâche A doit attendre. (S'il n'y a qu'un seul processeur disponible et que votre threadpool n'a donc qu'un seul thread, cela entraînera même un blocage, mais pour le moment, cela n'a pas d'importance). Le fait est que le thread qui vient d'exécuter la tâche Bbloque tout le fil . Comme nous avons le même nombre de threads que les processeurs et qu'un thread est bloqué, cela signifie qu'un processeur est inactif .
Fork / Join résout ce problème: dans le framework fork / join, vous écririez le même algorithme comme suit:
class AbcAlgorithm implements Runnable {
public void run() {
ATask aTask = new ATask());
aTask.fork();
StepBResult bResult = stepB();
StepAResult aResult = aTask.join();
stepC(aResult, bResult);
}
}
Ça a l'air pareil, n'est-ce pas? Cependant, l'indice est que aTask.join
cela ne bloquera pas . Au lieu de cela, c'est là que le vol de travail entre en jeu: le fil cherchera d'autres tâches qui ont été fourchues dans le passé et continuera avec celles-ci. Tout d'abord, il vérifie si les tâches qu'il a lui-même fourchues ont commencé à être traitées. Donc, si A n'a pas encore été démarré par un autre thread, il fera A ensuite, sinon il vérifiera la file d'attente des autres threads et leur volera leur travail. Une fois cette autre tâche d'un autre thread terminée, il vérifiera si A est terminé maintenant. Si c'est l'algorithme ci-dessus peut appeler stepC
. Sinon, il cherchera encore une autre tâche à voler. Ainsi, les pools fork / join peuvent atteindre 100% d'utilisation du processeur, même face à des actions de blocage .
Cependant, il y a un piège: le vol de travail n'est possible que pour l' join
appel de l' ForkJoinTask
art. Cela ne peut pas être fait pour des actions de blocage externes telles que l'attente d'un autre thread ou l'attente d'une action d'E / S. Alors qu'en est-il de cela, attendre la fin des E / S est une tâche courante? Dans ce cas, si nous pouvions ajouter un thread supplémentaire au pool Fork / Join qui sera à nouveau arrêté dès que l'action de blocage sera terminée, sera la deuxième meilleure chose à faire. Et le ForkJoinPool
peut réellement faire exactement cela si nous utilisons l' ManagedBlocker
art.
Fibonacci
Dans le JavaDoc pour RecursiveTask est un exemple de calcul des nombres de Fibonacci à l'aide de Fork / Join. Pour une solution récursive classique, voir:
public static int fib(int n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
Comme cela est expliqué dans les JavaDocs, c'est une jolie façon de calculer les nombres de fibonacci, car cet algorithme a une complexité O (2 ^ n) alors que des moyens plus simples sont possibles. Cependant, cet algorithme est très simple et facile à comprendre, nous nous en tenons donc à lui. Supposons que nous voulions accélérer cela avec Fork / Join. Une implémentation naïve ressemblerait à ceci:
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();
}
}
Les étapes dans lesquelles cette tâche est divisée sont beaucoup trop courtes et cela fonctionnera donc horriblement, mais vous pouvez voir comment le cadre fonctionne généralement très bien: les deux sommets peuvent être calculés indépendamment, mais nous avons alors besoin des deux pour construire le final. résultat. Donc, la moitié est faite dans un autre fil. Amusez-vous à faire de même avec des pools de threads sans vous bloquer (possible, mais pas aussi simple).
Juste pour être complet: Si vous souhaitez réellement calculer les nombres de Fibonacci en utilisant cette approche récursive, voici une version optimisée:
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);
}
}
}
Cela réduit considérablement les sous-tâches car elles ne sont fractionnées que lorsque n > 10 && getSurplusQueuedTaskCount() < 2
est vrai, ce qui signifie qu'il y a beaucoup plus de 100 appels de méthode à faire ( n > 10
) et qu'il n'y a pas de tâches man en attente ( getSurplusQueuedTaskCount() < 2
).
Sur mon ordinateur (4 cœurs (8 en comptant Hyper-threading), Intel (R) Core (TM) i7-2720QM CPU @ 2,20 GHz), cela fib(50)
prend 64 secondes avec l'approche classique et seulement 18 secondes avec l'approche Fork / Join qui est un gain tout à fait notable, mais pas autant que théoriquement possible.
Résumé
- Oui, dans votre exemple, Fork / Join n'a aucun avantage sur les pools de threads classiques.
- Fork / Join peut considérablement améliorer les performances en cas de blocage
- Fork / Join évite certains problèmes de blocage