Les systèmes simultanés peuvent être implémentés à l'aide de différents modèles de concurrence. Un modèle de concurrence spécifie la manière dont les threads collaborent dans le système pour accomplir les tâches qui leur sont confiées. Différents modèles de concurrence utilisent différentes manières de répartir le travail, et les threads peuvent communiquer et coopérer les uns avec les autres de différentes manières. Ce didacticiel sur le modèle de concurrence fournira une explication détaillée du modèle de concurrence le plus largement utilisé au moment de la rédaction.
Le modèle de concurrence est similaire à celui des systèmes distribués
Le modèle de concurrence mentionné dans ce texte est différent de celui utilisé dans les systèmes distribués. le cadre est similaire. Différents threads communiquent entre eux dans un système concurrent. Différents processus communiquent dans un système distribué (peut-être sur différents ordinateurs). Essentiellement, les threads et les processus sont très similaires. C'est pourquoi les différents modèles de concurrence ressemblent souvent à différents frameworks distribués.
Bien entendu, les systèmes distribués sont également confrontés à des défis supplémentaires, tels que des pannes de réseau, des pannes d'ordinateurs et de processus distants, etc. Cependant, dans un système simultané exécuté sur un grand serveur, des problèmes similaires peuvent survenir en cas de panne d'un processeur, d'une carte réseau, d'un disque, etc. Bien que la probabilité d’un tel échec soit faible, cela est théoriquement possible.
Étant donné que le modèle de concurrence est similaire au cadre du système distribué, ils peuvent souvent apprendre quelques idées les uns des autres. Par exemple, le modèle de répartition du travail entre les travailleurs (threads) est similaire à l'équilibrage de charge dans les systèmes distribués. Les techniques de gestion des erreurs telles que la journalisation, la tolérance aux pannes, etc. sont également les mêmes.
Travailleurs parallèles
Le premier modèle de concurrence, nous l'appelons le modèle de travailleur parallèle. Les tâches entrantes sont attribuées à différents travailleurs. Voici un diagramme :
Dans le modèle de concurrence des travailleurs parallèles, un agent distribue le travail entrant à différents travailleurs. Chaque travailleur accomplit la tâche entière. L'ensemble du travailleur travaille en parallèle, s'exécutant dans différents threads et éventuellement sur différents processeurs.
Si un modèle de travailleurs parallèles est mis en œuvre dans une usine automobile, chaque voiture sera produite par un ouvrier. Cet ouvrier recevra des instructions pour construire et construira tout du début à la fin.
Le modèle de simultanéité des travailleurs parallèles est le modèle de simultanéité le plus largement utilisé dans les applications Java (bien que cela soit en train de changer). De nombreuses classes d'utilitaires de concurrence du package Java java.util.concurrent sont conçues pour utiliser ce modèle. Vous pouvez également voir des traces de ce modèle dans les applications d'entreprise Java.
Avantages des travailleurs parallèles
L'avantage du modèle de concurrence des travailleurs parallèles est qu'il est relativement simple à comprendre. Pour augmenter le parallélisme de votre application, vous ajoutez simplement plus de travailleurs.
Par exemple, si vous implémentez une fonction de robot d'exploration Web, vous utiliserez différents nombres de travailleurs pour explorer un certain nombre de pages et verrez quel travailleur prendra le temps d'exploration le plus court (ce qui signifie des performances plus élevées). Étant donné que le web scraping est un travail gourmand en E/S, vous pouvez vous retrouver avec plusieurs threads par CPU/cœur sur votre ordinateur. Un thread par CPU est trop peu, car il restera inactif pendant une longue période en attendant le téléchargement des données.
Inconvénients des travailleurs parallèles
Le modèle de concurrence des travailleurs parallèles présente certains inconvénients cachés sous la surface. J'expliquerai la plupart des inconvénients dans les sections ci-dessous.
L'acquisition d'un État partagé est compliquée
En réalité, le modèle de concurrence des travailleurs parallèles est plus compliqué qu'expliqué ci-dessus. Ce travailleur partagé a souvent besoin d'accéder à certaines données partagées, soit en mémoire, soit dans une base de données partagée. Le diagramme ci-dessous montre la complexité du modèle de concurrence des travailleurs parallèles.
Une partie de cet état partagé se trouve dans un mécanisme de communication comme une file d'attente de travail. Mais une partie de cet état partagé concerne les données commerciales, le cache de données, le pool de connexions à la base de données, etc.
Dès que l'état partagé s'infiltre dans le modèle de concurrence des travailleurs parallèles, cela commence à se compliquer. Ce thread doit accéder aux données partagées d'une manière ou d'une autre pour garantir que les modifications apportées par un thread sont visibles par les autres threads (poussées vers la mémoire principale, et pas seulement bloquées dans le cache CPU du CPU exécutant ce thread). Les threads doivent éviter les conditions de concurrence, les blocages et de nombreux autres problèmes de concurrence d’état partagé.
De plus, lors de l'accès aux structures de données partagées, lorsque les threads s'attendent, la partie calcul parallèle est perdue. De nombreuses structures de données concurrentes sont obstruées, ce qui signifie qu'un ou un ensemble limité de threads peuvent y accéder à tout moment. Cela peut conduire à des conflits sur ces structures de données partagées. Un conflit élevé entraînera intrinsèquement un certain degré de sérialisation dans l'exécution des portions de code qui accèdent aux structures de données partagées.
Les algorithmes concurrents non bloquants modernes peuvent réduire les conflits et améliorer les performances, mais les algorithmes non bloquants sont difficiles à mettre en œuvre.
Les structures de données persistantes sont une autre option. Une structure de données persistante conserve toujours sa version précédente lorsqu'elle est modifiée. De plus, si plusieurs threads pointent vers la même structure de données persistante et que l'un des threads la modifie, le thread modificateur obtient une référence à la nouvelle structure. Tous les autres threads conserveront les références aux anciennes structures, qui resteront inchangées. Le langage de programmation Scala contient plusieurs structures de données persistantes.
Les structures de données persistantes ne fonctionnent pas bien tout en fournissant une solution élégante et concise pour la modification simultanée des structures de données partagées.
Par exemple, une liste persistante ajoutera tous les nouveaux éléments en tête de la liste et renverra une référence à l'élément nouvellement ajouté (cela exécutera ensuite le reste de la liste). Tous les autres threads conservent toujours une référence au premier élément précédent de la liste et la liste apparaît inchangée pour les autres threads. Ils ne peuvent pas voir cet élément nouvellement ajouté.
Une telle liste persistante est implémentée sous forme de liste chaînée. Malheureusement, les listes chaînées ne fonctionnent pas bien dans les logiciels modernes. Chaque élément de la liste est un objet distinct et ces objets peuvent être répartis dans la mémoire de l'ordinateur. Les processeurs modernes accèdent beaucoup plus rapidement aux données de manière séquentielle, de sorte que leur mise en œuvre au-dessus d'un tableau plutôt que d'une liste entraînera des performances plus élevées sur le matériel moderne. Un tableau stocke les données de manière séquentielle. Ce cache CPU peut charger des morceaux plus gros dans le cache à la fois, et les données sont accessibles directement dans ce cache CPU une fois chargées. Ceci est impossible à implémenter à l’aide d’une liste chaînée, car les éléments de la liste chaînée seront dispersés dans toute la RAM.
Travailleurs apatrides
L'état partagé dans le système peut être modifié par d'autres fils de discussion. Par conséquent, le travailleur doit relire cet état à chaque fois qu'il en a besoin pour confirmer s'il travaille sur la dernière copie. Cela est vrai, que l'état partagé soit en mémoire ou dans une base de données externe. Un travailleur qui ne maintient pas son état en interne (mais qui doit être relu à chaque fois) est dit apatride.
Ce sera plus lent si vous devez relire les données à chaque fois. Surtout si cet état est stocké dans une base de données externe.
L'ordre des tâches ne peut pas être déterminé
Un autre inconvénient du modèle de travail parallèle est que l'ordre d'exécution des tâches ne peut pas être déterminé. Il n'existe aucun moyen de garantir quelle tâche sera exécutée en premier et quelle tâche sera exécutée en dernier. La tâche A peut être confiée à un travailleur avant la tâche B, mais la tâche B peut être exécutée avant la tâche A.
La nature naturellement non déterministe du modèle de travail parallèle rend difficile le raisonnement sur l'état du système à un moment donné. Il serait également difficile de garantir qu’une tâche se déroule avant une autre (ce qui est fondamentalement impossible).
Ligne d'assemblage
Le deuxième modèle de concurrence, je l'appelle le modèle de concurrence de la chaîne d'assemblage. J'ai choisi ce nom simplement pour correspondre plus simplement à la métaphore du « travailleur parallèle ». D'autres développeurs utilisent d'autres noms (par exemple, systèmes réactifs ou systèmes pilotés par événements) pour s'appuyer sur la plateforme ou la communauté. Voici un exemple d'image pour illustrer :
Cet ouvrier est comme un ouvrier sur une chaîne de montage dans une usine. Chaque travailleur n'effectue qu'une partie du travail total. Lorsque cette partie est terminée, le travailleur transfère la tâche au travailleur suivant.
Chaque travailleur s'exécute dans son propre thread et il n'y a pas d'état partagé entre les travailleurs. Ceci est donc parfois mentionné comme un modèle de concurrence sans partage.
Les systèmes qui utilisent le modèle de concurrence de pipeline sont généralement conçus à l'aide d'E/S non bloquantes. Les IO non bloquantes signifient que lorsqu'un travailleur démarre une opération IO (telle que la lecture d'un fichier ou de données à partir d'une connexion réseau), le travailleur n'attend pas la fin de l'appel IO. Les opérations d'E/S sont si lentes qu'attendre la fin de l'opération d'E/S est un gaspillage de CPU. Ce processeur peut faire d'autres choses en même temps. Lorsque l'opération IO se termine, les résultats de l'opération IO (tels que l'état de lecture ou d'écriture des données) seront transmis à un autre travailleur.
Pour les IO non bloquantes, cette opération IO détermine la plage de limites entre les Workers. Un travailleur fait ce qu'il peut jusqu'à ce qu'il doive démarrer une opération d'E/S. Il abandonne alors la tâche de le contrôler. Lorsque cette opération d'E/S se termine, le travailleur suivant dans le pipeline continue de travailler sur cette tâche, jusqu'à ce qu'il doive également démarrer une opération d'E/S, et ainsi de suite.
En fait, ces tâches ne se déroulent pas forcément sur une chaîne de production. Étant donné que la plupart des systèmes font plus que simplement effectuer une seule tâche, le flux des tâches entre les travailleurs dépend de la tâche à effectuer. En fait, plusieurs pipelines virtuels différents seront exécutés en même temps. Le diagramme ci-dessous montre comment les tâches se déroulent dans un véritable système de pipeline.
Les tâches peuvent même exécuter plus d'un travailleur afin de s'exécuter simultanément. Par exemple, une tâche peut pointer à la fois vers un exécuteur de tâches et vers un journal des tâches. Ce diagramme illustre comment les trois pipelines finissent par confier leurs tâches au même travailleur (le dernier travailleur est sur le pipeline du milieu) :
Les pipelines peuvent même obtenir plus complexe que cela.
Système réactif, système piloté par les événements
Les systèmes qui utilisent un modèle de concurrence de pipeline sont souvent appelés systèmes réactifs, systèmes pilotés par les événements. Les travailleurs de ce système réagissent aux événements qui se produisent dans le système, qu'ils soient reçus de l'extérieur ou émis par d'autres travailleurs. Des exemples d'événements peuvent être une requête HTTP, ou la fin d'un fichier en cours de chargement en mémoire, etc.
Au moment de la rédaction de cet article, il existe de nombreuses plates-formes réactives/événementielles intéressantes disponibles, et d'autres seront disponibles dans le futur. Certains des plus courants ressemblent à ceci :
Vert.x
Akka
Node.JS(JavaScript)
Pour moi personnellement, je trouve Vert 🎜>
Actor VS.
Acteur et canal sont deux exemples similaires dans le modèle de pipeline (ou système réactif/piloté par les événements). Dans le modèle de l'acteur, chaque travailleur est appelé acteur. Les acteurs peuvent s'envoyer des messages. Les messages sont envoyés puis exécutés de manière asynchrone. Les acteurs peuvent être utilisés pour mettre en œuvre une ou plusieurs tâches, comme décrit précédemment. Voici un modèle d'acteurs :Avantages du pipeline
Ce modèle de concurrence de pipeline présente plusieurs avantages par rapport au modèle de travail parallèle. Dans les sections suivantes, je couvrirai les plus grands avantages.Aucun état partagé
Le fait que les travailleurs ne partagent pas l'état avec d'autres travailleurs signifie qu'ils n'ont pas à se soucier de toute concurrence problème à réaliser. Cela facilite la mise en œuvre des travailleurs. Lorsque vous implémentez un travailleur, si un seul thread effectue ce travail, il s'agit essentiellement d'une implémentation à un seul thread.Travailleur avec état
Parce que le travailleur sait qu'aucun autre thread ne modifiera ses données, ce travailleur a le statut. Avec état, ce qui signifie qu'ils peuvent conserver en mémoire les données dont ils ont besoin pour fonctionner et simplement réécrire les modifications finales sur le système de stockage externe. Un travailleur avec état est donc plus rapide qu'un travailleur apatride.Meilleure intégration matérielle
Le code monothread présente cet avantage, il s'adapte souvent mieux au matériel sous-jacent. Premièrement, vous pouvez créer des structures de données et des algorithmes plus optimisés lorsque vous supposez que le code peut être exécuté en mode monothread. Deuxièmement, les travailleurs avec état à thread unique, comme mentionné ci-dessus, peuvent mettre les données en cache en mémoire. Lorsque les données sont mises en cache dans la mémoire, il existe également une forte probabilité que les données soient également mises en cache dans le cache CPU du CPU du thread d'exécution. Cela rend l’accès aux données plus rapide.Je parle d'intégration matérielle lorsque le code est écrit, d'une manière qui profite du fonctionnement du matériel sous-jacent. Certains développeurs appellent cela la façon dont fonctionne le matériel. Je préfère le terme intégration matérielle car les ordinateurs ont très peu de pièces mécaniques, et le mot « sympathie » est utilisé dans cet article comme métaphore pour « mieux s'adapter », alors que je pense que le mot « se conformer » exprime plus raisonnable.
Quoi qu'il en soit, c'est du pinaillage. Utilisez simplement les mots que vous aimez.
Des tâches séquentielles sont possibles
Il est possible de mettre en œuvre un système concurrent basé sur le modèle de concurrence pipeline pour assurer l'ordre des tâches à certains mesure de. Les tâches séquentielles facilitent le raisonnement sur l'état du système à un moment donné. De plus, vous pouvez écrire toutes les tâches entrantes dans un journal. Ce journal peut être utilisé pour reconstruire à partir du point de défaillance en cas de défaillance d'une partie du système. Cette tâche peut être écrite dans le journal dans un certain ordre, et cet ordre devient un ordre de tâche fixe. Le schéma de conception ressemble à ceci :
La mise en œuvre d'une séquence de tâches fixe n'est certes pas simple, mais c'est possible. Si possible, cela simplifierait grandement les tâches telles que la sauvegarde, la restauration des données, la copie des données, etc. Tout cela peut être effectué via des fichiers journaux.
Inconvénients du pipeline
Le principal inconvénient du modèle de concurrence de pipeline est que l'exécution des tâches est souvent répartie sur plusieurs travailleurs et passera par vous Plusieurs classes dans le projet. Il sera donc plus difficile de voir exactement quel code est exécuté pour une tâche donnée.
Écrire du code peut également devenir difficile. Le code de travail est souvent écrit sous forme de fonctions de rappel. Le code fourni avec davantage de rappels imbriqués peut amener certains développeurs à se lasser des rappels à appeler. L'enfer des rappels signifie simplement qu'il est plus difficile de suivre ce que fait votre code, ainsi que de déterminer les données dont chaque rappel a besoin pour accéder à ses données.
En utilisant le modèle de concurrence des travailleurs parallèles, cela deviendra plus simple. Vous pouvez ouvrir ce code de travail et lire le code exécuté presque du début à la fin. Bien entendu, le code du travailleur parallèle peut également être réparti sur différentes classes, mais la séquence d'exécution sera plus facile à lire à partir du code.
Parallélisme fonctionnel
Le parallélisme fonctionnel est le troisième modèle de concurrence, qui a été beaucoup discuté au cours de ces années.
L'idée de base du parallélisme fonctionnel est d'implémenter votre programme à l'aide d'appels de fonction. Les fonctions sont considérées comme des « agents » ou des « acteurs » qui s'envoient des messages, un peu comme le modèle de concurrence de pipeline (systèmes réactifs AKA ou systèmes pilotés par événements). Lorsqu’une fonction en appelle une autre, cela revient à envoyer un message.
Tous les paramètres passés à la fonction sont copiés, afin qu'aucune entité extérieure à la fonction réceptrice ne puisse combiner ces données. Cette copie est cruciale pour éviter les conditions statiques sur les données partagées. Cela rend la fonction similaire à une opération atomique. Chaque appel de fonction peut être exécuté indépendamment de tout autre appel de fonction.
Bien qu'un appel de fonction puisse être exécuté individuellement, chaque appel de fonction peut être exécuté sur un processeur distinct. Cela signifie qu'un algorithme fonctionnel implémenté peut être exécuté en parallèle sur plusieurs processeurs.
Avec Java 7, nous obtenons le package java.util.concurrent contenant ForkAndJoinPool, qui peut vous aider à réaliser des choses similaires au parallélisme fonctionnel. Avec Java 8, nous obtenons un flux parallèle, qui vous aide à paralléliser les itérations de grandes collections. N'oubliez pas qu'il y a des développeurs qui ne sont pas satisfaits de ForkAndJoinPool (vous pouvez trouver des liens vers certaines critiques dans mon tutoriel ForkAndJoinPool).
La partie la plus difficile du parallélisme fonctionnel est de savoir quelle fonction appelle à paralléliser. La coordination des appels de fonction entre les processeurs entraîne une surcharge. Une unité de travail complétée par une fonction nécessite une certaine quantité de frais généraux. Si les appels de fonction sont très petits, essayer de les paralléliser peut en effet être plus lent qu'une exécution monothread et CPU uniquement.
D'après ma compréhension (bien sûr, ce n'est pas parfait), vous pouvez utiliser un système réactif et un pilotage temporel pour implémenter un algorithme et terminer la décomposition d'une œuvre. C'est similaire au parallélisme fonctionnel. Avec un modèle basé sur les événements, vous obtenez simplement plus de contrôle sur la quantité et la manière de paralléliser (je pense).
De plus, la répartition d'une tâche sur plusieurs processeurs entraîne un coût de coordination, ce qui n'a de sens que si cette tâche est actuellement la seule tâche exécutée par ce programme. Cependant, si le système exécute plusieurs autres tâches (par exemple, des serveurs Web, des serveurs de bases de données et de nombreux autres systèmes), il ne sert à rien de paralléliser une seule tâche. Les autres processeurs de l'ordinateur sont de toute façon occupés à effectuer d'autres tâches, il n'y a donc aucune raison de les perturber avec une tâche parallèle fonctionnelle plus lente. Il serait probablement judicieux d'utiliser un modèle de concurrence pipeline, car il entraîne moins de surcharge (exécution séquentielle en mode monothread) et est mieux conforme au matériel sous-jacent.
Quel modèle de concurrence est le meilleur
Alors, quel modèle de concurrence est le meilleur ?
C'est généralement le cas, la réponse dépend de l'apparence de votre système. Si vos tâches sont naturellement parallèles, indépendantes et ne nécessitent pas d'état partagé, vous pouvez alors utiliser un modèle de travail parallèle pour implémenter votre système.
De nombreuses tâches ne sont cependant pas naturellement parallèles et indépendantes. Pour ces types de systèmes, je pense que ce modèle de concurrence de pipeline présente plus d'avantages que d'inconvénients, et présente de plus grands avantages que le modèle de travail parallèle.
Vous n'avez même pas besoin d'écrire vous-même le code de la structure du pipeline. Les plateformes modernes comme Vert.x font déjà beaucoup de cela pour vous. Personnellement, j'explorerai les conceptions exécutées sur des plates-formes comme Vert.x dans mon prochain projet. Je pense que Java EE n'aura aucun avantage.
Ce qui précède est une introduction détaillée au modèle de concurrence Java. Pour plus de contenu connexe, veuillez faire attention au site Web PHP chinois (www.php.cn) !