Le projet Loom a pour objet d’apporter un nouveau modèle de programmation asynchrone dans le JDK. Une nouvelle notion de Thread arrive pour la plateforme Java : nos vieux threads d’il y a 25 ans laisseront la place à un nouveau type de thread, plus léger, et qui pourront être créés en plus grand nombre. Nous parlerons de programmation asynchrone, de programmation concurrent structurée, de scope et de scope local.
Résumé de la conférence donnée par José Paumard lors du devfest 2022
Avec la version Java 19 (Septembre 2022) est sorti en preview feature le projet Loom, l’un des plus gros projets actuels pour Java. Il a pour but de remplacer les threads actuels, présents depuis la création de Java, par un nouveau type de thread : les threads virtuels.
Avant de détailler l’implémentation de Loom, un petit historique sur la programmation concurrente et de ses limites s’impose. Pour rappel, différentes évolutions ont marqué la programmation concurrente en Java :
- 1995 : Thread, Runnable, synchronized()
- 2004 : ExecutorService, Callable, locks
- 2014 : Fork / Join, parallel streams, CompletableFuture
La programmation concurrente est utilisée principalement dans deux cas de figure :
- L’exécution de traitements en parallèle pour utiliser tous les cœurs du CPU
- L’augmentation du débit de traitement d’une application pour traiter plus de requêtes (ce qui nous intéresse ici dans le cadre d’une application web)
Cependant, un problème persiste depuis la création des threads : une fois une tâche lancée par un thread, elle ne peut plus être arrêtée correctement et on doit attendre soit sa complétion, soit une interruption via une exception (InterruptedException). Ainsi, une bonne gestion des lancements est primordiale pour un déroulement efficace de l’application.
Il y a dix ans, un thread était égal à une requête HTTP :
- préparation de la requête : 10 ns
- envoi de la requête et attente de la réponse : 10 ms
- traitement de la réponse : 10 ns
Un thread passait donc 99.999% du temps à attendre. Il faudrait alors un million de threads en simultané pour occuper le CPU à 100%. Or, un thread coûte cher en ressources :
- temps de lancement : ~1 ms
- mémoire occupée : 2 Mo
- temps pour passer d’un thread à l’autre : ~100 µs
Il est donc virtuellement impossible de lancer un million de threads classiques sur une machine (20 minutes de lancement, 2 To de mémoire). Pour tenter de pallier au problème, la programmation asynchrone a été introduite afin de pouvoir lancer plusieurs traitements sur le même thread. Cependant, cette manière d’utiliser les threads est beaucoup plus compliquée que la programmation synchrone, car elle est :
- Difficile à écrire / lire, tester et debugger
- Impossible à profiler
L’objectif du projet Loom était alors de revenir à la programmation synchrone (avec un thread par traitement), plus simple, mais d’augmenter le nombre possible de threads en simultané sur une machine.
Loom
La solution est « simple » : il suffit de raccourcir le temps de lancement et de limiter la mémoire occupée.
C’est exactement le but que Loom cherche à accomplir, en remplaçant les threads existants par des threads virtuels qui consomment beaucoup moins de ressources. Ainsi, un thread virtuel coûte ~1000 fois moins de ressources qu’un thread classique :
- mémoire de l’ordre du Ko (au lieu de Mo)
- création de l’ordre des µs (au lieu de ms)
Il est alors possible de créer un million de threads virtuels sur une machine.
Utilisation des threads virtuels
Pour créer un thread virtuel simple, on peut utiliser la classe VirtualThread, qui étend la classe Thread mais qui n’est pas visible en dehors de son package. Ainsi, on continue à utiliser l’objet Thread dans notre code.
Thread virtualThread = Thread.ofVirtual()
.name("demoLoom")
.start(runnable);
Il est également possible d’utiliser un ExecutorService de thread virtuel. L’ExecutorService d’un Tomcat ou d’un Jetty peut être alors remplacé pour que celui-ci utilise des threads virtuels.
ExecutorService service = Executors.newVirtualThreadPerTaskExecutor();
Structured Concurrency
Il est possible, avec quelques milliers de threads en simultané, de les analyser avec un IDE ou d’analyser des dumps de threads. Cependant, cela deviendrait impossible avec les millions de threads permis par Loom. Il a donc fallu structurer ces threads.
Le principe de Structured Concurrency a pour objectif de regrouper ensemble les threads. Un loom scope est un pool de threads structuré en arbre. Si un thread fils crée d’autres paquets de threads, ces paquets deviennent des enfants du pool principal.
Le nombre de scopes dépend ensuite de la logique métier implémentée derrière.
Exemple
Une agence de voyage veut à la fois, contacter plusieurs serveurs météorologiques et retourner les résultats du premier service qui répond, et obtenir le meilleur résultat entre différents serveurs de devis.
Récupérer le résultat du premier thread ayant fini sa tâche
public static Weather getWeather() {
try (var scope = new StructuredTaskScope.ShutdownOnSuccess<Weather>()) {
Future<Weather> futureA = scope.fork(() -> getWeatherFromA());
Future<Weather> futureB = scope.fork(() -> getWeatherFromB());
Future<Weather> futureC = scope.fork(() -> getWeatherFromC());
scope.join();
System.out.println("future A = " + futureA.state())
System.out.println("future B = " + futureB.state())
System.out.println("future C = " + futureC.state())
return scope.result();
}
}
Ici, un scope (StructuredTaskScope.ShutdownOnSuccess) est créé pour gérer les threads qui récupèrent les données météorologiques.
Le scope ShutdownOnSucces est un scope spécifique qui s’arrête lorsque l’une des Futures créées par scope.fork() a retourné un résultat.
Si on regarde plus en détails l’état des trois Futures, dans le cas où getWeatherFromB() finit en premier, la sortie standard affiche le résultat suivant :
future A = FAILED
future B = SUCCESS
future C = FAILED
…
Récupérer le meilleur résultat parmi tous les threads
Concernant les devis (quotation), la première réponse ne suffit pas car l’agence de voyage souhaite obtenir la solution la moins chère des trois devis. Des scopes permettent d’analyser et de comparer les résultats des différents threads :
private static class QuotationScope extends StructuredTaskScope<Quotation> {
private final Collection<Quotation> quotations = new ConcurrentLinkedQueue<>();
private final Collection<Throwable> exceptions = new ConcurrentLinkedQueue<>();
@Override
protected void handleComplete(Future<Quotation> future) {
switch(future.state()) {
case RUNNING -> throw new IllegalStateException("Oops");
case SUCCESS -> this.quotations.add(future.resultNow());
case FAILED -> this.exceptions.add(future.exceptionNow());
case CANCELLED -> {}
}
}
public Quotation bestQuotation() {
return this.quotations.stream()
.min(Comparator.comparing(Quotation::quotation))
.orElseThrow(this::exceptions);
}
public QuotationException exceptions() {
QuotationException exception = new QuotationException();
this.exceptions.forEach(exception::addSuppressed);
return exception;
}
}
Ici, la classe StructuredTaskScope a été étendue afin de redéfinir le traitement effectué lorsqu’un thread se termine. Pour cela, la méthode handleComplete() a été redéfinie et est lancée à chaque fois que l’un des threads du scope se termine. Le switch permet de définir les actions à exécuter en fonction de l’état du thread :
- RUNNING : le thread a fini mais est encore en train d’exécuter un traitement. Ceci ne devrait pas se produire et une exception est donc lancée
- SUCCESS : le thread a retourné un résultat. Nous ajoutons le devis trouvé à une liste. La liste est créée sur le thread global et peut être accédée par plusieurs threads, il faut donc veiller à utiliser une collection concurrente
- FAILED : le thread a rencontré une erreur et a lancé une exception, qui est ajoutée dans une liste d’exceptions
- CANCELLED : le thread a été créé mais annulé avant d’être lancé. Nous ne faisons donc rien dans ce cas
La méthode bestQuotation() a été créée pour retourner le meilleur prix ou lancer toutes les exceptions rencontrées si aucun devis n’a été trouvé.
Il ne reste alors plus qu’à lancer les threads qui appellent les différents serveurs, et d’attendre leur fin avec scope.join() pour récupérer le meilleur devis :
public static Quotation getQuotation() {
try (var scope = new QuotationScope()) {
Future<Quotation> futureA = scope.fork(() -> getQuotationFromA());
Future<Quotation> futureB = scope.fork(() -> getQuotationFromB());
Future<Quotation> futureC = scope.fork(() -> getQuotationFromC());
scope.join();
return scope.bestQuotation();
}
}
Joindre les résultats des deux méthodes
Pour l’instant, les deux objectifs ont été définis séparément. La méthode suivante récupère parallèlement les résultats obtenus et les retourne :
public static TravelPage getTravelPage() {
try (var scope = new TravelPageScope()) {
scope.fork(Weather::getWeather);
scope.fork(Quotation::getQuotation);
scope.join();
return scope.getTravelPage();
}
}
Ainsi, les deux méthodes sont lancées dans un scope personnalisé (voir sur le github dans les sources). Ce scope récupère simplement les deux résultats et initialise un objet TravelPage qui pourra être récupéré grâce à la méthode getTravelPage().
Conclusion
Au cours de cet article, nous avons pu voir comment le projet Loom et l’introduction des threads virtuels vont faire évoluer les threads que l’on connaît depuis la création du langage en 1995. Le projet est encore au stade expérimental et ne sera proposé qu’en mode preview lors des JDK 19 et 20.
Je vous invite à visionner (lien dans les sources) la rediffusion des conférences du Devfest pour la démonstration en direct par José Paumard, et du Devoxx pour plus de détails (2h30).
Sources
- Conférence de José Paumard à la Devfest 2022 : https://www.youtube.com/watch?v=jGJG6eUGQoo
- Conférence de José Paumard et Rémi Forax lors de la Devoxx 2022 : https://www.youtube.com/watch?v=wx7t69DylsI
- JEP 425 traitant de Loom : https://openjdk.org/jeps/425
- JEP 428 traitant de la Structured Concurrency : https://openjdk.org/jeps/428
Direction Technique
Stéphane YVON
Proxiad NORD