Principes et modèles de conception pour les applications hautement simultanées

1. Vue d'ensemble

Dans ce didacticiel, nous aborderons certains des principes et modèles de conception qui ont été établis au fil du temps pour créer des applications hautement simultanées.

Cependant, il est intéressant de noter que la conception d'une application simultanée est un sujet vaste et complexe, et donc aucun tutoriel ne peut prétendre être exhaustif dans son traitement. Ce que nous allons couvrir ici sont quelques-unes des astuces populaires souvent utilisées!

2. Bases de la concurrence

Avant d'aller plus loin, passons un peu de temps à comprendre les bases. Pour commencer, nous devons clarifier notre compréhension de ce que nous appelons un programme concurrent. Nous nous référons à un programme simultané si plusieurs calculs se produisent en même temps .

Maintenant, notez que nous avons mentionné que les calculs se déroulent en même temps - c'est-à-dire qu'ils sont en cours au même moment. Cependant, ils peuvent ou non s'exécuter simultanément. Il est important de comprendre la différence car l'exécution simultanée de calculs est appelée parallèle .

2.1. Comment créer des modules simultanés?

Il est important de comprendre comment créer des modules simultanés. Il existe de nombreuses options, mais nous allons nous concentrer sur deux choix populaires ici:

  • Processus : un processus est une instance d'un programme en cours d'exécution isolé des autres processus de la même machine. Chaque processus sur une machine a son propre temps et espace isolés. Par conséquent, il n'est normalement pas possible de partager la mémoire entre les processus et ils doivent communiquer en passant des messages.
  • Thread : Un thread, en revanche, n'est qu'un segment d'un processus . Il peut y avoir plusieurs threads dans un programme partageant le même espace mémoire. Cependant, chaque thread a une pile et une priorité uniques. Un thread peut être natif (planifié nativement par le système d'exploitation) ou vert (planifié par une bibliothèque d'exécution).

2.2. Comment les modules simultanés interagissent-ils?

C'est tout à fait idéal si les modules concurrents n'ont pas à communiquer, mais ce n'est souvent pas le cas. Cela donne lieu à deux modèles de programmation simultanée:

  • Mémoire partagée : dans ce modèle, les modules simultanés interagissent en lisant et en écrivant des objets partagés dans la mémoire . Cela conduit souvent à l'entrelacement de calculs simultanés, provoquant des conditions de concurrence. Par conséquent, il peut conduire de manière non déterministe à des états incorrects.
  • Passage de messages : dans ce modèle, les modules simultanés interagissent en se transmettant des messages via un canal de communication . Ici, chaque module traite les messages entrants de manière séquentielle. Puisqu'il n'y a pas d'état partagé, il est relativement plus facile à programmer, mais ce n'est toujours pas exempt de conditions de course!

2.3. Comment s'exécutent les modules simultanés?

Cela fait un moment que la loi de Moore n'a pas touché un mur en ce qui concerne la vitesse d'horloge du processeur. Au lieu de cela, comme nous devons grandir, nous avons commencé à emballer plusieurs processeurs sur la même puce, souvent appelés processeurs multicœurs. Mais encore, il n'est pas courant d'entendre parler de processeurs qui ont plus de 32 cœurs.

Maintenant, nous savons qu'un seul cœur ne peut exécuter qu'un seul thread, ou ensemble d'instructions, à la fois. Cependant, le nombre de processus et de threads peut être respectivement de centaines et de milliers. Alors, comment ça marche vraiment? C'est là que le système d'exploitation simule la concurrence pour nous . Le système d'exploitation y parvient en découpant le temps - ce qui signifie en fait que le processeur bascule entre les threads fréquemment, de manière imprévisible et non déterministe.

3. Problèmes de programmation simultanée

Au fur et à mesure que nous discutons des principes et des modèles pour concevoir une application simultanée, il serait sage de comprendre d'abord quels sont les problèmes typiques.

Dans une très large mesure, notre expérience de la programmation simultanée implique l' utilisation de threads natifs avec mémoire partagée . Par conséquent, nous nous concentrerons sur certains des problèmes courants qui en émanent:

  • Exclusion mutuelle (primitives de synchronisation) : les threads entrelacés doivent avoir un accès exclusif à l'état partagé ou à la mémoire pour garantir l'exactitude des programmes . La synchronisation des ressources partagées est une méthode populaire pour parvenir à l'exclusion mutuelle. Il existe plusieurs primitives de synchronisation disponibles à utiliser - par exemple, un verrou, un moniteur, un sémaphore ou un mutex. Cependant, la programmation pour l'exclusion mutuelle est sujette aux erreurs et peut souvent conduire à des goulots d'étranglement de performance. Il y a plusieurs problèmes bien discutés liés à cela, comme l'impasse et le blocage de la vie.
  • Changement de contexte (threads lourds) : chaque système d'exploitation a une prise en charge native, bien que variée, des modules simultanés tels que processus et thread. Comme indiqué, l'un des services fondamentaux fournis par un système d'exploitation est la planification des threads pour qu'ils s'exécutent sur un nombre limité de processeurs par découpage temporel. Maintenant, cela signifie effectivement que les threads sont fréquemment commutés entre différents états . Dans le processus, leur état actuel doit être enregistré et repris. Il s'agit d'une activité chronophage ayant un impact direct sur le débit global.

4. Modèles de conception pour une concurrence élevée

Maintenant que nous comprenons les bases de la programmation simultanée et les problèmes courants qu'elle contient, il est temps de comprendre certains des modèles courants pour éviter ces problèmes. Nous devons réitérer que la programmation simultanée est une tâche difficile qui nécessite une grande expérience. Par conséquent, suivre certains des modèles établis peut faciliter la tâche.

4.1. Concurrence basée sur les acteurs

La première conception que nous allons discuter en ce qui concerne la programmation simultanée s'appelle le modèle d'acteur. Il s'agit d' un modèle mathématique de calcul simultané qui traite fondamentalement tout comme un acteur . Les acteurs peuvent se transmettre des messages et, en réponse à un message, prendre des décisions locales. Cela a été proposé pour la première fois par Carl Hewitt et a inspiré un certain nombre de langages de programmation.

La principale construction de Scala pour la programmation simultanée est celle des acteurs. Les acteurs sont des objets normaux dans Scala que nous pouvons créer en instanciant la classe Actor . De plus, la bibliothèque Scala Actors fournit de nombreuses opérations d'acteurs utiles:

class myActor extends Actor { def act() { while(true) { receive { // Perform some action } } } }

Dans l'exemple ci-dessus, un appel à la méthode de réception à l' intérieur d'une boucle infinie suspend l'acteur jusqu'à ce qu'un message arrive. À l'arrivée, le message est supprimé de la boîte aux lettres de l'acteur et les actions nécessaires sont prises.

Le modèle de l'acteur élimine l'un des problèmes fondamentaux de la programmation simultanée - la mémoire partagée . Les acteurs communiquent par le biais de messages et chaque acteur traite les messages de ses boîtes aux lettres exclusives de manière séquentielle. Cependant, nous exécutons des acteurs sur un pool de threads. Et nous avons vu que les threads natifs peuvent être lourds et, par conséquent, limités en nombre.

Il existe, bien sûr, d'autres modèles qui peuvent nous aider ici - nous les couvrirons plus tard!

4.2. Concurrence basée sur les événements

Les conceptions basées sur les événements résolvent explicitement le problème que les threads natifs sont coûteux à générer et à exploiter. L'une des conceptions basées sur les événements est la boucle d'événements. La boucle d'événements fonctionne avec un fournisseur d'événements et un ensemble de gestionnaires d'événements. Dans cette configuration, la boucle d'événements se bloque sur le fournisseur d'événements et distribue un événement à un gestionnaire d'événements à l'arrivée .

Fondamentalement, la boucle d'événements n'est rien d'autre qu'un répartiteur d'événements! La boucle d'événements elle-même peut s'exécuter sur un seul thread natif. Alors, qu'est-ce qui se passe vraiment dans une boucle d'événements? Regardons le pseudo-code d'une boucle d'événement très simple pour un exemple:

while(true) { events = getEvents(); for(e in events) processEvent(e); }

Fondamentalement, tout ce que fait notre boucle d'événements est de rechercher en permanence des événements et, lorsque des événements sont trouvés, de les traiter. L'approche est vraiment simple, mais elle tire avantage d'une conception événementielle.

La création d'applications simultanées à l'aide de cette conception donne plus de contrôle à l'application. En outre, cela élimine certains des problèmes typiques des applications multithreads - par exemple, le blocage.

JavaScript implémente la boucle d'événements pour offrir une programmation asynchrone . Il maintient une pile d'appels pour garder une trace de toutes les fonctions à exécuter. Il gère également une file d'attente d'événements pour l'envoi de nouvelles fonctions à traiter. La boucle d'événements vérifie constamment la pile d'appels et ajoute de nouvelles fonctions à partir de la file d'attente d'événements. Tous les appels asynchrones sont envoyés aux API Web, généralement fournies par le navigateur.

La boucle d'événements elle-même peut s'exécuter sur un seul thread, mais les API Web fournissent des threads séparés.

4.3. Algorithmes non bloquants

Dans les algorithmes non bloquants, la suspension d'un thread n'entraîne pas la suspension d'autres threads. Nous avons vu que nous ne pouvons avoir qu'un nombre limité de threads natifs dans notre application. Maintenant, un algorithme qui bloque sur un thread réduit évidemment le débit de manière significative et nous empêche de créer des applications hautement simultanées.

Les algorithmes non bloquants utilisent invariablement la primitive atomique de comparaison et d'échange fournie par le matériel sous-jacent . Cela signifie que le matériel comparera le contenu d'un emplacement mémoire avec une valeur donnée, et que s'ils sont identiques, il mettra à jour la valeur avec une nouvelle valeur donnée. Cela peut paraître simple, mais cela nous fournit effectivement une opération atomique qui, autrement, nécessiterait une synchronisation.

Cela signifie que nous devons écrire de nouvelles structures de données et bibliothèques qui utilisent cette opération atomique. Cela nous a donné un énorme ensemble d'implémentations sans attente et sans verrouillage dans plusieurs langues. Java a plusieurs structures de données non bloquantes comme AtomicBoolean , AtomicInteger , AtomicLong et AtomicReference .

Considérez une application où plusieurs threads tentent d'accéder au même code:

boolean open = false; if(!open) { // Do Something open=false; }

De toute évidence, le code ci-dessus n'est pas thread-safe et son comportement dans un environnement multi-thread peut être imprévisible. Nos options ici sont soit de synchroniser ce morceau de code avec un verrou, soit d'utiliser une opération atomique:

AtomicBoolean open = new AtomicBoolean(false); if(open.compareAndSet(false, true) { // Do Something }

Comme nous pouvons le voir, l'utilisation d'une structure de données non bloquante comme AtomicBoolean nous aide à écrire du code thread-safe sans se livrer aux inconvénients des verrous!

5. Prise en charge des langages de programmation

Nous avons vu qu'il existe plusieurs façons de construire un module simultané. Bien que le langage de programmation fasse une différence, c'est surtout la façon dont le système d'exploitation sous-jacent prend en charge le concept. Cependant, comme la concurrence basée sur les threads prise en charge par les threads natifs atteint de nouveaux murs en termes d'évolutivité, nous avons toujours besoin de nouvelles options.

La mise en œuvre de certaines des pratiques de conception dont nous avons parlé dans la dernière section se révèle efficace. Cependant, nous devons garder à l'esprit que cela complique la programmation en tant que telle. Ce dont nous avons vraiment besoin, c'est de quelque chose qui offre la puissance de la concurrence basée sur les threads sans les effets indésirables qu'elle entraîne.

Les fils verts sont une solution à notre disposition. Les threads verts sont des threads qui sont planifiés par la bibliothèque d'exécution au lieu d'être planifiés nativement par le système d'exploitation sous-jacent. Bien que cela ne supprime pas tous les problèmes de concurrence basée sur les threads, cela peut certainement nous donner de meilleures performances dans certains cas.

Maintenant, il n'est pas anodin d'utiliser des threads verts à moins que le langage de programmation que nous choisissons d'utiliser le prenne en charge. Tous les langages de programmation n'ont pas cette prise en charge intégrée. En outre, ce que nous appelons vaguement les threads verts peut être implémenté de manière très unique par différents langages de programmation. Voyons quelques-unes de ces options qui s'offrent à nous.

5.1. Goroutines à Go

Les goroutines dans le langage de programmation Go sont des threads légers. Ils offrent des fonctions ou des méthodes qui peuvent s'exécuter simultanément avec d'autres fonctions ou méthodes. Les goroutines sont extrêmement bon marché car elles n'occupent que quelques kilo-octets de taille de pile, pour commencer .

Plus important encore, les goroutines sont multiplexées avec un nombre moindre de threads natifs. De plus, les goroutines communiquent entre elles en utilisant des canaux, évitant ainsi l'accès à la mémoire partagée. Nous obtenons à peu près tout ce dont nous avons besoin, et devinez quoi - sans rien faire!

5.2. Processus à Erlang

Dans Erlang, chaque thread d'exécution est appelé un processus. Mais ce n'est pas tout à fait comme le processus dont nous avons discuté jusqu'à présent! Les processus Erlang sont légers avec une faible empreinte mémoire et sont rapides à créer et à éliminer avec une faible surcharge de planification.

Sous le capot, les processus Erlang ne sont rien d'autre que des fonctions pour lesquelles le runtime gère la planification. De plus, les processus Erlang ne partagent aucune donnée et communiquent entre eux par transmission de messages. C'est la raison pour laquelle nous appelons ces «processus» en premier lieu!

5.3. Fibres en Java (proposition)

L'histoire de la concurrence avec Java a été une évolution continue. Java a pris en charge les threads verts, au moins pour les systèmes d'exploitation Solaris, pour commencer. Cependant, cela a été interrompu en raison d'obstacles dépassant la portée de ce didacticiel.

Depuis lors, la concurrence en Java concerne les threads natifs et comment les utiliser intelligemment! Mais pour des raisons évidentes, nous pourrions bientôt avoir une nouvelle abstraction de concurrence en Java, appelée fibre. Project Loom propose d' introduire des suites avec des fibres, ce qui peut changer la façon dont nous écrivons des applications concurrentes en Java!

Ceci est juste un aperçu de ce qui est disponible dans différents langages de programmation. Il existe des moyens beaucoup plus intéressants que d'autres langages de programmation ont essayé de gérer avec la concurrence.

De plus, il convient de noter qu'une combinaison de modèles de conception abordés dans la dernière section, ainsi que la prise en charge du langage de programmation pour une abstraction de type fil vert, peut être extrêmement puissante lors de la conception d'applications hautement simultanées.

6. Applications à haute concurrence

Une application du monde réel a souvent plusieurs composants interagissant les uns avec les autres sur le fil. Nous y accédons généralement via Internet et il se compose de plusieurs services tels que le service proxy, la passerelle, le service Web, la base de données, le service d'annuaire et les systèmes de fichiers.

Comment garantir une concurrence élevée dans de telles situations? Explorons certaines de ces couches et les options dont nous disposons pour créer une application hautement simultanée.

Comme nous l'avons vu dans la section précédente, la clé pour créer des applications à haute concurrence est d'utiliser certains des concepts de conception abordés ici. Nous devons choisir le bon logiciel pour le travail - ceux qui intègrent déjà certaines de ces pratiques.

6.1. Couche Web

Le Web est généralement la première couche où les demandes des utilisateurs arrivent, et l'approvisionnement pour une concurrence élevée est inévitable ici. Voyons quelles sont certaines des options:

  • Node (également appelé NodeJS ou Node.js) est un moteur d'exécution JavaScript multiplateforme open source basé sur le moteur JavaScript V8 de Chrome. Node fonctionne assez bien pour gérer les opérations d'E / S asynchrones. La raison pour laquelle Node le fait si bien est qu'il implémente une boucle d'événements sur un seul thread. La boucle d'événements à l'aide de rappels gère toutes les opérations de blocage telles que les E / S de manière asynchrone.
  • nginx est un serveur Web open source que nous utilisons couramment comme proxy inverse parmi ses autres utilisations. La raison pour laquelle nginx offre une concurrence élevée est qu'il utilise une approche asynchrone, basée sur les événements. nginx fonctionne avec un processus maître dans un seul thread. Le processus maître gère les processus de travail qui effectuent le traitement réel. Par conséquent, les processus de travail traitent chaque demande simultanément.

6.2. Couche d'application

Lors de la conception d'une application, il existe plusieurs outils pour nous aider à créer une concurrence élevée. Examinons quelques-unes de ces bibliothèques et frameworks qui nous sont disponibles:

  • Akka est une boîte à outils écrite en Scala pour créer des applications hautement simultanées et distribuées sur la JVM. L'approche d'Akka en matière de gestion de la concurrence est basée sur le modèle d'acteur dont nous avons parlé précédemment. Akka crée une couche entre les acteurs et les systèmes sous-jacents. Le framework gère les complexités de la création et de la planification des threads, de la réception et de la distribution des messages.
  • Project Reactor est une bibliothèque réactive permettant de créer des applications non bloquantes sur la JVM. Il est basé sur la spécification Reactive Streams et se concentre sur le passage efficace des messages et la gestion de la demande (contre-pression). Les opérateurs de réacteurs et les planificateurs peuvent maintenir des débits élevés pour les messages. Plusieurs frameworks populaires fournissent des implémentations de réacteur, notamment Spring WebFlux et RSocket.
  • Netty est une infrastructure d'application réseau asynchrone, orientée événement. Nous pouvons utiliser Netty pour développer des serveurs de protocole et des clients hautement simultanés. Netty exploite NIO, qui est une collection d'API Java qui offre un transfert de données asynchrone via des tampons et des canaux. Il nous offre plusieurs avantages comme un meilleur débit, une latence plus faible, une consommation de ressources moindre et une réduction des copies mémoire inutiles.

6.3. Couche de données

Enfin, aucune application n'est complète sans ses données, et les données proviennent d'un stockage persistant. Lorsque nous discutons de la concurrence élevée en ce qui concerne les bases de données, l'accent reste principalement mis sur la famille NoSQL. Ceci est principalement dû à l'évolutivité linéaire que les bases de données NoSQL peuvent offrir, mais qui est difficile à obtenir dans les variantes relationnelles. Examinons deux outils populaires pour la couche de données:

  • Cassandra est une base de données distribuée NoSQL gratuite et open source qui offre une haute disponibilité, une grande évolutivité et une tolérance aux pannes sur le matériel de base. Cependant, Cassandra ne fournit pas de transactions ACID couvrant plusieurs tables. Ainsi, si notre application ne nécessite pas une cohérence et des transactions fortes, nous pouvons bénéficier des opérations à faible latence de Cassandra.
  • Kafka est une plateforme de streaming distribuée . Kafka stocke un flux d'enregistrements dans des catégories appelées sujets. Il peut fournir une évolutivité horizontale linéaire pour les producteurs et les consommateurs des enregistrements tout en offrant en même temps une fiabilité et une durabilité élevées. Les partitions, les répliques et les courtiers font partie des concepts fondamentaux sur lesquels il fournit une concurrence massivement distribuée.

6.4. Couche de cache

Eh bien, aucune application Web dans le monde moderne qui vise une haute concurrence ne peut se permettre d'accéder à la base de données à chaque fois. Cela nous laisse choisir un cache - de préférence un cache en mémoire qui peut prendre en charge nos applications hautement simultanées:

  • Hazelcast est un magasin d'objets et un moteur de calcul en mémoire distribués, conviviaux pour le cloud et prenant en charge une grande variété de structures de données telles que Map , Set , List , MultiMap , RingBuffer et HyperLogLog . Il a une réplication intégrée et offre une haute disponibilité et un partitionnement automatique.
  • Redis est un magasin de structure de données en mémoire que nous utilisons principalement comme cache . Il fournit une base de données de valeurs-clés en mémoire avec une durabilité facultative. Les structures de données prises en charge incluent des chaînes, des hachages, des listes et des ensembles. Redis a une réplication intégrée et offre une haute disponibilité et un partitionnement automatique. Au cas où nous n'aurions pas besoin de persistance, Redis peut nous offrir un cache en mémoire riche en fonctionnalités, en réseau, avec des performances exceptionnelles.

Bien sûr, nous avons à peine effleuré la surface de ce qui nous est disponible dans notre quête pour créer une application hautement simultanée. Il est important de noter que, plus que les logiciels disponibles, notre exigence doit nous guider pour créer une conception appropriée. Certaines de ces options peuvent convenir, tandis que d'autres peuvent ne pas convenir.

Et n'oublions pas qu'il existe de nombreuses autres options disponibles qui peuvent être mieux adaptées à nos besoins.

7. Conclusion

Dans cet article, nous avons abordé les bases de la programmation simultanée. Nous avons compris certains des aspects fondamentaux de la concurrence et les problèmes qu'elle peut entraîner. En outre, nous avons passé en revue certains des modèles de conception qui peuvent nous aider à éviter les problèmes typiques de la programmation simultanée.

Enfin, nous avons passé en revue certains des frameworks, bibliothèques et logiciels dont nous disposons pour créer une application de bout en bout hautement simultanée.