Lors de la 12ème édition de Devoxx Paris, tenue au Palais des Congrès du 17 au 19 avril 2024, Piotr Przybył a animé une conférence passionnante sur les threads virtuels. Pour ceux qui ne connaissent pas, ces threads virtuels, introduits dans Java 19 en mode preview (JEP-425), ont enfin quitté leur statut de “preview” pour devenir aussi stables qu’un funambule sur un fil dans Java 21, la dernière version LTS (JEP-444).
Oui, vous avez bien entendu, ils sont maintenant prêts pour la production !
Inspirés par cette conférence et divers articles (dont les liens sont disponibles à la fin de cet article), nous allons d’abord faire un petit retour en arrière sur les threads en Java. Pensez à cela comme une séance de rattrapage, mais sans l’ennui des cours du soir.
Ensuite, nous explorerons le monde des threads virtuels. Imaginez des threads qui font des acrobaties dignes du Cirque du Soleil, mais attention, il y a des pièges potentiels. On vous montrera comment éviter une utilisation incorrecte de ces threads, pour que vous puissiez briller parmi vos collègues développeurs.
Les Threads OS en Java : L’Ancienne Garde
Un thread Java traditionnel est souvent associé à un thread OS (ou thread système d’exploitation). On parle aussi de thread plateforme. Cela signifie que la gestion, la planification et l’exécution du thread sont déléguées au système d’exploitation. Et maintenant, accrochez-vous bien, voici les caractéristiques des threads OS :
- Gestion par le système d’exploitation : Les threads sont créés, planifiés et détruits par le noyau du système d’exploitation. Cela inclut la commutation de contexte, la gestion des priorités et la coordination entre les différents threads. En gros, c’est le système d’exploitation qui fait tout le travail lourd.
- Commutation de contexte (context switch) : La commutation entre threads OS implique une commutation de contexte au niveau du noyau, ce qui peut être coûteux en termes de performance, surtout si le nombre de threads est élevé. Imaginez devoir changer de tenue pour chaque tâche de votre journée, ce n’est pas très efficace, n’est-ce pas ?
- Utilisation des ressources : Chaque thread OS consomme des ressources système, notamment de la mémoire pour la pile du thread et des structures de données pour la gestion du thread par le noyau. C’est un peu comme avoir un énorme sac à dos pour chaque excursion, même pour aller acheter du pain.
- Parallélisme réel : Les threads OS peuvent tirer parti des multiples cœurs de processeur disponibles, permettant une véritable exécution parallèle de plusieurs threads. C’est comme avoir plusieurs chefs dans une cuisine, chacun travaillant sur un plat différent, ce qui permet de préparer un festin en un temps record.
Exemple de Création d’un Thread en Java : Une touche de Code pour l’Humour
public class ThreadExample { public static void main(String[] args) { Thread thread = new Thread(() -> { System.out.println("Thread executed by: " + Thread.currentThread().getName()); }); thread.start(); // Starting the thread } }
Maintenant que nous avons apprécié le spectacle des threads OS, penchons-nous sur leurs inconvénients. Ah, les threads OS, ces héros du quotidien dans le monde du développement Java! Mais même les plus grands ont leurs défauts.
- Overhead élevé : La création et la gestion des threads OS peuvent être coûteuses en termes de performance et de mémoire. Autrement dit, créer un nouveau thread pour chaque tâche concurrente peut vite épuiser les ressources. On risque alors de se retrouver à court de mémoire plus vite que vous ne le pensez.
- Scalabilité limitée : En raison de l’overhead de la gestion des threads OS, il peut être inefficace de gérer un très grand nombre de threads.
Comment optimiser l’utilisation des threads OS ?
C’est là qu’intervient notre sauveur, le “pool” de threads. Imaginez-le comme une équipe de travailleurs acharnés, prêts à se relayer pour accomplir les tâches les plus ardues.
Grâce à l’interface ExecutorService, nous pouvons créer ce “pool” en un clin d’œil. Par exemple, pour créer un pool de threads fixes de taille 4, on peut utiliser la méthode Executors.newFixedThreadPool(4).
Dans ce concept, chaque nouvelle tâche à exécuter est assignée à un thread du pool disponible. Si tous les threads du pool sont actifs, les tâches en attente sont placées dans une file d’attente, prêtes à être exécutées dès qu’un thread devient disponible. Cette approche garantit une utilisation efficace des ressources du système et une exécution fluide des tâches, sans les tracas liés à la gestion manuelle des threads.
Réf:https://communitycarbonit.medium.com/%C3%A0-lassaut-de-java-21-93bc5abca946
Voici un petit bijou de code pour créer votre propre piscine de threads :
public class FantasticThreadPool { public static void main(String[] args) { // Let's create our own magical thread pool ExecutorService threadPool = Executors.newFixedThreadPool(4); // Now, let's add some tasks to accomplish for (int i = 0; i < 10; i++) { threadPool.submit(() -> { String threadName = Thread.currentThread().getName(); System.out.println("Task accomplished by " + threadName); }); } // Don't forget to close the pool once tasks are done! threadPool.shutdown(); } }
Voilà pour les threads OS en Java : fidèles, robustes, mais parfois un peu lourds à gérer. Pour optimiser leur utilisation, il est judicieux de recourir aux pools de threads.
Les Threads Virtuels : “Le Big Bang de Java, Après Lambda !”
En réponse aux limitations des threads OS, Java a dévoilé les threads virtuels, prêts à bouleverser le paysage de la programmation. Gérés par la JVM (Java Virtual Machine), ces petits malins sont beaucoup plus légers que leurs prédécesseurs, ce qui signifie qu’ils peuvent être créés et gérés par milliers, voire par millions, avec une efficacité redoutable. Pourquoi sont-ils si géniaux ? Eh bien, voici pourquoi :
- Leur consommation mémoire est nettement moins importante que celle des Threads OS, d’autant plus que les Virtual Threads sont stockés en tant qu’objets Java dans la “heap” évitant ainsi les gaspillages inutiles.
- Leur création est aussi rapide que l’éclair. Contrairement aux threads OS, créer un nouveau thread virtuel est une opération légère comme une plume. Autant dire que vous pouvez en lancer autant que vous voulez, sans craindre de ralentir le système.
Pour créer un Virtual Thread, il suffit de faire appel à Thread.ofVirtual().start(Runnable task) comme illustré dans l’exemple ci-dessous.
public class HelloVirtualThread { public static void main(String[] args) { Thread.ofVirtual().start(() -> { System.out.println("Hello World !"); }); }}
Alors comment ça fonctionne ?
Pour tout vous dire sur le fonctionnement des Threads Virtuels, chaque thread virtuel se voit assigné à un thread OS. Imaginez que les Threads Virtuels sont comme des passagers qui ont besoin de se déplacer dans la ville de Java. Eh bien, pour leur faire traverser cette jungle urbaine, chaque passager est assigné à un Thread Java, qui est comme un taxi dans ce cas-ci, et ce, grâce au fantastique planificateur JDK (ForkJoinPool). Dans cette situation, le Thread OS (le taxi) devient un genre de transporteur (Carrier),
Maintenant, quand un passager monte dans un taxi, on dit qu’il est “mounted” (le thread virtuel est assigné à un thread OS carrier). Mais attention, tous les passagers ne peuvent pas toujours trouver un taxi disponible immédiatement. Ceux qui attendent patiemment sur le trottoir, prêts à partir dès qu’un taxi se libère, sont “unmounted” (Threads Virtuels non assignés, et prêts à l’emploi), comme illustré dans le schéma suivant.
Réf:https://communitycarbonit.medium.com/%C3%A0-lassaut-de-java-21-93bc5abca946
Lorsqu’un thread virtuel réalise une opération bloquante, il est retiré du thread Carrier tant que la réponse de l’opération bloquante n’a pas été reçue. Puis, dans l’attente de cette réponse, un autre thread virtuel peut être assigné.
Par exemple, dans le schéma ci-dessous, le Virtual Thread 3 réalise une opération bloquante et est donc détaché de son Carrier. Pendant ce temps, le Virtual Thread 4 initialement en attente, est assigné au Carrier laissé libre.
Réf:https://communitycarbonit.medium.com/%C3%A0-lassaut-de-java-21-93bc5abca946
D’autre part, si vous êtes d’humeur à remplacer vos threads classiques par des Threads Virtuels dans l’exemple précédent, la transition sera aussi fluide qu’un changement de costume pour une soirée mondaine. Il suffit de remplacer la fonctionnalité actuelle par la méthode ExecutorService avec Executors.newVirtualThreadPerTaskExecutor(). Comme par magie, cette méthode vous fournira un ExecutorService tout neuf, prêt à lancer un nouveau Virtual Thread pour chaque tâche émise.
public class FantasticVirtualThreadPool { public static void main(String[] args) { // Let's conjure up our own magical virtual thread pool ExecutorService threadPool = Executors.newVirtualThreadPerTaskExecutor(); // Now, let's add some tasks to accomplish for (int i = 0; i < 10; i++) { threadPool.submit(() -> { String threadName = Thread.currentThread().getName(); System.out.println("Task accomplished by " + threadName); }); } // Don't forget to dismiss the pool once tasks are done! threadPool.shutdown(); } }
Pour en savoir un peu plus sur le fonctionnement des threads virtuels, je vous invite à lire l’article suivant de mon collègue sur le blog de Invivoo.
https://www.invivoo.com/projet-loom-thread-virtuel/
Les limitations des Threads Virtuels : “ Quand le Virtuel Frôle le Réel”
Au vu des avantages décrits précédemment, cela laisse penser que les Virtual Threads sont la solution à toutes les problématiques de concurrence. Or, ce n’est pas le cas puisque les Virtual Threads ont aussi leur limitation.
Lorsqu’un Virtual Thread est retiré de son “Carrier” suite à une opération bloquante, il peut rencontrer des circonstances où il devient impossible de le détacher.
Dans ces cas-là, on dit que le Thread Virtuel est “pinned” (ne peut pas être détaché /unmounted du thread carrier). Certains de ces cas incluent :
- L’opération bloquante a lieu dans un bloc ou une méthode synchronized.
- L’opération bloquante a lieu durant l’exécution d’une méthode native ou d’une “foreign function”.
Cette situation peut ralentir l’application et affecter ses performances si la situation se répète plusieurs fois. Or, selon les librairies utilisées dans l’application, ces situations seront d’autant plus courantes. En effet, certaines librairies requièrent l’exécution d’opérations bloquantes dans les conditions présentées précédemment, ce qui mettra le Virtual Thread dans un état “pinned”.
Par exemple, dans le domaine de la programmation en C, des librairies telles que les bibliothèques standard C (libc) ou des librairies tierces comme OpenSSL peuvent nécessiter des opérations bloquantes lors de leur utilisation. De même, dans le domaine de la programmation réseau, des librairies telles que Netty ou Apache HttpClient peuvent être concernées. Ces librairies peuvent être indispensables pour certaines fonctionnalités de l’application, mais elles peuvent également introduire des contraintes pour l’utilisation des Threads Virtuels.
Dans ces cas-là, il est recommandé de ne pas utiliser les Threads Virtuels tant que les librairies concernées n’ont pas été révisées.
En résumé, voilà ce qu’il faut éviter quand on utilise les threads virtuels (VT) :
- Utiliser les VT pour les tâches gourmandes en CPU : Cette affirmation est valide dans la mesure où les Virtual Threads sont plus adaptés aux tâches IO-bound (entrée/sortie) plutôt qu’aux tâches CPU-bound (calcul intensif). Les Virtual Threads peuvent être inefficaces pour les tâches qui monopolisent le processeur en raison de la nature bloquante de ces opérations.
- Utiliser les VT avec synchronized : Il est important de préciser que l’utilisation de synchronized avec des Virtual Threads peut entraîner des problèmes de performance, car cela peut entraîner un blocage de la file d’attente des Virtual Threads. Cependant, l’utilisation de pools de threads pour réutiliser les Virtual Threads est une bonne pratique pour optimiser les performances.
- Envoyer en masse des tâches sans aucun contrôle de flux : Cette recommandation est valable, car l’envoi massif de tâches sans aucun contrôle de flux peut entraîner une surcharge du système et des ressources. Il est important d’utiliser des mécanismes de contrôle de flux tels que les limites de taux ou les files d’attente pour gérer la charge.
- Oublier de démonter les VT pour les opérations d’entrée/sortie (IO) : C’est un point crucial à ne pas négliger. Omettre de démonter les Virtual Threads après des opérations IO peut entraîner une utilisation inefficace des ressources et une latence accrue dans l’application.
- Ignorer les interruptions : Ignorer les interruptions peut conduire à des problèmes de gestion des ressources et à une réactivité réduite de l’application. Il est essentiel de prendre en compte les interruptions et de les gérer de manière appropriée dans la conception des applications utilisant des Virtual Threads.
En évitant ces pièges, vous pouvez vous assurer que vos threads virtuels exécutent leurs tâches avec grâce et efficacité, tout comme des acrobates accomplissant des numéros époustouflants sans encombre.
Avant d’en finir, veuillez trouver ci-dessous un tableau récapitulant les principales différences entre les threads OS et les threads virtuels en termes d’avantages, d’inconvénients et d’utilisation des ressources.
Caractéristiques | Threads OS | Threads Virtuels |
Gestion | Système d’exploitation | JVM (Java Virtual Machine) |
Avantages | Parallélisme réel | Scalabilité élevée |
Inconvénients | Overhead élevé | Latence potentielle |
Utilisation des ressources | Consomme plus de mémoire | Peut être plus léger |
Commutation de contexte | Coûteuse | Plus efficace |
Conclusion
Les threads virtuels en Java offrent une solution prometteuse pour améliorer la gestion de la concurrence dans nos applications modernes apportant légèreté, scalabilité, efficacité et un brin de magie dans notre code. Mais attention, jongler avec des threads virtuels demande une habileté certaine et une connaissance approfondie de leurs subtilités.
Avec le temps, à mesure que la technologie mûrit et que l’écosystème Java s’adapte, les threads virtuels pourraient devenir un outil incontournable pour le développement d’applications concurrentes hautement performantes surtout s’ils sont accompagnés de nouvelles fonctionnalités telles que les Scoped Values et la Structured Concurrency, (encore en preview dans java 22, mais prêtes à faire leur entrée sous les projecteurs). Alors, gardez un œil sur le blog, car le prochain numéro vous réserve bien des surprises !
Références
https://przybyl.org/pres/ButcherVirtualThreads/DevoxxFR.html#/0
https://communitycarbonit.medium.com/%C3%A0-lassaut-de-java-21-93bc5abca946
Piotr Przybył :