Concurrence et tolérance aux pannes simplifiées : un didacticiel Akka avec des exemples
Publié: 2022-03-11Le défi
L'écriture de programmes concurrents est difficile. Avoir à gérer des threads, des verrous, des conditions de concurrence, etc. est très sujet aux erreurs et peut conduire à un code difficile à lire, à tester et à entretenir.
Beaucoup préfèrent donc éviter complètement le multithreading. Au lieu de cela, ils utilisent exclusivement des processus à thread unique, s'appuyant sur des services externes (tels que des bases de données, des files d'attente, etc.) pour gérer toutes les opérations simultanées ou asynchrones nécessaires. Bien que cette approche soit dans certains cas une alternative légitime, il existe de nombreux scénarios dans lesquels ce n'est tout simplement pas une option viable. De nombreux systèmes en temps réel - tels que les applications commerciales ou bancaires, ou les jeux en temps réel - n'ont pas le luxe d'attendre la fin d'un processus à un seul thread (ils ont besoin de la réponse maintenant !). D'autres systèmes sont si gourmands en calcul ou en ressources qu'ils prendraient un temps démesuré (des heures, voire des jours dans certains cas) pour s'exécuter sans introduire de parallélisation dans leur code.
Une approche monothread assez courante (largement utilisée dans le monde Node.js, par exemple) consiste à utiliser un paradigme non bloquant basé sur des événements. Bien que cela améliore les performances en évitant les changements de contexte, les verrous et les blocages, cela ne résout toujours pas les problèmes d'utilisation simultanée de plusieurs processeurs (cela nécessiterait le lancement et la coordination entre plusieurs processus indépendants).
Cela signifie-t-il que vous n'avez pas d'autre choix que de vous plonger dans les entrailles des threads, des verrous et des conditions de concurrence afin de créer une application concurrente ?
Grâce au framework Akka, la réponse est non. Ce didacticiel présente des exemples Akka et explore les façons dont il facilite et simplifie la mise en œuvre d'applications distribuées concurrentes.
Qu'est-ce que le framework Akka ?
Akka est une boîte à outils et un environnement d'exécution permettant de créer des applications hautement concurrentes, distribuées et tolérantes aux pannes sur la JVM. Akka est écrit en Scala, avec des liaisons de langage fournies à la fois pour Scala et Java.
L'approche d'Akka pour gérer la concurrence est basée sur le modèle d'acteur. Dans un système basé sur les acteurs, tout est un acteur, de la même manière que tout est un objet dans la conception orientée objet. Une différence clé, cependant - particulièrement pertinente pour notre discussion - est que le modèle d'acteur a été spécifiquement conçu et architecturé pour servir de modèle concurrent alors que le modèle orienté objet ne l'est pas. Plus précisément, dans un système d'acteurs Scala, les acteurs interagissent et partagent des informations, sans aucun présupposé de séquentialité. Le mécanisme par lequel les acteurs partagent des informations entre eux et se chargent mutuellement est la transmission de messages.
Akka crée une couche entre les acteurs et le système sous-jacent de sorte que les acteurs doivent simplement traiter les messages. Toute la complexité de la création et de la planification des threads, de la réception et de la distribution des messages, et de la gestion des conditions de concurrence et de la synchronisation, est reléguée au framework pour une gestion transparente.
Akka adhère strictement au Manifeste Réactif. Les applications réactives visent à remplacer les applications multithread traditionnelles par une architecture qui satisfait une ou plusieurs des exigences suivantes :
- Évènementiel. En utilisant les acteurs, on peut écrire du code qui gère les requêtes de manière asynchrone et utilise exclusivement des opérations non bloquantes.
- Évolutif. Dans Akka, l'ajout de nœuds sans avoir à modifier le code est possible, grâce à la fois au passage de messages et à la transparence de l'emplacement.
- Résilient. Toute application rencontrera des erreurs et échouera à un moment donné. Akka fournit des stratégies de « supervision » (tolérance aux pannes) pour faciliter un système d'auto-guérison.
- Sensible. De nombreuses applications actuelles à hautes performances et à réponse rapide doivent donner un retour d'information rapide à l'utilisateur et doivent donc réagir aux événements de manière extrêmement rapide. La stratégie non bloquante et basée sur les messages d'Akka permet d'atteindre cet objectif.
Qu'est-ce qu'un acteur à Akka ?
Un acteur n'est essentiellement rien de plus qu'un objet qui reçoit des messages et prend des mesures pour les gérer. Il est découplé de la source du message et sa seule responsabilité est de bien reconnaître le type de message qu'il a reçu et d'agir en conséquence.
Lors de la réception d'un message, un acteur peut effectuer une ou plusieurs des actions suivantes :
- Exécuter certaines opérations lui-même (comme effectuer des calculs, conserver des données, appeler un service Web externe, etc.)
- Transférer le message, ou un message dérivé, à un autre acteur
- Instancier un nouvel acteur et lui transmettre le message
Alternativement, l'acteur peut choisir d'ignorer complètement le message (c'est-à-dire qu'il peut choisir l'inaction) s'il le juge approprié.
Pour implémenter un acteur, il est nécessaire d'étendre le trait akka.actor.Actor et d'implémenter la méthode receive. La méthode de réception d'un acteur est appelée (par Akka) lorsqu'un message est envoyé à cet acteur. Son implémentation typique consiste en une correspondance de modèle, comme illustré dans l'exemple Akka suivant, pour identifier le type de message et réagir en conséquence :
import akka.actor.Actor import akka.actor.Props import akka.event.Logging class MyActor extends Actor { def receive = { case value: String => doSomething(value) case _ => println("received unknown message") } }
La correspondance de modèles est une technique relativement élégante de gestion des messages, qui a tendance à produire un code « plus propre » et plus facile à naviguer qu'une implémentation comparable basée sur des rappels. Considérez, par exemple, une implémentation simpliste de requête/réponse HTTP.
Tout d'abord, implémentons cela en utilisant un paradigme basé sur le rappel en JavaScript :
route(url, function(request){ var query = buildQuery(request); dbCall(query, function(dbResponse){ var wsRequest = buildWebServiceRequest(dbResponse); wsCall(wsRequest, function(wsResponse) { sendReply(wsResponse); }); }); });
Comparons maintenant cela à une implémentation basée sur la correspondance de modèles :
msg match { case HttpRequest(request) => { val query = buildQuery(request) dbCall(query) } case DbResponse(dbResponse) => { var wsRequest = buildWebServiceRequest(dbResponse); wsCall(dbResponse) } case WsResponse(wsResponse) => sendReply(wsResponse) }
Bien que le code JavaScript basé sur le rappel soit certes compact, il est certainement plus difficile à lire et à naviguer. En comparaison, le code basé sur la correspondance de modèles permet de voir plus immédiatement quels cas sont pris en compte et comment chacun est traité.
Le système d'acteur
Prendre un problème complexe et le diviser récursivement en sous-problèmes plus petits est une bonne technique de résolution de problèmes en général. Cette approche peut être particulièrement bénéfique en informatique (conformément au principe de responsabilité unique), car elle a tendance à produire un code propre et modularisé, avec peu ou pas de redondance, qui est relativement facile à entretenir.
Dans une conception basée sur les acteurs, l'utilisation de cette technique facilite l'organisation logique des acteurs dans une structure hiérarchique connue sous le nom de système d'acteurs. Le système d'acteurs fournit l'infrastructure à travers laquelle les acteurs interagissent les uns avec les autres.
À Akka, le seul moyen de communiquer avec un acteur est via un ActorRef
. Un ActorRef
représente une référence à un acteur qui empêche d'autres objets d'accéder directement ou de manipuler les éléments internes et l'état de cet acteur. Les messages peuvent être envoyés à un acteur via un ActorRef
en utilisant l'un des protocoles de syntaxe suivants :
-
!
("dire") - envoie le message et revient immédiatement -
?
("demander") - envoie le message et renvoie un futur représentant une réponse possible
Chaque acteur dispose d'une boîte aux lettres à laquelle ses messages entrants sont livrés. Il existe plusieurs implémentations de boîtes aux lettres parmi lesquelles choisir, l'implémentation par défaut étant FIFO.
Un acteur contient de nombreuses variables d'instance pour maintenir l'état lors du traitement de plusieurs messages. Akka garantit que chaque instance d'un acteur s'exécute dans son propre thread léger et que les messages sont traités un par un. De cette manière, l'état de chaque acteur peut être maintenu de manière fiable sans que le développeur ait à se soucier explicitement de la synchronisation ou des conditions de concurrence.
Chaque acteur reçoit les informations utiles suivantes pour effectuer ses tâches via l'API Akka Actor :
-
sender
: unActorRef
à l'expéditeur du message en cours de traitement -
context
: informations et méthodes relatives au contexte dans lequel l'acteur s'exécute (comprend, par exemple, une méthodeactorOf
pour instancier un nouvel acteur) -
supervisionStrategy
: définit la stratégie à utiliser pour récupérer des erreurs -
self
: l'ActorRef
pour l'acteur lui-même
Pour aider à lier ces didacticiels ensemble, considérons un exemple simple de comptage du nombre de mots dans un fichier texte.
Pour les besoins de notre exemple Akka, nous décomposerons le problème en deux sous-tâches ; à savoir, (1) une tâche "enfant" consistant à compter le nombre de mots sur une seule ligne et (2) une tâche "parente" consistant à additionner ces nombres de mots par ligne pour obtenir le nombre total de mots dans le fichier.
L'acteur parent chargera chaque ligne du fichier, puis déléguera à un acteur enfant la tâche de compter les mots de cette ligne. Lorsque l'enfant a terminé, il enverra un message au parent avec le résultat. Le parent recevra les messages avec le nombre de mots (pour chaque ligne) et conservera un compteur pour le nombre total de mots dans l'ensemble du fichier, qu'il renverra ensuite à son invocateur une fois terminé.
(Notez que les exemples de code du didacticiel Akka fournis ci-dessous sont uniquement didactiques et ne concernent donc pas nécessairement toutes les conditions de bord, les optimisations de performances, etc. De plus, une version compilable complète des exemples de code présentés ci-dessous est disponible dans cet essentiel.)
Examinons d'abord un exemple d'implémentation de la classe enfant StringCounterActor
:
case class ProcessStringMsg(string: String) case class StringProcessedMsg(words: Integer) class StringCounterActor extends Actor { def receive = { case ProcessStringMsg(string) => { val wordsInLine = string.split(" ").length sender ! StringProcessedMsg(wordsInLine) } case _ => println("Error: message not recognized") } }
Cet acteur a une tâche très simple : consommer des messages ProcessStringMsg
(contenant une ligne de texte), compter le nombre de mots sur la ligne spécifiée et renvoyer le résultat à l'expéditeur via un message StringProcessedMsg
. Notez que nous avons implémenté notre classe pour utiliser le !
("tell") pour envoyer le message StringProcessedMsg
(c'est-à-dire pour envoyer le message et revenir immédiatement).

OK, tournons maintenant notre attention vers la classe parent WordCounterActor
:
1. case class StartProcessFileMsg() 2. 3. class WordCounterActor(filename: String) extends Actor { 4. 5. private var running = false 6. private var totalLines = 0 7. private var linesProcessed = 0 8. private var result = 0 9. private var fileSender: Option[ActorRef] = None 10. 11. def receive = { 12. case StartProcessFileMsg() => { 13. if (running) { 14. // println just used for example purposes; 15. // Akka logger should be used instead 16. println("Warning: duplicate start message received") 17. } else { 18. running = true 19. fileSender = Some(sender) // save reference to process invoker 20. import scala.io.Source._ 21. fromFile(filename).getLines.foreach { line => 22. context.actorOf(Props[StringCounterActor]) ! ProcessStringMsg(line) 23. totalLines += 1 24. } 25. } 26. } 27. case StringProcessedMsg(words) => { 28. result += words 29. linesProcessed += 1 30. if (linesProcessed == totalLines) { 31. fileSender.map(_ ! result) // provide result to process invoker 32. } 33. } 34. case _ => println("message not recognized!") 35. } 36. }
Beaucoup de choses se passent ici, alors examinons chacune d'elles plus en détail (notez que les numéros de ligne référencés dans la discussion qui suit sont basés sur l'exemple de code ci-dessus) …
Tout d'abord, notez que le nom du fichier à traiter est passé au constructeur WordCounterActor
(ligne 3). Cela indique que l'acteur ne doit être utilisé que pour traiter un seul fichier. Cela simplifie également le travail de codage pour le développeur, en évitant d'avoir à réinitialiser les variables d'état ( running
, totalLines
, linesProcessed
et result
) lorsque le travail est terminé, puisque l'instance n'est utilisée qu'une seule fois (c'est-à-dire pour traiter un seul fichier) puis jeté.
Ensuite, observez que le WordCounterActor
gère deux types de messages :
-
StartProcessFileMsg
(ligne 12)- Reçu de l'acteur externe qui lance initialement le
WordCounterActor
. - Lorsqu'il est reçu, le
WordCounterActor
vérifie d'abord qu'il ne reçoit pas une demande redondante. - Si la requête est redondante,
WordCounterActor
génère un avertissement et rien de plus n'est fait (ligne 16). - Si la demande n'est pas redondante :
-
WordCounterActor
stocke une référence à l'expéditeur dans la variable d'instancefileSender
(notez qu'il s'agit d'uneOption[ActorRef]
plutôt que d'uneOption[Actor]
- voir ligne 9). CetActorRef
est nécessaire pour y accéder ultérieurement et y répondre lors du traitement duStringProcessedMsg
final (qui est reçu d'un enfantStringCounterActor
, comme décrit ci-dessous). -
WordCounterActor
lit ensuite le fichier et, au fur et à mesure que chaque ligne du fichier est chargée, un enfantStringCounterActor
est créé et un message contenant la ligne à traiter lui est transmis (lignes 21 à 24).
-
- Reçu de l'acteur externe qui lance initialement le
-
StringProcessedMsg
(ligne 27)- Reçu d'un
StringCounterActor
enfant lorsqu'il termine le traitement de la ligne qui lui est assignée. - Lorsqu'il est reçu, le
WordCounterActor
incrémente le compteur de lignes du fichier et, si toutes les lignes du fichier ont été traitées (c'est-à-dire lorsquetotalLines
etlinesProcessed
sont égaux), il envoie le résultat final aufileSender
d'origine (lignes 28-31).
- Reçu d'un
Encore une fois, notez qu'à Akka, le seul mécanisme de communication inter-acteurs est la transmission de messages. Les messages sont la seule chose que les acteurs partagent et, puisque les acteurs peuvent potentiellement accéder simultanément aux mêmes messages, il est important qu'ils soient immuables, afin d'éviter les conditions de concurrence et les comportements inattendus.
Il est donc courant de transmettre des messages sous la forme de classes de cas car elles sont immuables par défaut et en raison de leur intégration transparente avec la correspondance de modèles.
Terminons l'exemple avec l'exemple de code pour exécuter l'ensemble de l'application.
object Sample extends App { import akka.util.Timeout import scala.concurrent.duration._ import akka.pattern.ask import akka.dispatch.ExecutionContexts._ implicit val ec = global override def main(args: Array[String]) { val system = ActorSystem("System") val actor = system.actorOf(Props(new WordCounterActor(args(0)))) implicit val timeout = Timeout(25 seconds) val future = actor ? StartProcessFileMsg() future.map { result => println("Total number of words " + result) system.shutdown } } }
Remarquez comment cette fois le ?
méthode est utilisée pour envoyer un message. De cette manière, l'appelant peut utiliser le Future renvoyé pour imprimer le résultat final lorsqu'il est disponible et pour quitter le programme en fermant l'ActorSystem.
Tolérance aux pannes Akka et stratégies de supervision
Dans un système d'acteurs, chaque acteur est le superviseur de ses enfants. Si un acteur ne parvient pas à gérer un message, il se suspend lui-même ainsi que tous ses enfants et envoie un message, généralement sous la forme d'une exception, à son superviseur.
À Akka, la manière dont un superviseur réagit et gère les exceptions qui lui parviennent de ses enfants est appelée stratégie de superviseur. Les stratégies de superviseur constituent le mécanisme principal et direct par lequel vous définissez le comportement de tolérance aux pannes de votre système.
Lorsqu'un message signalant une panne parvient à un superviseur, celui-ci peut effectuer l'une des actions suivantes :
- Reprendre l'enfant (et ses enfants), en gardant son état interne. Cette stratégie peut être appliquée lorsque l'état enfant n'a pas été corrompu par l'erreur et qu'il peut continuer à fonctionner correctement.
- Redémarrez l'enfant (et ses enfants), en effaçant son état interne. Cette stratégie peut être utilisée dans le scénario inverse de celui qui vient d'être décrit. Si l'état enfant a été corrompu par l'erreur, il est nécessaire de réinitialiser son état avant de pouvoir l'utiliser dans le futur.
- Arrêter l'enfant (et ses enfants) définitivement. Cette stratégie peut être utilisée dans les cas où la condition d'erreur n'est pas considérée comme rectifiable, mais ne compromet pas le reste de l'opération en cours d'exécution, qui peut être effectuée en l'absence de l'enfant défaillant.
- Arrêtez-vous et escaladez l'erreur. Employé lorsque le superviseur ne sait pas comment gérer l'échec et qu'il le transmet donc à son propre superviseur.
De plus, un acteur peut décider d'appliquer l'action uniquement aux enfants ratés ou également à ses frères et sœurs. Il existe deux stratégies prédéfinies pour cela :
-
OneForOneStrategy
: applique l'action spécifiée à l'enfant défaillant uniquement -
AllForOneStrategy
: applique l'action spécifiée à tous ses enfants
Voici un exemple simple, utilisant la OneForOneStrategy
:
import akka.actor.OneForOneStrategy import akka.actor.SupervisorStrategy._ import scala.concurrent.duration._ override val supervisorStrategy = OneForOneStrategy() { case _: ArithmeticException => Resume case _: NullPointerException => Restart case _: IllegalArgumentException => Stop case _: Exception => Escalate }
Si aucune stratégie n'est spécifiée, la stratégie par défaut suivante est utilisée :
- S'il y a eu une erreur lors de l'initialisation de l'acteur ou si l'acteur a été tué, l'acteur est arrêté.
- S'il y avait un autre type d'exception, l'acteur est simplement redémarré.
L'implémentation fournie par Akka de cette stratégie par défaut est la suivante :
final val defaultStrategy: SupervisorStrategy = { def defaultDecider: Decider = { case _: ActorInitializationException ⇒ Stop case _: ActorKilledException ⇒ Stop case _: Exception ⇒ Restart } OneForOneStrategy()(defaultDecider) }
Akka permet la mise en œuvre de stratégies de superviseur personnalisées, mais comme l'avertit la documentation Akka, faites-le avec prudence car des implémentations incorrectes peuvent entraîner des problèmes tels que des systèmes d'acteurs bloqués (c'est-à-dire des acteurs suspendus de manière permanente).
Transparence de l'emplacement
L'architecture Akka prend en charge la transparence de l'emplacement, permettant aux acteurs d'être totalement agnostiques quant à l'origine des messages qu'ils reçoivent. L'expéditeur du message peut résider dans la même JVM que l'acteur ou dans une JVM distincte (s'exécutant sur le même nœud ou sur un nœud différent). Akka permet de traiter chacun de ces cas de manière totalement transparente pour l'acteur (et donc le développeur). La seule mise en garde est que les messages envoyés sur plusieurs nœuds doivent être sérialisables.
Les systèmes Actor sont conçus pour fonctionner dans un environnement distribué sans nécessiter de code spécialisé. Akka nécessite uniquement la présence d'un fichier de configuration ( application.conf
) qui spécifie les nœuds auxquels envoyer des messages. Voici un exemple simple de fichier de configuration :
akka { actor { provider = "akka.remote.RemoteActorRefProvider" } remote { transport = "akka.remote.netty.NettyRemoteTransport" netty { hostname = "127.0.0.1" port = 2552 } } }
Quelques conseils de séparation…
Nous avons vu comment le framework Akka permet d'atteindre la simultanéité et des performances élevées. Cependant, comme l'a souligné ce tutoriel, il y a quelques points à garder à l'esprit lors de la conception et de la mise en œuvre de votre système afin d'exploiter au maximum la puissance d'Akka :
- Dans la mesure du possible, chaque acteur doit se voir attribuer la plus petite tâche possible (comme indiqué précédemment, conformément au principe de responsabilité unique)
Les acteurs doivent gérer les événements (c'est-à-dire traiter les messages) de manière asynchrone et ne doivent pas bloquer, sinon des changements de contexte se produiront, ce qui peut nuire aux performances. Concrètement, il est préférable d'effectuer des opérations de blocage (IO, etc.) dans un Future pour ne pas bloquer l'acteur ; c'est à dire:
case evt => blockingCall() // BAD case evt => Future { blockingCall() // GOOD }
- Assurez-vous que vos messages sont tous immuables, car les acteurs qui les transmettent les uns aux autres s'exécuteront tous simultanément dans leurs propres threads. Les messages modifiables sont susceptibles d'entraîner un comportement inattendu.
- Étant donné que les messages envoyés entre les nœuds doivent être sérialisables, il est important de garder à l'esprit que plus les messages sont volumineux, plus il faudra de temps pour les sérialiser, les envoyer et les désérialiser, ce qui peut avoir un impact négatif sur les performances.
Conclusion
Akka, écrit en Scala, simplifie et facilite le développement d'applications hautement simultanées, distribuées et tolérantes aux pannes, cachant une grande partie de la complexité au développeur. Rendre justice à Akka nécessiterait bien plus que ce seul tutoriel, mais j'espère que cette introduction et ses exemples étaient suffisamment captivants pour vous donner envie d'en savoir plus.
Amazon, VMWare et CSC ne sont que quelques exemples d'entreprises leaders qui utilisent activement Akka. Visitez le site Web officiel d'Akka pour en savoir plus et pour savoir si Akka pourrait également être la bonne réponse pour votre projet.