Le projet Loom en Java : une (r)évolution pour vos threads

La gestion de la concurrence en Java est une préoccupation de longue date qui a régulièrement fait l’objet d’améliorations par les architectes du JDK. Les principales évolutions sont les suivantes :
Dès les débuts de Java, un modèle de programmation concurrente a été introduit au niveau du langage avec la classe Thread, l’interface Runnable et les blocs synchronisés. Cela a servi de fondation pour la gestion de la concurrence jusqu'à aujourd'hui.

  • Java 1.5, avec les concurrency utilities du package java.util.concurrent a introduit le framework Executor. Celui-ci a permis de standardiser l’invocation, l’ordonnancement, l’exécution et le contrôle des tâches asynchrones. Les locks ont également été ajoutés.
  • Java 7 a par la suite apporté le framework fork/join pour simplifier le calcul parallèle… même si son utilisation est tout sauf triviale !
  • Java 8 a ensuite proposé la classe CompletableFuture pour simplifier l’exécution de tâches asynchrones en les combinant, tout en prenant en charge la gestion des exceptions.

Si les possibilités offertes par le langage Java pour gérer la concurrence se sont peu à peu étoffées, toutes ces fonctionnalités reposent sur la même fondation : les threads Java introduits dès 1995. Or, ceux-ci présentent des limitations qui deviennent de plus en plus criantes et un nouveau type d’abstraction devient nécessaire. C’est la raison pour laquelle le projet Loom a vu le jour en 2017 afin de proposer un nouveau modèle d’exécution. Celui-ci introduit les threads virtuels (initialement nommés fibers) qui sont une alternative aux threads plateforme que nous connaissons actuellement. Ils sont de plus accompagnés d’un nouveau modèle de programmation concurrente : la concurrence structurée (structured concurrency). Ces deux fonctionnalités ont fait l’objet de JEP (JDK Enhancement Proposal) présentées récemment dans le JDK 19 (septembre 2022). Une seconde version non définitive est attendue pour le JDK 20 au mois de mars 2023. De plus, le JDK 20 contiendra également la JEP des Scoped values, qui est une sorte de version 2 des ThreadLocal (fonctionnalité très peu utilisée par les développeurs finaux). La publication de toutes ces JEP est la raison pour laquelle le projet Loom est revenu sur le devant de la scène, même s’il est actif depuis plusieurs années.

JDK 19 JDK 20
Virtual Threads JEP 425 (Preview) JEP 436 (Second preview)
Structured Concurrency JEP 428 (Incubator) JEP 437 (Second Incubator)
Scoped Values JEP 429 (Incubator)

L’objectif de cet article est de présenter les threads virtuels, fonctionnalité au cœur du projet Loom. Pour cela, nous commencerons par un détour en rappelant les motivations qui ont poussé au développement de la programmation asynchrone. Puis, nous présenterons en quoi les threads virtuels de Loom offrent une alternative à ce modèle de programmation pour répondre à une même préoccupation tout en fournissant certains bénéfices. Enfin, nous traiterons de l’opportunité d’avoir ou non recours aux threads virtuels dans un contexte où, de notre point de vue, certains articles trop enthousiastes manquent cruellement de nuances.

De la programmation impérative à la programmation asynchrone

De manière générale, les tâches exécutées par un programme peuvent être réparties en deux catégories :

  • L’exécution de logique et de calculs, qui utilise pour cela intensivement le processeur
  • Des entrées sorties (par exemple pour communiquer avec une base de données ou avec une interface réseau) durant lesquelles l’exécution est suspendue, en attente.

L’exécution d’un programme Java ne fait pas exception à cette répartition. Les threads Java actuels (threads plateforme), qui sont mappés sur des threads du système d’exploitation, passent habituellement une partie importante de leur temps à être bloqués, en attente d’entrée/sortie. À titre d’exemple, il n’est pas rare d’attendre quelques dizaines de millisecondes une entrée/sortie sur la carte réseau, quand le temps de préparation et de traitement d’une requête est de l’ordre de quelques dizaines de nanosecondes : le rapport entre les deux est de l’ordre du million !

Pour que l’efficacité globale du programme augmente (et pour gérer la concurrence), la solution employée historiquement est de multiplier le nombre de threads. Ainsi, quand un thread est bloqué, c’est un autre thread qui a besoin de faire des calculs qui est exécuté par le processeur. C’est ce qui est par exemple très classiquement employé par la plupart des serveurs web qui utilisent un pool de worker threads pour répondre aux requêtes.

Cependant, cette approche dans laquelle chaque tâche est traitée de bout en bout par un thread n’est pas satisfaisante. Les threads plateforme étant liés à des threads système, il n’est pas possible de multiplier à l’infini leur nombre. Ils passent beaucoup de temps bloqués en attente d’entrée/sorties, et le CPU reste largement sous-utilisé. Rappelons que le facteur de temps entre les deux peut atteindre un million !

Pour pallier ces limites, la programmation asynchrone (avec notamment la programmation réactive, qui apporte en plus la back-pressure) a connu un fort essor en Java durant la décennie 2010. Avec cette approche, on cherche à maximiser l’utilisation du processeur par les threads système, en retirant les temps morts d’attente des entrées/sorties. Pour cela, on va quitter le modèle dans lequel une tâche est affectée à un unique thread tout au long de son exécution. Au lieu de cela, les tâches vont être soumises à un petit pool de threads chargé de les exécuter les unes à la suite des autres. Cependant, à l’inverse de précédemment, quand une entrée sortie va être demandée, elle ne va pas bloquer le thread. La tâche est alors déchargée du thread et elle sera rappelée de manière asynchrone une fois le résultat de l’entrée/sortie obtenu. Et dans l'intervalle de temps, d’autres tâches peuvent être exécutées par le même thread.

Grâce à cette approche, les threads du système d’exploitation sont toujours occupés, et jamais bloqués en attente d’entrée/sortie. Malheureusement, cela vient avec une contrainte majeure. Les programmes doivent être entièrement réécrits pour être découpés en morceaux qui sont rappelés les uns après les autres de manière asynchrone lors des notifications de fin d’entrée/sortie.  Plus qu’une réécriture, c’est même un changement radical de manière de programmer. Le code devient totalement asynchrone et donc, bien plus difficile à lire, à écrire, à tester et à faire évoluer. Et lors du débogage, les stack traces deviennent illisibles, car le fil logique de l’exécution se trouve éclaté en plusieurs morceaux pouvant être exécutés sur des threads différents. Enfin, toute erreur de programmation se paie au prix fort. Si par inadvertance, une tâche bloque un thread au lieu de demander à être rappelée de manière asynchrone, la performance globale du système s’effondre, car le nombre de threads disponibles est dans cette approche volontairement très limité (généralement deux fois le nombre de cœurs CPU) pour éviter de perdre du temps lors des changements de contexte.

Voici un exemple extrêmement simple pour illustrer cela. Nous souhaitons retrouver le profil d’un utilisateur. Pour cela, nous allons chercher son id à partir de son nom, puis son profil à partir de l’id.

Nous allons écrire cela de manière asynchrone, en utilisant les CompletableFuture

public void findBobWithFuture() throws InterruptedException, ExecutionException {
  CompletableFuture<Integer> futureId = CompletableFuture.supplyAsync(() -> findUserIdByName("Bob"));
  CompletableFuture<User> futureUser = futureId.thenApply(id -> findUserById(id));
  User user = futureUser.get();
  System.out.println(user);
}

Non seulement le code n’est pas très lisible (la logique est pourtant très simple !) mais en plus si on souhaite débugger la méthode findUserIdByName, on obtient la stack trace suivante :

Tout cela pour un programme de quatre lignes, dont une d'affichage !

En conclusion, la programmation impérative est simple à mettre en œuvre, mais utilise extrêmement mal les ressources du système quand le programme effectue des entrées/sorties. La programmation asynchrone est bien plus efficace, adaptée à un haut niveau de concurrence, mais vient avec un niveau de complexité important et tout le code doit être écrit selon cette approche.

Mais alors… pourquoi ne pas chercher une nouvelle voie, qui puisse concilier le meilleur des deux mondes ? Écrire le code de manière purement impérative et séquentielle, pour qu’il soit ensuite exécuté sans bloquer le thread porteur, comme s’il avait été écrit de manière asynchrone avec des callbacks. C’est justement la promesse des threads virtuels proposés par le projet Loom !

Les threads virtuels

Le projet Loom vient proposer une approche basée sur les threads virtuels (virtual threads, initialement nommés fibers). Contrairement aux threads plateforme dont nous disposons actuellement en Java, un thread virtuel n’est pas associé à un thread système déterminé. Au lieu de cela, un thread virtuel peut être attaché ou détaché d’un thread système à la demande. Son exécution se déroule de la manière suivante : une fois créé, un thread virtuel est attaché à un thread système “porteur” (carrier thread) sélectionné au sein d’un pool. Quand le thread virtuel doit faire une opération bloquante d’entrée sortie, il est mis en pause (opération Yield) et détaché du thread système, laissant ce dernier libre pour l’exécution d’un autre thread virtuel. Une fois l’opération bloquante terminée, le thread virtuel est rattaché à un thread système (grâce à une Continuation) pour poursuivre son exécution. Il y a donc bien un mécanisme de callback, mais il est géré par la JVM. Et côté développement, c’est du code purement impératif et séquentiel qui est écrit. Et pour le débogage, il n’y aura pas de stack trace avec des callbacks, même si le virtual thread a été déchargé du thread système à un moment donné.

Si le code précédent est exécuté sur un thread virtuel, et qu’aucun appel ne risque de bloquer le carrier thread (pas de bloc synchronized, pas de code natif bloquant,...) il est possible de le réécrire de la manière suivante :

public void findBobWithLoom() {
  Integer id = findUserIdByName("Bob");
  User user = findUserById(id);
  System.out.println(user);
}

Même s’il est écrit de manière séquentielle, il sera bien exécuté comme s’il avait été écrit avec des callbacks, sans bloquer le thread porteur ! Et les stack traces sont alors nettement plus claires :

Mieux encore, les méthodes d’entrée sortie peuvent détecter si l’exécution se déroule sur un thread virtuel ou un thread plateforme. Dans le premier cas, une implémentation bloquant le virtual thread mais pas le carrier thread sera utilisée. Dans le second cas, l’exécution sera totalement bloquante et séquentielle. Cette approche est notamment fortement étudiée par le framework Quarkus qui prépare activement l’arrivée du projet Loom.

Au terme de cette première approche, on comprend mieux les caractéristiques clés des threads virtuels :

  • Ils sont extrêmement légers, car ils ne sont pas couplés nativement à un thread système. Il est donc possible d’en créer un très grand nombre, d’autant plus qu’ils ont une très faible empreinte mémoire.
  • Étant de petits objets, ils sont prévus pour être collectés par le garbage collector. Il n’est plus nécessaire de chercher à les réutiliser au sein d’un pool.
  • Ils sont optimisés pour être attachés et détachés très rapidement d’un thread système. De plus, leur ordonnancement est extrêmement rapide, car il est pris en charge directement par la JVM, et non par le système d’exploitation.

Vers un accroissement magique des performances ?

C’est à partir de ce constat que certains articles concluent que les threads plateforme vont tous être remplacés par les threads virtuels, et que nos programmes vont ainsi gagner radicalement en efficacité sans réécriture. Or, cela n’a jamais été un objectif de Loom, et cela est d’ailleurs exprimé clairement dans les non-goals des JEP 425 et 436. Voyons pourquoi.

La première raison est que les threads plateforme et les threads virtuels ne sont pas comparables et ne doivent pas être utilisés de la même manière. De par leurs caractéristiques intrinsèques, les threads virtuels sont conçus pour être utilisés à très large échelle. C’est la raison pour laquelle transformer un faible nombre de threads plateforme en threads virtuels n'apportera aucun bénéfice. Il faudra au contraire découper l’exécution des threads existants en plus petites unités pouvant être exécutées de manière concurrente. Ce n’est qu’alors, en passant de milliers de threads plateforme à des millions de threads virtuels que des gains substantiels pourront être observés.

Par ailleurs, nous avons vu que les threads virtuels ne peuvent pas s’exécuter seuls. Ils ont besoin pour cela de threads systèmes qui vont porter leur exécution. La JVM va ensuite ordonnancer les threads virtuels sur le pool des threads porteurs, en gérant elle-même les changements de contexte. Avec cette approche, on retombe sur la même contrainte qu’avec la programmation réactive : bloquer les threads porteurs va se payer au prix fort, et cela doit à tout prix être évité. Or, il est un cas bien connu où cela ne sera pas possible : quand une opération bloquante d’entrée/sortie est effectuée au sein d’un bloc synchronize, la JVM n’est pas en mesure de détacher le thread virtuel du thread porteur qui se trouve ainsi bloqué. Si cette situation se reproduit fréquemment avec une longue durée de blocage, les performances globales de l’application vont être fortement dégradées. C'est d'ailleurs la raison pour laquelle la JEP 425 prévoir l’ajout d’un nouveau flag (jdk.tracePinnedThreads) au Java Flight Recorder pour identifier les threads dans cette situation… et inciter les développeurs à mettre à jour leur code en conséquence.

Si le projet Loom peut tenir sa promesse d’exécuter de manière asynchrone du code écrit de manière purement séquentielle (donc plus lisible et mieux testable), le gain de performance ne sera pas magiquement au rendez-vous. Il faudra pour cela multiplier le nombre de threads virtuels, tout en prenant garde que ceux-ci ne bloquent pas les threads porteurs.

Par ailleurs, un nouveau problème va émerger. En multipliant le nombre de threads virtuels qui pourront être exécutés de manière concurrente, le débogage va se trouver fortement complexifié. Qui peut prétendre comprendre l'exécution d'un programme avec des milliers de threads exécutés en parallèle ? Et de toute façon, nos IDE ne sont pas adaptés à cela, avec leur petit panneau listant à plat les threads exécutés. C’est l’une des raisons pour laquelle les concepts de structured concurrency ont été développés dans le cadre du projet Loom… mais il faudrait un autre article pour expliquer cela !

Conclusion

Les virtual threads proposés par le projet Loom sont encore loin d’être disponibles en version définitive. Cependant, leur développement est déjà suffisamment avancé pour se faire une première idée de leur potentiel. Leur ambition est de permettre aux développeurs Java de continuer à écrire du code purement impératif et séquentiel, tout en bénéficiant à l’exécution de l'efficacité de la programmation asynchrone.

Cependant, des gains de performance ne pourront être obtenus que dans les programmes utilisant de manière très intensive la concurrence avec de nombreuses (et surtout, longues) opérations d’entrée/sortie. Il est donc illusoire de penser qu’il suffira de transformer tous les threads plateforme en virtual threads pour obtenir systématiquement et magiquement des gains de performance.

Avec la multiplication du nombre de threads virtuels, de nouveaux paradigmes de programmation vont être nécessaires afin de gérer simplement l’exécution massivement concurrente de tâches asynchrones. C’est justement le propos de la JEP 428 qui fait elle aussi partie du projet Loom.

Dans tous les cas, le chemin reste encore long avant que toutes les JEP du projet Loom atteignent leur version définitive, et plus encore, que les threads virtuels soient parfaitement intégrés aux frameworks que nous utilisons tous les jours (Spring Quarkus,...). Pour cela, il faudra certainement patienter jusqu’en 2025, mais dès maintenant, nous pouvons nous préparer à cette évolution majeure de la plateforme Java.


Si vous souhaitez en savoir plus sur le projet Loom, en complément de la lecture de la JEP 436, je vous recommande les ressources suivantes que j’ai utilisées pour écrire cet article :


Image par Rhugved Kandpile de Pixabay