Programmation Asynchrone et Concurrente structurée
Avec la PinkTeamAxa, nous avons assisté à une journée de DevFest 2022 à Lille très enrichissante. Au-delà des conférences pures tech très intéressantes, j’ai également apprécié l’aspect organisationnel qui était résolument tourné vers la réduction de l’empreinte écologique de la journée.
J’ai aussi énormément apprécié la place de choix réservée à la Keynote de Nicolas qui traite de la sensibilisation sur le handicap, et l’accessibilité à toutes et tous les citoyens aux outils digitaux.
Sans oublier, toutes les informations affichées permettant de mettre le respect, l’inclusion, la diversité et la bienveillance au centre de la journée.
Parmi toutes les conférences, j’ai beaucoup apprécié la conférence sur le projet Loom qui traite de la programmation asynchrone et concurrente structurée, la conférence sur le projet Spring native et Ahead of time avec springboot 3.0.
Dans la suite de ce texte, j’ai choisi de vous faire un retour d’expérience sur le projet Loom et sa programmation asynchrone et concurrente structurée.
Chronologie de la concurrence en Java
Il faut savoir que la programmation concurrente existe depuis les débuts de Java.
En 1995, il y avait les Threads, les Runnables, les Joins et les blocks synchronized.
En 2014, apparait le package java.util.concurrent avec les Callables, modèle ExecutorService et les structures de données comme ConcurrentMap et BlockingQueue.
En 2011 (java 7) et 2014(java 8), nous avons assisté à la sortie de fork/join et ParallelStream par exemple.
Problématique
Il faut savoir que l’implémentation du modèle actuel de programmation concurrente est basée sur les threads du système d’exploitation sous-jacent. A partir de là, les contraintes sont beaucoup trop fortes pour que Java puisse répondre aux attentes actuelles de la programmation concurrente en termes d’efficience. Et être au même niveau que les autres langages du marché comme Node, Go ou Kotlin pour ne citer que ces 3.
Nous identifions principalement 2 problèmes que je vais introduire ci-dessous :
- De nos jours, les applications peuvent recevoir des millions de transactions ou de requêtes d’utilisateur. Compte tenu du nombre limité de threads OS, avoir un thread par utilisateur ou transaction n’est pas toujours faisable. Sans parler du coup de création et blocage de thread.
- D’une manière générale, après chaque requête, une bonne partie des applications concurrentes a besoin de faire une synchronisation entre threads.
C’est à cause de cette action que nous avons un switch très couteux de contexte entre les différents threads OS.
La première solution à cette problématique est d’utiliser les APIs concurrentes asynchrones à l’instar des completableFuture et RxJava.
Ils ont l’avantage de ne pas bloquer les threads OS, tout en offrant une construction plus fine de la concurrence au-dessus des threads Java.
Cependant, cette solution est très difficile à maintenir, analyser et/ou debugger en cas de problème.
Par conséquent, a émergé le besoin d’avoir en Java un modèle de concurrence plus souple et léger, travaillant au niveau JVM, en faisant abstraction des threads OS. La communité openJDK, a initié le projet Loom pour ce besoin.
Projet Loom
Loom est un projet de la communauté openJDK, il a pour but de fournir des fonctionnalités JVM et des APIs qui vont permettre de faire de la programmation concurrente légère.
Les 2 concepts que j’ai retenu du projet Loom sont l’introduction des threads virtuels (Fibers) et la programmation concurrente structurée.
Le projet Loom est accessible en mode preview sur Java 19, il faut l’activer explicitement en utilisant l’option :
cf: fonctionnement
Threads virtuels (Fibers)
Les threads Java classiques s’appuient sur le système d’exploitation sous-jacent, ce qui peut nous limiter en termes de nombre de threads instanciés simultanément.
Avec Loom, nous avons la notion de Threads virtuels qui sont créés au JVM en faisant une abstraction des threads du système d’exploitation.
Le thread virtuel peut se balader entre plusieurs threads réels. Ce modèle nécessite tout de même de continuer à gérer la concurrence d’accès aux données.
Pour lancer un thread virtual :
NB: dans la suite je vais utiliser le terme de « Continuations » qui est un concept java.
L’implémentation de la classe Fibers qui porte les threads virtuels ressemble beaucoup à l’implémentation des threads classiques.
Cependant, il faut noter 2 différences fondamentales :
- Les threads virtuels peuvent envelopper n’importe quelle tâche dans une continuation interne. Cela permet à la tâche de se suspendre et reprendre au niveau JVM et pas au niveau du système d’exploitation.
- L’utilisation du forkJoinPool en mode asynchrone, comme scheduler par défaut.
Maintenant parlons un plus précisément des concepts de « continuations » ainsi que du scheduler par défaut
Continuations
De manière simplifié, nous pouvons définir une continuation comme étant une séquence d’instructions pouvant être facilement interrompue, stockée dans un tas, puis reprise plus tard au niveau de son point d’interruption.
Chaque continuation a un point d’entrée et un point d’interruption ou point de yield. Ce point de yield correspond à l’endroit où l’exécution a été stoppée.
A chaque fois que l’exécution reprend, il partira du dernier point d’interruption. Il est important de ne pas oublier que cette suspension/reprise va s’effectuer dans la JVM et pas au niveau du système d’exploitation. Ce qui est non négligeable quand il faut faire un switch de contexte, créer ou stopper un thread.
Pour utiliser aisément les continuations, il y a 5 choses à connaitre :
- Scope : Une continuation s’exécute dans un scope fourni par la classe ContinuationScope.
- Runnable : La classe Continuation prend en arguments un scope et un Runnable (type : () → void)
- .yield : Yield est la méthode qui permet d’interrompre un continuation.
- .run : c’est la méthode qui permet de lancer ou reprendre
- .isDone : cette méthode permet de vérifier si l’exécution s’est terminée
Exemple de continuation :
Taches et Scheduler
Nous avons vu que les problématiques de coût de création et de switch de contexte sont liées à l’utilisation du scheduler OS.
En plus de l’implémentation des threads virtuels, le projet Loom utilise le concept de ForkJoinPool en mode asynchrone comme scheduler par défaut. Cela aura l’avantage de profiter du work-stealing algorithm sur lequel est basé le forkJoinPool. Ainsi, chaque thread va maintenir un tas de tâches et exécuter la tâche en haut de ce tas. Le plus intéressant réside dans le fait qu’aucun thread ne reste inactif parce qu’il n’aurait plus de tâche. Si le thread 1 n’a plus de tâche à exécuter, il va en prendre dans le tas des autres threads.
Avantages
Le principal avantage de Loom réside dans le fait qu’il introduit les threads virtuels dont la création et les blocages sont très peu coûteux. La mise en place du forkJoinPool asynchrone avec le work-steal algorithm n’est pas négligeable non plus en matière d’optimisation de l’utilisation des ressources CPU et mémoire.
Inconvénients
Le gros point noir de cette approche réside dans le fait que le schudeler du CPU n’a aucune idée du moment idéal pour mettre en pause votre exécution.
Donc, il pourrait faire le changement de contexte au moment d’un cas calcul coûteux impliquant une réelle utilisation maximale du CPU. Ce qui ne serait pas top, car le travail en cours n’est pas terminé, et l’autre code ne peut l’utiliser en l’état.
Un autre point réside dans le fait que cette feature est encore en preview, donc pas encore complètement stabilisée et éprouvée.
Etat de l’art
L’implémentation du projet Loom est très fortement inspirée d’une implémentation du modèle concurrent de Erlang. Il y a Go aussi qui dispose des goroutines qui sont inspirés des coroutines de Kotlin, sans oublier l’approche async/wait de Javascript. Même si le modèle implémenté dans le projet Loom est diffèrent de celui implémenté dans Node, ce dernier reste une alternative sérieuse pour faire de la concurrence.
En définitive, avec le projet Loom les applications Java commencent à avoir un modèle de concurrence efficient et comparable à ce que font les autres langages du marché.
En revanche, ne pas oublier que cette feature n’est pas encore stabilisée, et qu’elle pourrait changer complètement avant sa sortie officielle.
Donc, de mon point de vue à l’instant T, il ne faut l’utiliser qu’en environnement de test/démo en activant le mode preview de Java 19.