26 août 2016

Loom : La fin de la programmation asynchrone

Design & Code

Vincent

Lepore

Image ordinateur.

26 août 2016

Loom : La fin de la programmation asynchrone

Design & Code

Vincent

Lepore

Image ordinateur.

26 août 2016

Loom : La fin de la programmation asynchrone

Design & Code

Vincent

Lepore

Image ordinateur.

La 11ème édition du Devoxx France s’est tenue à Paris au Palais des Congrès du mercredi 12 au vendredi 14 avril 2023.A cette occasion, José Paumard nous a offert une conférence dans l’amphi Maillot vendredi en fin de matinée.

Passionné de programmation informatique depuis plus de 20 ans, José Paumard a fait ses débuts en assembleur et en C, pour les machines parallèles SIMD, avant d'adopter Java comme langage orienté objet.

Il apporte son expertise et sa capacité d'analyse et de synthèse à de nombreux projets, principalement dans les couches basses, proches des données, mais aussi sur la conception d'UI complexes, ou dans le navigateur.

Docteur en mathématiques appliquées et informatique, maître de conférences à l'Université Paris Nord pendant 15 ans, José Paumard est également passionné par l'éducation et la transmission des savoirs.

Il est membre du Paris Java User Group français, co-organisateur de la conférence Devoxx France, et surtout membre du Java Platform Group chez Oracle en tant que Java Developer Relations depuis avril 2021. Autant dire qu’on est dans du lourd !

Bref. S’il y avait bien une conférence à ne pas rater ce vendredi 14 avril, c’était bien la sienne.

Intitulé Le futur de java : programmation asynchrone avec Loom, il aurait aussi bien pu l’appeler La fin de la programmation asynchrone, comme il s’est plu à le mentionner dès le début de sa conférence.

Et cet article tâchera de vous expliquer pourquoi.

1. Loom

Loom est un projet open-source en cours de développement par Oracle pour améliorer la gestion des threads et l'exécution asynchrone dans Java en fournissant des fonctionnalités plus légères, plus flexibles et plus expressives pour les développeurs.

Au niveau de l’adoption, Loom est une petite révolution dans l’écosystème java.

Tous les grands framework tel que Helidon, Tomcat, Spring, Springboot, Vertx, Jetty, Quarkus, vont se mettre à jour pour fonctionner sur les technologies Loom.

L'une des technologies les plus importantes est la possibilité de créer des threads virtuels.

Et les threads virtuels, c’est pour septembre 2023 !

En effet, José PAUMARD nous a annoncé que le mardi 11 avril à 6h18 (3 jours avant la conférence), la JEP 444 était passé en mode intégré.

Cela signifie que le code des threads virtuels est actuellement dans la branche du JDK 21.

Nous devrions avoir les threads virtuels en version finale en 21 (qui est la prochaine LTS).

Pour information, Loom est en preview depuis 2019, et la taille de la pull request qui a permis d’intégrer Loom en preview il y a 2 ans dans le JDK faisait plus de 100 000 lignes de code.

2. La programmation concurrente – piqûre de rappel.

2.1. Historique

3 grandes étapes :

  • 1995 : Thread, Runnable, synchronized()

Un modèle de programmation concurrent, dans le langage dès le début.

Java est le seul langage qui fasse cela.

  • 2004 : ExecutorService, Callable, locks

Date charnière avec l’introduction des processeurs multicœur, ce qui change pas mal de chose en termes de programmation concurrente et de paradigmes.

On a alors une API assez importante qui s’appelle java.util.concurrent, intégré dans le jdk et développé par Doug Lea, et qui apporte cette nouvelle notion : la tâche.

On avait Runnable. On a dorénavant Callable

On avait les threads, on a dorénavant les pools de threads: l’executorservice

Avec l’executorservice, vous soumettez une tâche qui vous rend un Future afin de pouvoir interagir avec cette tâche au travers de ce Future.

  • 2014 : Fork / Join, parallel streams, CompletableFuture

Avec java 8, apparait le fork/join framework, construit sur un fork/join pool (qui est un autre modèle de pool de thread).

Avant 2014, avec l’excutorservice, on avait une file d’attente.

On envoyait nos tâches dedans et les threads y faisaient leur marché.

Depuis 2014, avec le fork/join pool, il y a une file d’attente par thread.

Quand un thread génère des tâches, on va les mettre préférentiellement dans sa file d’attente. Mais si jamais il est trop occupé et que d’autres threads à coté ne font rien, alors ces derniers vont faire leur marché dans la file d’attente du thread occupé.

C’est ce qu’on appelle le work stealing.

Enfin, on a une deuxième API en java 8, qui s’appelle CompleatableFuture, quipermet de faire de la programmation asynchrone.

Mais il y a une chose qui reste la même sur toute ces évolutions, et qui représente tout de même plus de 25 ans d’engineering dans le domaine de la programmation concurrente, c’est qu’une fois qu’on a soumis une tâche à un thread, cette tâche est attachée à ce thread jusqu’à ce qu’elle se termine. On ne peut pas la détacher, soit elle termine avec un résultat, soit elle termine avec une exception, soit on casse le thread (interruption). Cependant, nous n’avons aucun moyen de détacher cette tâche de ce thread.

En conséquence, si on envoie une tâche bloquante, on bloque le thread et c’est coûteux.

Tous les 10 ans, il se passe quelque chose dans le paysage de la programmation concurrente en java.

Septembre 2023, Loom arrive et propose une solution pour pouvoir détacher une tâche d’un thread, un fois la tâche soumise.

2.2. Les contextes de la programmation concurrente

Il y a 2 grands domaines dans lesquels on utilise la programmation concurrente :

  • Les calculs en mémoire

  • L’augmentation de la bande passante de notre application.


2.2.1. Les calculs en mémoire

Les streams parallel ou le fork / join framework qui nous permet de faire les calculs en mémoire.

En 1995, il n’y avait qu’un seul cœur dans les CPU.

Maintenant, non seulement on a plein de cœurs mains plein d’unité de calcul dans chaque cœur.

Si on soumet une tâche qui ne tourne que sur 1 seul cœur, on n’utilise que 10-20% de la capacité de notre CPU.

Résultat, si on a une tâche qui traite beaucoup de données, on va diviser le volume de données par 2, une première fois, puis une deuxième fois etc…, jusqu’à avoir des petits bouts de données qui sont traitables.

Ensuite, on rassemble les résultats partiels les uns avec les autres de façon à produire un résultat global pour notre calcul global. C’est du calcul en mémoire qui utilise environ 90-95% du CPU.

Mais ce n’est pas du tout ce que Loom traite comme problème 😊.

Loom n’accélère pas nos calculs en mémoire !

Si on a des stream parallel dans notre application, les faire fonctionner sur des threads virtuels risque plutôt de diminuer les performances que de les augmenter.

Le garbage collector ne tourne pas dans un thread virtuel, ni le compilateur JIT. C’est du calcul en mémoire.


2.2.2. L’augmentation de la bande passante

La concurrence pour faire des i/o se définit en trois temps.

Dans un premier temps on prépare sa requête.

Nos requêtes sont des chaînes de caractère, donc du calcul en mémoire.

L’échelle de temps pour le calcul en mémoire c’est la dizaine de nanosecondes.

Dans un deuxième temps, on lance sa requête sur le réseau.

Et là on attend des millisecondes.

On a donc un facteur 1 million entre le temps qu’on attend et le temps de traitement pour créer la requête !

Dans un troisième temps, notre objet JSON arrive.

On le transforme en un objet java.

Et là on attend encore des nanosecondes.

Si vous regardez l’échelle de temps ci-dessous, la partie rouge pose un problème, car l’occupation CPU sur cette partie est de 0,001%

Pour pouvoir maintenir mon CPU occupé à 100%, il me faudrait 1 million de threads.

Malheureusement, actuellement on ne peut lancer que 4 000 threads maximum sur une machine classique, et cela essentiellement pour des raisons de mémoire.

Quand on crée un thread (plateforme), il faut allouer 2 Mo pour stocker sa propre stack.

Par ailleurs un thread prend 1ms pour se lancer.

Exemple :

  • Si on en lance 1 000, cela va prendre 1 seconde.

  • Si on en lance 1 000 000, il va prendre 1 000 secondes (soit ¼ d’heure).

En outre, lancer à l’heure actuelle 1 million de thread plateforme n’est même pas envisageable, à moins d’avoir 2To de mémoire vive !

C’est pour cela que des gens ont imaginé des solutions en se disant, plutôt que d’utiliser un thread pour une requête, on va essayer d’utiliser un thread pour plusieurs requêtes.

Alors, on découpe notre problème en pleins de petites tâches élémentaires, on place les tâches dans un framework. Et le framework va essayer de s’arranger pour maintenir les threads occupés le plus possible. C’est le début de la programmation asynchrone.

On veut qu’un thread ne fasse pas qu’une seule chose mais plusieurs. Cela nous amène à ces modèles de programmations avec des callbacks, etc…

Mais, s’agit-il de bons modèles ?

Oui, ce sont des modèles qui font le job mais le code est difficile à écrire et à lire.

Ci-dessous, un code bloquant :

Ci-dessous, un code non bloquant :

  • Le code est généralement impossible à tester,

  • Le code est horrible à debugger

  • Le profiling est impossible

Ne pourrait-on pas créer des threads qui seraient moins coûteux ?

Aujourd’hui on peut lancer 1 000 threads.

Si on arrive à gagner un facteur 1 000 sur les performances des threads, on pourrait en lancer 1 million et on résoudrait ainsi notre problème, n’est-ce pas ?

C’est l’objectif des threads virtuels.

3 – Les threads virtuels

La magie des threads virtuels, c’est de pouvoir gagner un facteur 1 000 sur les performances des threads actuels :

  • 1 000 fois moins de mémoire à utiliser (on passe de 2Mo à 10Ko)

  • 1 000 fois moins de temps pour lancer un thread virtuel (on passe de la milli à la nano voir micro seconde)

  • 100 à 1 000 fois moins de temps pour bloquer un thread virtuel.

Parlons code.

On peut créer un thread virtuel avec un modèle de Builder.

Ainsi, j’ai une méthode ofVirtual() sur la classe Thread (il existe aussi une méthode ofPlatform()).

Je lui donne un runnable à manger et il va m’exécuter un runnable.

Maintenant que se passe-t-il quand on bloque un thread virtuel ?

Là ça devient intéressant.

Un thread virtuel n’est pas cher à bloquer parce que ça ne bloque pas le thread plateforme.

Le modèle de thread virtuel ne change pas le modèle de thread plateforme qu’on avait avant.

Donc le thread plateforme continue à marcher exactement comme avant.

La classe VirtualThread, n’est pas publique, on ne peut pas jouer avec. Elle étend la classe Thread et fait la même chose qu’un thread plateforme.

Tous les problèmes de race condition, de deadlock vont être à peu près les mêmes.

Si on bloque un thread virtuel, il ne faut pas que cela bloque le thread plateforme.

Maintenant attention, le tour de magie va commencer !

A l’intérieur des threads virtuels il y a un objet qui s’appelle l’objet continuation.

Et pour les appels réseaux par exemple, il fonctionne avec un handler.

Du coup notre tâche est lancée au travers de cette continuation.

Et cette continuation va installer le thread virtuel au-dessus du thread plateforme.

Nous nous retrouvons avec des threads plateforme dans notre JVM, comme avant.

Ils peuvent provenir d’un fork / join pool, par exemple.

Ils sont en nombre limité. Si on a 8 cœurs on en aura 8 probablement, mais pas plus.

Et quand on lance un thread virtuel, il va s’exécuter au-dessus d’un thread plateforme.

On appelle ce thread plateforme le carrier threadpar rapport au thread virtuel, car c’est lui qui porte le thread virtuel.

Et si à un moment ou un autre, on bloque (par exemple sur des I/O du réseau),

… alors cet objet continuation, au moment du blocage, va appeler une méthode particulière qui s’appelle yield().

Et cette méthode yield() va prendre la stack du thread virtuel

… et la copier dans la heap.

Et ça c’est quatre ans d’effort d’ingénierie pour réussir à le faire marcher sans tout casser dans la jvm.

Donc ce morceau de stack est backupé dans la heap. Mon thread plateforme est donc disponible pour faire autre chose.

Dans le schéma ci-dessus, la stack, qui est dans la heap, contient le code bloquant, le code de mon réseau qui est en attente des données. Mais mon thread plateforme il a juste vu mon thread virtuel partir.

Et le thread plateforme continue à travailler en exécutant un autre thread virtuel.

Au bout d’un moment l’OS signalera que les données du réseau sont arrivées.

On peut alors réactiver le thread virtuel

Un signal va être envoyé et capté par un handler.

Le handler va appeler, toujours sur cet objet continuation une méthode run().

Et cette méthode run()va restaurer la stack de ce thread virtuel sur le thread plateforme.

Le mouvement entre la stack et la heap, constitue un mouvement mémoire d’une dizaine de Ko.

Evidemment, ce n’est pas un mouvement qui est gratuit, mais en attendant, on n’a pas bloqué le thread plateforme. Il est toujours installé sur le cœur de son processeur.

Le thread plateforme voit juste une espèce de danse des threads virtuels au-dessus de lui, mais il n’est jamais bloqué.

Si on se trouve dans le cas où le thread plateforme (TP1) est toujours utilisé, le thread virtuel va être mis dans la file d’attente de ce thread plateforme

Et si un autre thread plateforme (TP2) ne fait rien à côté, alors le TP2 va pouvoir faire du work stealing, et donc prendre ce thread virtuel pour l’exécuter.

Finalement, c’est le thread virtuel qui exécute la tâche et il peut maintenant sauter d’un thread plateforme à l’autre.

Et ce qu’on ne pouvait pas faire avant, à savoir détacher une tâche d’un thread plateforme, on peut dorénavant le faire avec la technologie des threads virtuels.

4 - Conclusion

Pourquoi avons-nous envie d’écrire du code bloquant dans ce genre de contexte, et donc pourquoi est-ce la fin de la programmation asynchrone ?

Parce que ça ne coûte pas cher de :

  • Créer des threads virtuels à la demande,

  • Laisser mourir des threads virtuels,

  • Bloquer des threads virtuels.

Ecrire du code non bloquant sur des threads virtuels, c’est inutile.

Et c’est en fait moins performant que d’écrire du code bloquant.

Si vous écoutez l’interview de Tomas Langer sur Helidon, la première version d’Helidon 4 qu’il a codé, était en fait du code non bloquant sur des threads virtuels.

Et il s’est rendu compte que c’était moins performant d’écrire son serveur de cette façon plutôt que de l’écrire avec du code bloquant sur des threads virtuels.

Le code bloquant ci-dessous devient alors le pattern performant

… par rapport au code non bloquant ci-dessous.

Si vous voulez en savoir plus sur Loom, je vous invite à regarder la conférence de Jausé Paumard en vidéo et/ou à consulter l'article sur LOOM, écrit en 2022 par Chaouki lors de la 10ème DEVOOX.

Vous pouvez aussi suivre José Paumard sur sa chaine youtube, et plus particulièrement son JEP café.

Enfin, pour vous tenir régulièrement informé des évolutions java, je retiendrai deux sites :