web-dev-qa-db-fra.com

Flux parallèles, collecteurs et sécurité des threads

Voir l'exemple simple ci-dessous qui compte le nombre d'occurrences de chaque mot dans une liste:

Stream<String> words = Stream.of("a", "b", "a", "c");
Map<String, Integer> wordsCount = words.collect(toMap(s -> s, s -> 1,
                                                      (i, j) -> i + j));

À la fin, wordsCount est {a=2, b=1, c=1}.

Mais mon flux est très volumineux et je veux paralléliser le travail, alors j'écris:

Map<String, Integer> wordsCount = words.parallel()
                                       .collect(toMap(s -> s, s -> 1,
                                                      (i, j) -> i + j));

Cependant, j'ai remarqué que wordsCount est un simple HashMap donc je me demande si je dois explicitement demander une carte simultanée pour assurer la sécurité des threads:

Map<String, Integer> wordsCount = words.parallel()
                                       .collect(toConcurrentMap(s -> s, s -> 1,
                                                                (i, j) -> i + j));

Les collecteurs non simultanés peuvent-ils être utilisés en toute sécurité avec un flux parallèle ou dois-je utiliser uniquement les versions simultanées lors de la collecte à partir d'un flux parallèle?

41
assylias

Les collecteurs non simultanés peuvent-ils être utilisés en toute sécurité avec un flux parallèle ou dois-je utiliser uniquement les versions simultanées lors de la collecte à partir d'un flux parallèle?

Il est sûr d'utiliser un collecteur non simultané dans une opération collect d'un flux parallèle.

Dans la spécification de l'interface Collector, dans la section avec une demi-douzaine de puces, est la suivante:

Pour les collecteurs non simultanés, tout résultat renvoyé par le fournisseur de résultats, l'accumulateur ou les fonctions de combinateur doit être limité en thread. Cela permet à la collecte de se produire en parallèle sans que le collecteur n'ait à implémenter de synchronisation supplémentaire. L'implémentation de réduction doit gérer que l'entrée est correctement partitionnée, que les partitions sont traitées de manière isolée et que la combinaison ne se produit qu'une fois l'accumulation terminée.

Cela signifie que les différentes implémentations fournies par la classe Collectors peuvent être utilisées avec des flux parallèles, même si certaines de ces implémentations peuvent ne pas être des collecteurs simultanés. Cela s'applique également à l'un de vos propres collecteurs non simultanés que vous pourriez implémenter. Ils peuvent être utilisés en toute sécurité avec des flux parallèles, à condition que vos capteurs n'interfèrent pas avec la source du flux, soient sans effets secondaires, indépendants de la commande, etc.

Je recommande également de lire la section Mutable Reduction de la documentation du package Java.util.stream. Au milieu de cette section se trouve un exemple qui est déclaré parallélisable, mais qui recueille les résultats dans un ArrayList, qui n'est pas thread-safe.

La façon dont cela fonctionne est qu'un flux parallèle se terminant par un collecteur non simultané garantit que différents threads fonctionnent toujours sur différentes instances des collections de résultats intermédiaires. C'est pourquoi un collecteur a une fonction Supplier, pour créer autant de collections intermédiaires qu'il y a de threads, afin que chaque thread puisse s'accumuler dans le sien. Lorsque des résultats intermédiaires doivent être fusionnés, ils sont transmis en toute sécurité entre les threads et à tout moment, un seul thread fusionne une paire de résultats intermédiaires.

43
Stuart Marks

Tous les collecteurs, s'ils suivent les règles de la spécification, sont sûrs de fonctionner en parallèle ou séquentiels. La préparation parallèle est ici un élément clé de la conception.

La distinction entre les collecteurs simultanés et non simultanés est liée à l'approche de la parallélisation.

Un collecteur ordinaire (non simultané) fonctionne en fusionnant les sous-résultats. Ainsi, la source est partitionnée en un groupe de morceaux, chaque morceau est collecté dans un conteneur de résultats (comme une liste ou une carte), puis les sous-résultats sont fusionnés dans un conteneur de résultats plus grand. Ceci est sûr et préserve l'ordre, mais pour certains types de conteneurs - en particulier les cartes - peut être coûteux, car la fusion de deux cartes par clé est souvent coûteuse.

Un collecteur simultané crée à la place un conteneur de résultats, dont les opérations d'insertion sont garanties pour les threads et y insère des éléments à partir de plusieurs threads. Avec un conteneur de résultats hautement simultané comme ConcurrentHashMap, cette approche pourrait bien mieux fonctionner que la fusion de HashMaps ordinaires.

Ainsi, les collecteurs simultanés sont strictement des optimisations par rapport à leurs homologues ordinaires. Et ils ne viennent pas sans frais; comme les éléments sont dynamisés à partir de nombreux threads, les collecteurs simultanés ne peuvent généralement pas conserver l'ordre de rencontre. (Mais, souvent, vous ne vous souciez pas - lors de la création d'un histogramme de comptage de mots, vous ne vous souciez pas de l'instance de "foo" que vous avez comptée en premier.)

19
Brian Goetz

Il est sûr d'utiliser des collections non simultanées et des compteurs non atomiques avec des flux parallèles.

Si vous jetez un œil à la documentation de Stream :: collect , vous trouverez le paragraphe suivant:

Comme reduce(Object, BinaryOperator), les opérations de collecte peuvent être parallélisées sans nécessiter de synchronisation supplémentaire.

Et pour la méthode Stream :: réduire :

Bien que cela puisse sembler un moyen plus détourné d'effectuer une agrégation par rapport à la simple mutation d'un total en cours dans une boucle, les opérations de réduction se parallélisent plus gracieusement, sans nécessiter de synchronisation supplémentaire et avec un risque considérablement réduit de courses de données.

Cela pourrait être un peu surprenant. Cependant, notez que les flux parallèles sont basés sur un modèle de jointure en fourche . Cela signifie que l'exécution simultanée fonctionne comme suit:

  • séquence divisée en deux parties d'environ la même taille
  • traiter chaque pièce individuellement
  • recueillir les résultats des deux parties et les combiner en un seul résultat

Dans la deuxième étape, les trois étapes sont appliquées récursivement aux sous-séquences.

Un exemple devrait clarifier cela. le

IntStream.range(0, 4)
    .parallel()
    .collect(Trace::new, Trace::accumulate, Trace::combine);

Le seul objectif de la classe Trace est de consigner les appels de constructeur et de méthode. Si vous exécutez cette instruction, elle imprime les lignes suivantes:

thread:  9  /  operation: new
thread: 10  /  operation: new
thread: 10  /  operation: accumulate
thread:  1  /  operation: new
thread:  1  /  operation: accumulate
thread:  1  /  operation: combine
thread: 11  /  operation: new
thread: 11  /  operation: accumulate
thread:  9  /  operation: accumulate
thread:  9  /  operation: combine
thread:  9  /  operation: combine

Vous pouvez voir que quatre objets Trace ont été créés, accumulés a été appelé une fois sur chaque objet, et combine a été utilisé trois fois pour combiner les quatre objets en un seul. Chaque objet ne peut être accédé que par un thread à la fois. Cela rend le code thread-safe, et il en va de même pour la méthode Collectors :: toMap .

11
nosid