Source
Auteurs: Doug Lea en collaboration avec Brian Goetz, Paul Sandoz, Alexey Shipilev, Heinz Kabutz, Joe Bowbeer, ...
Le framework java.util.streams
contient des opérations pilotées par les données sur les collections et d'autres sources de données. La plupart des méthodes de flux effectuent la même opération sur chaque élément. En utilisant la méthode de collecte parallelStream()
, si vous disposez de plusieurs cœurs, vous pouvez transformer les données pilotées en données parallèles . Mais quand vaut-il la peine?
Envisagez d'utiliser S.parallelStream().operation(F)
au lieu de S.stream().operation(F)
, à condition que les opérations soient indépendantes les unes des autres et soient coûteuses en termes de calcul ou appliquées à un grand nombre d'éléments qui sont effectivement fractionnés (divisibles) des structures de données, ou les deux. Plus précisément:
F
: une fonction pour travailler avec un seul élément, généralement un lambda, est indépendante, c'est-à-dire l'opération sur l'un des éléments est indépendante et n'affecte pas les opérations sur d'autres éléments (pour des recommandations sur l'utilisation de fonctions sans état sans interférence, voir la documentation du package de flux ).S
: La collection d'origine est effectivement divisée. En plus des collections, il existe d'autres adaptées à la parallélisation, à la diffusion de sources de données, par exemple, java.util.SplittableRandom
(pour la parallélisation, vous pouvez utiliser la méthode stream.parallel()
). Mais la plupart des sources avec E / S au cœur sont principalement conçues pour un fonctionnement séquentiel.- La durée totale d'exécution en mode séquentiel dépasse la limite minimale autorisée. Aujourd'hui, pour la plupart des plateformes, la limite est à peu près égale (dans x10) à 100 microsecondes. Dans ce cas, des mesures précises ne sont pas nécessaires. Pour des raisons pratiques, il suffit de multiplier simplement
N
(le nombre d'éléments) par Q
(le temps de fonctionnement d'un F
), et Q
peut être estimé approximativement par le nombre d'opérations ou le nombre de lignes de code. Après cela, vous devez vérifier que N * Q
est au moins inférieur à 10000
(si vous êtes timide, ajoutez un ou deux zéros). Donc, si F
est une petite fonction comme x -> x + 1
, alors l'exécution parallèle aura un sens lorsque N >= 10000
. Inversement, si F
est un calcul de poids, semblable à trouver le prochain meilleur coup dans une partie d'échecs, alors la valeur de Q
si grande que N
peut être négligée, mais jusqu'à ce que la collection soit complètement divisée.
Le cadre de traitement du streaming n'insistera pas (et ne peut pas) insister sur aucun des éléments ci-dessus. Si les calculs sont interdépendants, leur exécution parallèle n'a aucun sens, ou sera nuisible du tout et entraînera des erreurs. Les autres critères dérivés des problèmes d'ingénierie et des compromis ci-dessus comprennent:
- Démarrage
L'apparition de cœurs supplémentaires dans les processeurs, dans la plupart des cas, s'est accompagnée de l'ajout d'un mécanisme de gestion de l'alimentation, ce qui peut entraîner un ralentissement du lancement des cœurs, parfois avec des superpositions supplémentaires de la JVM, du système d'exploitation et de l'hyperviseur. Dans ce cas, la limite à laquelle le mode parallèle prend tout son sens correspond à peu près au temps nécessaire pour commencer à traiter les sous-tâches avec un nombre suffisant de cœurs. Après cela, le calcul parallèle peut être plus économe en énergie que séquentiel (selon les détails des processeurs et des systèmes. Pour un exemple, voir l' article ). - Détails (granularité)
Il est rarement judicieux de fractionner de petits calculs. Le cadre divise généralement la tâche afin que les différentes pièces puissent fonctionner sur tous les cœurs système disponibles. Si, après le démarrage, il n'y a pratiquement pas de travail pour chaque cœur, les efforts (généralement séquentiels) pour organiser le calcul parallèle seront vains. Étant donné que dans la pratique le nombre de cœurs varie de 2 à 256 seuils, il empêche également l'effet indésirable d'une division excessive de la tâche. - Divisibilité
Les collections fractionnées les plus efficaces incluent ArrayList
et {Concurrent}HashMap
, ainsi que les tableaux réguliers ( T[]
, qui sont divisés en parties à l'aide de méthodes statiques java.util.Arrays
). Les séparateurs les moins efficaces sont LinkedList
, BlockingQueue
et la plupart des sources avec E / S basées. Les autres se situent quelque part au milieu (les structures de données qui prennent en charge l'accès aléatoire et / ou la recherche efficace sont généralement divisées efficacement). Si le fractionnement des données prend plus de temps que le traitement, l'effort est vain. Si Q
est suffisamment grand, vous pouvez obtenir une augmentation en raison de la parallélisation, même pour LinkedList
, mais c'est un cas assez rare. De plus, certaines sources ne peuvent pas être divisées en un seul élément et il peut donc y avoir une restriction sur le degré de décomposition du problème.
L'obtention des caractéristiques exactes de ces effets peut être difficile (bien que, si vous essayez, cela peut être fait en utilisant des outils comme JMH ). Mais l'effet cumulatif est assez facile à remarquer. Pour le ressentir vous-même - faites une expérience. Par exemple, sur une machine de test à 32 cœurs, lorsque vous exécutez de petites fonctions, telles que max()
ou sum()
, au-dessus de ArrayList
seuil de rentabilité est d'environ 10 000. Pour plus d'éléments, une accélération jusqu'à 20 fois est notée. Les heures d'ouverture pour les collections de moins de 10 000 articles ne sont pas beaucoup moins que pour 10 000, et donc plus lentes que le traitement séquentiel. Le pire résultat se produit avec moins de 100 éléments - dans ce cas, les threads impliqués s'arrêtent sans rien faire d'utile, car les calculs sont terminés avant de commencer. D'un autre côté, lorsque les opérations sur les éléments prennent du temps, lors de l'utilisation de collections efficaces et complètement séparables, telles que ArrayList
, les avantages sont immédiatement visibles.
Pour paraphraser tout ce qui précède, l'utilisation de parallel()
dans le cas d'une quantité de calcul déraisonnablement petite peut coûter environ 100
microsecondes, et une autre utilisation devrait économiser au moins cette fois-ci (ou peut-être des heures pour de très grandes tâches). Le coût et les avantages spécifiques varieront au fil du temps pour différentes plates-formes, et aussi, selon le contexte. Par exemple, l'exécution de petits calculs en parallèle dans un cycle séquentiel améliore l'effet des hauts et des bas (les microtests de performance dans lesquels cela se produit peuvent ne pas refléter la situation réelle).
Q & A
- Pourquoi la JVM ne peut-elle pas comprendre quand exécuter des opérations en parallèle?
Elle pourrait essayer, mais trop souvent la décision serait mauvaise. La recherche d'un parallélisme multicœur entièrement automatique n'a pas conduit à une solution universelle au cours des trente dernières années et, par conséquent, le cadre utilise une approche plus fiable, ne demandant à l'utilisateur que de choisir entre oui ou non . Ce choix est basé sur des problèmes d'ingénierie que l'on rencontre constamment dans la programmation séquentielle et qui ne disparaîtront probablement jamais complètement. Par exemple, vous pouvez rencontrer un ralentissement au centuple lorsque vous recherchez la valeur maximale dans une collection contenant un seul élément en comparaison en utilisant directement cette valeur (sans collection). Parfois, la JVM peut optimiser ces cas pour vous. Mais cela se produit rarement dans les cas séquentiels, et jamais dans le cas du mode parallèle. D'un autre côté, nous pouvons nous attendre à ce qu'à mesure qu'ils se développent, les outils aident les utilisateurs à prendre de meilleures décisions.
- Et si pour prendre une bonne décision, je n'ai pas suffisamment de connaissances sur les paramètres (
F
, N
, Q
, S
)?
Ceci est également similaire aux problèmes rencontrés dans la programmation séquentielle. Par exemple, la S.contains(x)
de la classe Collection
s'exécute généralement rapidement si S
est un HashSet
, lente si LinkedList
et moyenne dans les autres cas. Habituellement, pour l'auteur d'un composant utilisant la collection, le meilleur moyen de sortir de cette situation est de l'encapsuler et de ne publier qu'une opération spécifique sur celui-ci. Les utilisateurs seront alors isolés de la nécessité de choisir. Il en va de même pour les opérations parallèles. Par exemple, un composant avec une collecte de prix interne peut déterminer une méthode qui vérifie sa taille à la limite, ce qui aura du sens jusqu'à ce que le calcul au niveau du bit soit trop cher. Un exemple:
public long getMaxPrice() { return priceStream().max(); } private Stream priceStream() { return (prices.size() < MIN_PAR) ? prices.stream() : prices.parallelStream(); }
Cette idée peut être étendue à d'autres considérations sur le moment et la façon d'utiliser la concurrence.
- Que faire si ma fonction effectue probablement des E / S ou des opérations synchronisées?
À une extrémité se trouvent les fonctions qui ne répondent pas aux critères d'indépendance, notamment les opérations d'E / S séquentielles, l'accès au blocage des ressources synchronisées et les cas où une erreur dans une sous-tâche parallèle qui effectue des E / S affecte les autres. Leur parallélisation n'a pas beaucoup de sens. D'un autre côté, il existe des calculs qui effectuent occasionnellement des E / S ou rarement une synchronisation bloquée (par exemple, la plupart des cas de journalisation et l'utilisation de collections compétitives telles que ConcurrentHashMap
). Ils sont inoffensifs. Ce qui les sépare nécessite plus de recherche. Si chaque sous-tâche peut être bloquée pendant un temps considérable en attendant les E / S ou l'accès, les ressources CPU seront inactives sans possibilité d'utilisation par le programme ou la JVM. De cela est mauvais pour tout le monde. Dans ces cas, le traitement en streaming parallèle n'est pas toujours le bon choix. Mais il existe de bonnes alternatives - par exemple, les E / S asynchrones et l'approche CompletableFuture
.
- Que faire si ma source est basée sur les E / S?
À l'heure actuelle, à l'aide des générateurs JDK Stream
/ I / O (par exemple, BufferedReader.lines()
), ils sont principalement adaptés pour une utilisation en mode séquentiel, en traitant les éléments un par un à mesure qu'ils deviennent disponibles. La prise en charge du traitement en masse hautes performances des E / S tamponnées est possible, mais pour le moment, cela nécessite le développement de générateurs spéciaux Stream
s, Spliterator
s et Collector
s. La prise en charge de certains cas courants peut être ajoutée dans les futures versions du JDK.
- Que faire si mon programme s'exécute sur un ordinateur occupé et que tous les noyaux sont occupés?
Les machines ont généralement un nombre fixe de cœurs et ne peuvent pas en créer par magie de nouveaux lors de l'exécution d'opérations parallèles. Cependant, tant que les critères de choix d'un mode parallèle parlent clairement, il n'y a aucun doute. Vos tâches parallèles rivaliseront pour le CPU avec les autres et vous remarquerez moins d'accélération. Dans la plupart des cas, cela reste plus efficace que d'autres alternatives. Le mécanisme sous-jacent est conçu de sorte que s'il n'y a pas de cœurs disponibles, vous ne remarquerez qu'un léger ralentissement par rapport à la version séquentielle, sauf lorsque le système est tellement surchargé qu'il passe tout son temps à changer de contexte au lieu de faire un vrai travail, ou configuré dans l'espoir que tout le traitement est effectué séquentiellement. Si vous avez un tel système, alors peut-être que l'administrateur a déjà désactivé l'utilisation du multithreading / Nucléarité dans les paramètres JVM. Et si vous êtes l'administrateur système, il est logique de le faire.
- Toutes les opérations sont-elles parallélisées lors de l'utilisation du mode parallèle?
Oui Au moins dans une certaine mesure. Mais il convient de tenir compte du fait que le cadre de flux prend en compte les limites des sources et des méthodes lors du choix de la manière de procéder. En général, moins il y a de restrictions, plus le potentiel de parallélisme est grand. D'un autre côté, rien ne garantit que le cadre identifiera et appliquera toutes les opportunités de simultanéité disponibles. Dans certains cas, si vous avez le temps et les compétences, votre propre solution peut faire un bien meilleur usage des possibilités de concurrence.
- Quelle accélération vais-je obtenir de la concurrence?
Si vous respectez ces conseils, alors, généralement, assez pour avoir du sens. La prévisibilité n'est pas un point fort du matériel et des systèmes modernes, et il n'y a donc pas de réponse universelle. La localisation du cache, les caractéristiques du GC, la compilation JIT, les conflits d'accès à la mémoire, l'emplacement des données, les politiques de planification du système d'exploitation et la présence d'un hyperviseur sont quelques-uns des facteurs qui ont un impact significatif. Les performances du mode séquentiel sont également soumises à leur influence, qui, lors de l'utilisation du parallélisme, est souvent amplifiée: le problème provoquant une différence de 10% en cas d'exécution séquentielle peut entraîner une différence de 10 fois dans le traitement parallèle.
Le cadre de flux comprend certaines fonctionnalités qui aident à augmenter les chances d'accélération. Par exemple, l'utilisation de la spécialisation pour les primitives, telles que IntStream
, a généralement un effet plus important pour le mode parallèle que pour le mode séquentiel. La raison en est que dans ce cas, non seulement la consommation de ressources (et de mémoire) diminue, mais la localisation du cache s'améliore également. L'utilisation de ConcurrentHashMap
au lieu de HashMap
, dans le cas de l'opération parallèle de l'opération de collect
, réduit les coûts internes. De nouveaux trucs et astuces apparaîtront au fil de l'expérience acquise avec le framework.
- Tout cela fait trop peur! Ne pouvons-nous pas simplement proposer des règles d'utilisation des propriétés JVM pour désactiver la concurrence?
Nous ne voulons pas vous dire quoi faire. L'émergence de nouvelles façons pour les programmeurs de faire quelque chose de mal peut être effrayante. Des erreurs de code, d'architecture et d'évaluations se produiront certainement. Il y a des décennies, certaines personnes prédisaient que la simultanéité au niveau de l'application entraînerait de grandes catastrophes. Mais cela ne s'est jamais réalisé.