Booster ses coroutines Kotlin avec Loom

Lors d’une discussion avec un membre de mon équipe sur la manière dont sont gérés les Threads lors de la répartition des appels sur les routes API de notre projet, je me suis rappelé que le projet Loom de Java arrivait à grands pas et qu’il pourrait apporter son lot de changements dans ce domaine en améliorant les performances.

Il y a quelques années, j'avais eu la chance d’assister à une présentation de Loom par l’un des membres chargé de ce projet chez Oracle ; les fonctionnalités de cette révolution me semblaient alors très prometteuses. Je me suis donc demandé où ce projet en était et s’il était possible de l’inclure dans mon projet.

Travaillant actuellement sur une API en Kotlin et utilisant les coroutines, j’ai souhaité voir s’il était possible d’exécuter les coroutines Kotlin sur des threads virtuels introduits en Java par le projet Loom. Cet article présentera les résultats de mes investigations et mettra en lumière les avancées technologiques actuellement réalisables grâce à ce projet.

A notre disposition

La gestion des tâches concurrentes a toujours apporté son lot de complexité lors de l’écriture de programmes asynchrones. Avec les coroutines introduites en 2017, Kotlin propose une approche concise, élégante et légère pour la gestion de ses tâches concurrentes. Il s’agit de modules permettant d’exécuter des fonctions pouvant être suspendues. Ces dernières peuvent être mises en pause et reprises par un autre Thread, libérant ainsi de la ressource.

Pour une présentation détaillée des coroutines, je vous renvoie à l’article de Yannick : https://blog.ippon.fr/2018/06/14/introduction-aux-coroutines-dans-kotlin/

Avec l’avènement du projet Loom ces dernières années et son arrivée en preview dans  Java 19 (Jep 425) en septembre 2022, une nouvelle ère va s’ouvrir pour les utilisateurs de Kotlin. Une nouvelle possibilité dans l’allègement et la rapidité d’exécution des Threads s’ouvre. La promesse de Loom avec ses VirtualThread est de simplifier la programmation concurrente en offrant une nouvelle technologie permettant d’améliorer les performances.

Dans cet article, nous explorerons la manière dont l'intégration de Loom avec les coroutines Kotlin peut élargir les possibilités pour des systèmes plus performants et efficients.

Comprendre Loom

Java a introduit les Threads en 1995. Depuis, le langage n’a cessé d’évoluer, pour introduire de nouveaux paradigmes de programmation et pour s’adapter aux évolutions du matériel. Avec l’augmentation du nombre de cœurs dans les processeurs, les besoins en parallélisme ont augmenté. C’est pour répondre à ce besoin que le projet Loom a vu le jour. Ce dernier a pour vocation la simplification de la programmation concurrente en introduisant un nouveau concept, les Fibers. Il s’agit d’une nouvelle unité d'exécution plus légère que les Threads traditionnels. L’élément principal de cette unité est le VirtualThread. Il permet la création de Threads légers et transparents.

La fonctionnalité clé de ces Threads virtuels se trouve dans le non blocage des Threads. Actuellement et d’une manière générale, une tâche est affectée à un Thread pour la durée complète de son exécution. Dans le cas des entrées/sorties, ce dernier va attendre le retour de la lecture/écriture avant de poursuivre son exécution, monopolisant ainsi de la ressource. En adoptant les VirtualThreads, les développeurs mettent alors en place assez facilement une nouvelle gestion de l’allocation des Threads. En effet, quand une ressource bloquante (telle qu’une lecture io) est demandée par le processus, celle-ci ne va désormais plus le bloquer mais être déchargée et rappelée lorsque le résultat sera disponible. Cela permet de libérer et d’allouer le Thread à une autre tâche.


Je vous invite à aller lire l’article d’Etienne qui rentre bien plus en détail sur le sujet : https://blog.ippon.fr/2023/03/29/le-projet-loom-en-java-une-r-evolution-pour-vos-threads/

Intégrer Loom de manière transparente ...

Le projet  Loom a assumé dès le début la contrainte de s’intégrer aux systèmes existants sans demander des semaines voire des mois de migration.

L’exemple ci-dessous, écrit en Java, montre à quel point l’utilisation des threads virtuels peut être simple en passant de :

import java.util.concurrent.*;


public class Main {
   public static void main(String[] args) {
       ExecutorService executor = Executors.newFixedThreadPool(100);
      
       // Submit 100 runnable tasks
       for (int i = 0; i < 100; i++) {
           executor.submit(() -> System.out.println("Task executed by: " + Thread.currentThread().getName()));
       }
      
       executor.shutdown();
   }
}

à

import java.util.concurrent.*;


public class Main {
   public static void main(String[] args) {
       ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();


       // Submit 100 runnable tasks
       for (int i = 0; i < 100; i++) {
           executor.submit(() -> System.out.println("Task executed by: " + Thread.currentThread().getName()));
       }


       executor.shutdown();
   }
}

Et oui, il suffit “juste” de remplacer le type d’exécuteur pour accéder à la puissance Loom !

... en Kotlin

Dans le contexte de la programmation concurrente avec les coroutines en Kotlin, nous avons déjà des optimisations proposées pour pouvoir déterminer en fonction de la tâche à réaliser le Thread d'exécution à utiliser.

Cette répartition se fait par les Dispatchers. Il en existe 4 types :

  • Dispatchers.Default : Utile pour les calculs intensifs. Il va utiliser un nombre de threads égal au nombre de processeurs de la machine
  • Dispatchers.IO : C’est celui qui se rapproche le plus des optimisations proposées par Loom. Il est destiné aux tâches intensives de lecture et d’écriture (réseau, disque, …)
  • Dispatchers.Unconfined : Ce dispatcher n’utilise pas de Thread dédié. Il exécute la coroutine dans n’importe quel thread.
  • Dispatchers.Main : Utilisé dans le développement Android, ce dispatcher utilise le Thread principal. Il faut faire attention car il peut bloquer ou ralentir le programme.

Pour lancer une coroutine, le code suivant est suffisant :

import kotlinx.coroutines.*


fun main() = runBlocking {
   launch {
       Thread.sleep(2000L)
       println("Operation completed on thread: ${Thread.currentThread().name}")
   }
}

Ici, la tâche va être exécutée sur le Dispatchers.Default car aucun Dispatcher n’a été spécifié.


Passons maintenant à la partie la plus intéressante, l’intégration dans les coroutines.

Quelques paramètres sont à appliquer sur le projet pour pouvoir le faire fonctionner correctement :

  • Vous devez utiliser au minimum la version 19 du JDK
  • Les fonctionnalités en preview doivent être activées en ajoutant en option de VM --enable-preview
  • Ayant eu quelques soucis avec gradle, j’ai utilisé la gestion des librairies de l’IDE et j’ai pour cela  importé jetbrains.kotlinx.coroutines.core pour utiliser les coroutines

Une fois le projet configuré, la première étape est de créer un nouveau Dispatcher. Cela se fait facilement en utilisant les fonctions d’extension de Kotlin :

val Dispatchers.LOOM: CoroutineDispatcher
   get() = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()

Pour pouvoir mesurer l’efficacité de ce dispatcher par rapport à celui qui s’en rapproche le plus (Dispatcher.IO car on veut simuler une entrée/sortie), j’ai exécuté la coroutine dans un scope supervisé pour mesurer le temps d'exécution.

J’ai également voulu donner les mêmes ressources aux deux dispatchers en limitant le parallélisme à 5000.


Voici le code complet :

import kotlinx.coroutines.*
import java.util.concurrent.Executors
import kotlin.system.measureTimeMillis


fun main() = runBlocking {
   val dispatchers = listOf(
       Dispatchers.LOOM.limitedParallelism(5000),
       Dispatchers.IO.limitedParallelism(5000),
   )


   dispatchers.forEach { dispatcher ->
       val time = measureTimeMillis {
           supervisorScope {
               repeat(10_000_000) {
                   launch(dispatcher) {
                       delay(1000)
                   }
               }
           }
       }
       println("Time taken with $dispatcher: $time ms")
   }
}


val Dispatchers.LOOM: CoroutineDispatcher
   get() = Executors.newVirtualThreadPerTaskExecutor().asCoroutineDispatcher()
   

Dans cet exemple, nous simulons le lancement de 10_000_000 exécutions d’une tâche bloquante monopolisant la ressource pendant 1 seconde.

Voici le résultat que j’ai obtenu :

Time taken with LimitedDispatcher@47fd17e3: 18512 ms // Dispatchers.LOOM
Time taken with LimitedDispatcher@4563e9ab: 184688 ms // Dispatchers.IO

Le temps d’exécution est presque 10x plus rapide avec Loom !

Bref

Si je devais souligner deux éléments essentiels issus de cette expérimentation, je mentionnerais que :

  • Les performances sont prometteuses.Loom semble être dix fois plus rapide que les threads classiques bloquants dans cet exemple où les opérations d'entrée/sortie sont intensivement sollicitées..
  • L’intégration de Loom est plutôt simple à réaliser. En effet, la création d’un nouveau dispatcher est simple et son utilisation est facile.

Par curiosité, j’ai également testé les dispatchers Default et Unconfined.  J’ai obtenu les résultats suivants :

Time taken with LimitedDispatcher@47fd17e3: 2361 ms // LOOM
Time taken with LimitedDispatcher@4563e9ab: 17411 ms // IO
Time taken with Dispatchers.Default: 11708 ms // Default
Time taken with Dispatchers.Unconfined: 1711 ms // Unconfined

Il est intéressant de voir que pour cet exemple précis, c’est Unconfined qui offre la meilleure performance. Cela prouve que  Loom n’est pas systématiquement  LA solution. Les dispatchers ont déjà été conçus et optimisés pour des tâches  qui monopolisent  des ressources. Le choix de la technique d’implémentation est à effectuer au cas par cas en fonction de la flexibilité sémantique, de la maturité des technologies et des caractères spécifiques de votre projet. N’hésitez donc pas à faire des tests avant de prendre une décision !

En conclusion

Loom est prometteur. Encore en preview, il est important de prendre en compte que ce qui nous est présenté est encore susceptible de subir des modifications.

Son intégration à Kotlin reste simple et semble offrir de nouvelles possibilités dans l’optimisation de la consommation de ressources d’un programme. Il serait intéressant pour les plus curieux d’entre vous de pousser l'expérimentation à des cas plus concrets qu’une simple mise en pause du Thread tel qu’une lecture/écriture en base de données. La migration de vos projets est aussi intéressante car la “transparence” (facilité de mise en place) annoncée peut parfois se heurter aux spécificités de chaque projet.

A titre personnel, je vais continuer de chercher des cas d’utilisations où Loom pourrait être compétiteur. Les coroutines ont déjà été pensées sur le même type de fonctionnement par l’utilisation des suspend et peut-être que Loom va perturber ce fonctionnement.


Si vous souhaitez aller plus loin, je vous recommande de consulter les sources suivantes :