web-dev-qa-db-fra.com

Comment le framework fork / join est-il meilleur qu'un pool de threads?

Quels sont les avantages de l’utilisation de la nouvelle structure fork/join par rapport à la division de la grosse tâche en N sous-tâches au début, en les envoyant à un pool de threads mis en cache (à partir de Executors ) et en attendant que chaque tâche soit terminée? Je ne vois pas en quoi l'utilisation de l'abstraction fork/join simplifie le problème ou rend la solution plus efficace par rapport à ce que nous avions depuis des années.

Par exemple, l’algorithme de flou parallélisé dans le exemple de didacticiel pourrait être mis en œuvre de la manière suivante:

public class Blur implements Runnable {
    private int[] mSource;
    private int mStart;
    private int mLength;
    private int[] mDestination;

    private int mBlurWidth = 15; // Processing window size, should be odd.

    public ForkBlur(int[] src, int start, int length, int[] dst) {
        mSource = src;
        mStart = start;
        mLength = length;
        mDestination = dst;
    }

    public void run() {
        computeDirectly();
    }

    protected void computeDirectly() {
        // As in the example, omitted for brevity
    }
}

Fractionner au début et envoyer des tâches à un pool de threads:

// source image pixels are in src
// destination image pixels are in dst
// threadPool is a (cached) thread pool

int maxSize = 100000; // analogous to F-J's "sThreshold"
List<Future> futures = new ArrayList<Future>();

// Send stuff to thread pool:
for (int i = 0; i < src.length; i+= maxSize) {
    int size = Math.min(maxSize, src.length - i);
    ForkBlur task = new ForkBlur(src, i, size, dst);
    Future f = threadPool.submit(task);
    futures.add(f);
}

// Wait for all sent tasks to complete:
for (Future future : futures) {
    future.get();
}

// Done!

Les tâches accèdent à la file d'attente du pool de threads, à partir desquelles elles sont exécutées lorsque les threads de travail deviennent disponibles. Tant que la scission est suffisamment granulaire (pour éviter d'attendre particulièrement la dernière tâche) et que le pool de threads contient suffisamment de threads (au moins N processeurs), tous les processeurs fonctionnent à pleine vitesse jusqu'à ce que le calcul complet soit terminé.

Est-ce que je manque quelque chose? Quelle est la valeur ajoutée de l’utilisation du framework fork/join?

123
Joonas Pulakka

Je pense que le malentendu fondamental est que les exemples Fork/Join ne font PAS montrer le travail voler mais seulement une sorte de division standard et conquérir.

Le vol de travail ressemblerait à ceci: le travailleur B a terminé son travail. Il est gentil, alors il regarde autour de lui et voit le travailleur A qui travaille toujours très dur. Il se promène et demande: "Hé mec, je pourrais te donner un coup de main." A répond "Cool, j'ai cette tâche de 1000 unités. Jusqu'à présent, j'ai fini 345, laissant 655. Pourriez-vous s'il vous plaît travailler sur les numéros 673 à 1000, je vais faire les 346 à 672." B dit "OK, commençons afin que nous puissions aller au pub plus tôt."

Vous voyez, les travailleurs doivent communiquer les uns avec les autres même lorsqu'ils ont commencé le vrai travail. C'est la partie manquante dans les exemples.

Les exemples d’autre part ne montrent que quelque chose comme "utiliser des sous-traitants":

Travailleur A: "Dang, j'ai 1000 unités de travail. Trop pour moi. Je vais en faire 500 moi-même et en sous-traiter 500 à quelqu'un d'autre." Cela continue jusqu'à ce que la grosse tâche soit décomposée en petits paquets de 10 unités chacun. Celles-ci seront exécutées par les ouvriers disponibles. Mais si un paquet est une sorte de pilule empoisonnée et prend beaucoup plus de temps que d’autres - malchance, la phase de division est terminée.

La seule différence qui reste entre Diviser/Joindre et diviser la tâche en amont est la suivante: lors du fractionnement en amont, la file d'attente de travail est pleine dès le début. Exemple: 1000 unités, le seuil est 10, la file d'attente compte donc 100 entrées. Ces paquets sont distribués aux membres du pool de threads.

Fork/Join est plus complexe et essaie de réduire le nombre de paquets dans la file d'attente:

  • Étape 1: Mettez un paquet contenant (1 ... 1000) en file d'attente
  • Étape 2: Un opérateur affiche le paquet (1 ... 1000) et le remplace par deux paquets: (1 ... 500) et (501 ... 1000).
  • Étape 3: Un travailleur affiche un paquet (500 ... 1000) et pousse (500 ... 750) et (751 ... 1000).
  • Étape n: La pile contient les paquets suivants: (1..500), (500 ... 750), (750 ... 875) ... (991..1000)
  • Etape n + 1: le paquet (991..1000) est sauté et exécuté
  • Étape n + 2: le paquet (981..990) est affiché et exécuté.
  • Étape n + 3: le paquet (961..980) est éclaté et divisé en (961 ... 970) et (971..980). ....

Vous voyez: dans Fork/Join, la file d'attente est plus petite (6 dans l'exemple) et les phases "scission" et "travail" sont entrelacées.

Lorsque plusieurs travailleurs sautent et poussent simultanément, les interactions ne sont pas aussi claires.

129
A.H.

Si n threads occupés travaillent tous à 100% de manière indépendante, cela sera meilleur que n threads dans un pool Fork-Join (FJ). Mais ça ne marche jamais comme ça.

Il pourrait ne pas être en mesure de scinder le problème avec précision en n parties égales. Même si vous le faites, la planification des threads est un peu juste. Vous finirez par attendre le fil le plus lent. Si vous avez plusieurs tâches, elles peuvent toutes être exécutées avec un parallélisme inférieur à n-way (généralement plus efficace), tout en allant jusqu'à n-way lorsque les autres tâches sont terminées.

Alors pourquoi ne pas couper le problème en morceaux de la taille d'un FJ et laisser un pool de threads travailler dessus. L'utilisation typique de FJ réduit le problème en minuscules morceaux. Faire cela dans un ordre aléatoire nécessite beaucoup de coordination au niveau du matériel. Les frais généraux seraient un tueur. Dans FJ, les tâches sont placées dans une file d'attente que le thread lit dans l'ordre dernier entré premier sorti (LIFO/pile) et le vol de travail (généralement dans le travail de base) est effectué premier entré premier sorti (FIFO/"file d'attente"). Le résultat est que le traitement de matrice longue peut être effectué en grande partie séquentiellement, même s'il est divisé en minuscules morceaux. (Il est également vrai qu'il ne serait peut-être pas banal de diviser le problème en petits morceaux de taille égale et homogène en une seule grosse explosion. Dites que vous devez traiter avec une certaine forme de hiérarchie sans équilibrer.)

Conclusion: FJ permet une utilisation plus efficace des threads matériels dans des situations inégales, ce qui sera toujours le cas si vous avez plusieurs threads.

25

L’objectif ultime des pools de threads et de Fork/Join est identique: ils souhaitent tous deux utiliser au mieux la puissance de calcul disponible pour un débit maximal. Le débit maximum signifie que le plus grand nombre de tâches possible doit être effectué sur une longue période. Que faut-il pour faire ça? (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 "CPU" de manière équivalente pour les cœurs ou les cœurs virtuels en cas d'hyper-threading).

  1. Au moins, il faut qu'il y ait autant de threads en cours d'exécution que de processeurs disponibles, car en exécutant moins de threads, le cœur ne sera pas utilisé.
  2. Au maximum, il doit y avoir autant de threads en cours d’exécution que de processeurs disponibles, car l’exécution de plusieurs threads créera une charge supplémentaire pour le planificateur qui assigne les processeurs aux différents threads, ce qui a pour effet de laisser un peu de temps CPU au programmateur plutôt que 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 de processeurs. Dans l'exemple flou d'Oracle, vous pouvez utiliser un pool de threads de taille fixe avec un nombre de threads égal au nombre de CPU 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-à-dire si un thread bloque , car votre thread attend la fin d'une autre tâche. 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);
    }
}

Nous voyons ici un algorithme composé de trois étapes A, B et C. A et B peuvent être exécutés indépendamment l'un de l'autre, mais l'étape C a besoin du résultat de l'étape A AND B. Le but de cet algorithme est de soumettre la tâche A à le pool de threads et effectuer la tâche b directement. Après cela, le thread attendra que la tâche A soit également exécutée et continue à 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 le dicte, mais cela peut également être le cas, car il n’existe pas de fil 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 pool de threads ne possède qu'un seul thread, cela entraînera même un blocage, mais pour l'instant, c'est en dehors du point). Le fait est que le thread qui vient d'exécuter la tâche B bloque l'ensemble du thread . Comme nous avons le même nombre de threads que de 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);
    }
}

On dirait la même chose, n'est-ce pas? Cependant, l'indice est que aTask.join ne bloquera pas . Au lieu de cela, voici où le vol de travail entre en jeu: le fil de discussion examinera les autres tâches qui ont été fourchues dans le passé et les poursuivra. En premier lieu, il vérifie si les tâches qu'il s'est lui-même définies ont commencé le traitement. 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 que cette autre tâche d'un autre thread est terminée, il vérifiera si A est terminé maintenant. Si c'est le cas, l'algorithme ci-dessus peut appeler stepC. Sinon, il cherchera encore une autre tâche à voler. Ainsi les pools de jonction/jointure peuvent atteindre une utilisation de 100% de la CPU, même en cas de blocage .

Cependant, il existe un piège: le vol de travail n’est possible que pour l’appel join de ForkJoinTasks. Cela ne peut pas être fait pour des actions de blocage externes telles qu'attendre un autre thread ou attendre une action d'E/S. Alors qu'en est-il, attendre la fin des entrées/sorties est une tâche courante? Dans ce cas, si nous pouvions ajouter un thread supplémentaire au pool Fork/Join, celui-ci sera arrêté de nouveau dès que l'action de blocage sera terminée sera la deuxième meilleure chose à faire. Et le ForkJoinPool peut le faire si nous utilisons ManagedBlockers.

Fibonacci

Dans le JavaDoc for RecursiveTask , vous trouverez un exemple de calcul des nombres Fibonacci à l’aide de Fork/Join. Pour une solution classique récursive, voir:

public static int fib(int n) {
    if (n <= 1) {
        return n;
    }
    return fib(n - 1) + fib(n - 2);
}

Comme expliqué dans les JavaDocs, il s’agit là d’un moyen assez simple de calculer les nombres de fibonacci, car cet algorithme présente une complexité de O (2 ^ n), bien que des méthodes plus simples soient possibles. Cependant, cet algorithme est très simple et facile à comprendre, nous nous en tenons à cela. Supposons que nous souhaitons accélérer le processus 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 scindée sont trop courtes et donc cela fonctionnera 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 ensuite besoin des deux pour construire la version finale. résultat. Donc, une moitié est faite dans un autre thread. Amusez-vous à faire de même avec les pools de threads sans obtenir une impasse (possible, mais pas aussi simple).

Juste pour compléter: si vous voulez 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 permet de garder les sous-tâches beaucoup plus petites car elles ne sont divisées que lorsque n > 10 && getSurplusQueuedTaskCount() < 2 est vraie, ce qui signifie qu'il y a beaucoup plus de 100 appels de méthode à effectuer (n > 10) Et qu'il n'y a pas beaucoup de tâches man déjà en attente (getSurplusQueuedTaskCount() < 2).

Sur mon ordinateur (4 coeurs (8 en comptant l’hyper-threading), processeur Intel i7-2720QM Intel (R) Core (TM) à 2,20 GHz), la fib(50) prend 64 secondes avec l’approche classique et seulement 18 secondes. avec l’approche Fork/Join qui est un gain assez notable, bien que pas autant que théoriquement possible.

Sommaire

  • 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 élimine certains problèmes de blocage
15
yankee

La branche/jointure est différente d'un pool de threads car elle implémente le vol de travail. De Fork/Join

Comme avec tout service d'exécution, la structure fork/join distribue les tâches aux threads de travail d'un pool de threads. La structure fork/join est distincte car elle utilise un algorithme de vol de travail. Les threads de travail qui n'ont plus rien à faire peuvent voler des tâches à d'autres threads encore occupés.

Supposons que vous avez deux threads et 4 tâches a, b, c, d qui prennent respectivement 1, 1, 5 et 6 secondes. Initialement, a et b sont affectés au thread 1 et c et d au thread 2. Dans un pool de threads, cela prend 11 secondes. Avec fork/join, le thread 1 se termine et peut voler du travail au thread 2; la tâche d sera donc exécutée par le thread 1. Le thread 1 exécute a, b et d, le thread 2 juste c. Temps total: 8 secondes, pas 11.

EDIT: Comme le souligne Joonas, les tâches ne sont pas nécessairement pré-allouées à un thread. L'idée de fork/join est qu'un thread peut choisir de scinder une tâche en plusieurs sous-morceaux. Donc, pour reformuler ce qui précède:

Nous avons deux tâches (ab) et (cd) qui prennent respectivement 2 et 11 secondes. Le fil 1 commence à s'exécuter et se scinde en deux sous-tâches a et b. De même avec le fil 2, il se scinde en deux sous-tâches c & d. Lorsque le fil 1 a terminé a & b, il peut voler d du fil 2.

13
Matthew Farwell

Tout le monde ci-dessus a raison, les avantages sont obtenus par le vol de travail, mais expliquez pourquoi.

Le principal avantage est la coordination efficace entre les threads de travail. Le travail doit être scindé et réassemblé, ce qui nécessite une coordination. Comme vous pouvez le voir dans la réponse de A.H ci-dessus, chaque fil a sa propre liste de travail. Une propriété importante de cette liste est qu'elle est triée (grandes tâches en haut et petites tâches en bas). Chaque thread exécute les tâches au bas de sa liste et vole les tâches au sommet des autres listes de threads.

Le résultat de ceci est:

  • L'en-tête et la fin des listes de tâches peuvent être synchronisés indépendamment, ce qui réduit les conflits sur la liste.
  • Les sous-arbres importants du travail sont scindés et réassemblés par le même fil, de sorte qu'aucune coordination inter-thread n'est requise pour ces sous-arbres.
  • Lorsqu'un fil vole son travail, il prend un gros morceau qu'il divise ensuite en une liste.
  • L'acier de travail signifie que les fils sont presque entièrement utilisés jusqu'à la fin du processus.

La plupart des autres systèmes de division et de conquête utilisant des pools de threads nécessitent davantage de communication et de coordination inter-thread.

12
iain

Dans cet exemple, Fork/Join n'ajoute aucune valeur car le forking n'est pas nécessaire et la charge de travail est répartie de manière égale entre les threads de travail. Fork/Join ajoute uniquement des frais généraux.

Voici un article de Nice sur le sujet. Citation:

Globalement, nous pouvons dire que ThreadPoolExecutor doit être préféré lorsque la charge de travail est répartie de manière égale sur plusieurs threads de travail. Pour pouvoir garantir cela, vous devez savoir précisément à quoi ressemblent les données d'entrée. En revanche, ForkJoinPool offre de bonnes performances quelles que soient les données d'entrée et constitue donc une solution nettement plus robuste.

11
volley

Une autre différence importante semble être que, avec F-J, vous pouvez effectuer plusieurs phases complexes "Joindre". Considérez le type de fusion de http://faculty.ycp.edu/~dhovemey/spring2011/cs365/lecture/lecture18.html , il faudrait trop d'orchestration pour pré-scinder ce travail. par exemple. Vous devez faire les choses suivantes:

  • trier le premier trimestre
  • trier le deuxième trimestre
  • fusionner les 2 premiers trimestres
  • trier le troisième trimestre
  • trier le quatrième trimestre
  • fusionner les 2 derniers trimestres
  • fusionner les 2 moitiés

Comment spécifiez-vous que vous devez faire les tris avant les fusions qui les concernent, etc.

J'ai cherché comment faire au mieux une certaine chose pour chacun des éléments d'une liste. Je pense que je vais simplement pré-scinder la liste et utiliser un ThreadPool standard. F-J semble plus utile lorsque le travail ne peut pas être pré-divisé en suffisamment de tâches indépendantes mais peut être divisé de manière récursive en tâches indépendantes les unes des autres (par exemple, le tri des moitiés est indépendant mais la fusion des 2 moitiés triées en un tout trié ne l'est pas).

8
ashirley

F/J présente également un avantage distinct lorsque les opérations de fusion sont coûteuses. Comme il se scinde en une arborescence, vous ne faites que fusionner log2 (n), par opposition à n fusionner avec le fractionnement de threads linéaires. (Cela présuppose théoriquement que vous avez autant de processeurs que de threads, mais qu'il s'agit toujours d'un avantage). Pour un devoir, nous avons dû fusionner plusieurs milliers de tableaux 2D (toutes les mêmes dimensions) en faisant la somme des valeurs de chaque index. Avec les processus fork join et P, le temps approche log2 (n) lorsque P approche l'infini.

1 2 3 .. 7 3 1 .... 8 5 4
4 5 6 + 2 4 3 => 6 9 9
7 8 9 .. 1 1 0 .... 8 9 9

6
Daemon Fisher

Si le problème est tel que nous devons attendre que les autres threads soient terminés (comme dans le cas du tri d'un tableau ou de la somme d'un tableau), la jointure fork doit être utilisée, car Executor (Executors.newFixedThreadPool (2)) s'étouffera en raison de contraintes limitées. le nombre de fils. Le pool forkjoin créera plus de threads dans ce cas pour couvrir le thread bloqué afin de maintenir le même parallélisme

Source: http://www.Oracle.com/technetwork/articles/Java/fork-join-422606.html

Le problème rencontré par les exécuteurs pour la mise en œuvre des algorithmes de division et de conquête n’est pas lié à la création de sous-tâches, car Callable est libre de soumettre une nouvelle sous-tâche à son exécuteur et d’attendre son résultat de manière synchrone ou asynchrone. Le problème est celui du parallélisme: lorsqu'un objet Callable attend le résultat d'un autre objet Callable, il est placé dans un état d'attente, perdant ainsi une opportunité de gérer un autre objet Callable en file d'attente pour exécution.

La structure fork/join ajoutée au package Java.util.concurrent dans Java SE 7 grâce aux efforts de Doug Lea comble cette lacune

Source: https://docs.Oracle.com/javase/7/docs/api/Java/util/concurrent/ForkJoinPool. html

Le pool tente de maintenir un nombre suffisant de threads actifs (ou disponibles) en ajoutant, suspendant ou reprenant dynamiquement des threads de travail internes, même si certaines tâches sont bloquées en attente d'en rejoindre d'autres. Cependant, aucun ajustement de ce type n’est garanti en cas de blocage IO bloqué ou de toute autre synchronisation non gérée).

public int getPoolSize () Retourne le nombre de threads de travail qui ont commencé mais ne sont pas encore terminés. Le résultat renvoyé par cette méthode peut différer de getParallelism () lorsque des threads sont créés pour maintenir le parallélisme lorsque d'autres sont bloqués de manière coopérative.

2
V S

Vous serez surpris par les performances de ForkJoin dans des applications similaires à celles du robot d'exploration. voici le meilleur tutoriel vous apprendrez de.

La logique de Fork/Join est très simple: (1) séparez (fork) chaque tâche importante en tâches plus petites; (2) traiter chaque tâche dans un thread séparé (en les séparant si nécessaire); (3) rejoindre les résultats.

2
danielad

Je voudrais ajouter une réponse courte pour ceux qui n'ont pas beaucoup de temps pour lire des réponses longues. La comparaison est tirée du livre Applied Akka Patterns:

Votre décision d'utiliser ou non un exécuteur fork-join-executor ou un exécuteur thread-pool-pool dépend en grande partie du fait que les opérations de ce répartiteur bloquent ou non. Un fork-join-executor vous donne un nombre maximum de threads actifs, alors qu'un thread-pool-executor vous donne un nombre fixe de threads. Si les threads sont bloqués, un fork-join-executor en créera plus, contrairement à un thread-pool-executor. Pour les opérations de blocage, il vaut généralement mieux utiliser un exécuteur de pool de threads, car il empêche le nombre de threads d'exploser. Des opérations plus "réactives" sont meilleures dans un exécuteur fork-join-join.

1
Vadim S.