Project LOOM: Les Continuations & les Thread virtuels

Introduction

Dans cet article nous allons parler du projet Loom.

Le projet Loom est une version bêta de l'OpenJDK qui vise à introduire un modèle de concurrence léger à Java.

En effet, pas mal de langages proposent des implantations plus ou moins performantes de modèles qui facilitent les exécutions concurrentes.

Par exemple, Clojure dispose d'un modèle CSP-like qui permet d'envoyer des processus dans un thread pool relativement aisément. Go dispose de ses fameuses go-routines, inspirées des coroutines que nous retrouvons dans Kotlin. Scala propose plusieurs modèles d'exécution comme les acteurs et les fibers (coroutines). L'approche Async/Await de JavaScript…

Loom introduit deux features principales qui sont les suivantes :

  • Les Continuations :

    Les continuations sont un ensemble de fonctions capables d'être interrompues et stockées sur le tas puis reprises plus tard dans l’exécution du programme.

  • Les Fibers / Threads virtuels (terme privilégié) :

    Les threads virtuels sont similaires aux coroutines de Kotlin ou au fameux Async/Await de JavaScript, à l'exception près que les fibers sont gérées directement par la VM et pas par le compilateur.

Intérêt et cas d'usage

Pourquoi porter son attention sur ce modèle de concurrence ?

Meilleures performances

L'idée derrière les Threads virtuels est juste d'avoir un thread dont la création et le blocage sont peu couteux.

Pour expliquer ça, il faut comprendre deux-trois choses :

java.lang.Thread est un simple wrapper sur des threads machine. Les threads machines sont assez lourds, puisqu'ils nécessitent :

  • Un support dans un maximum de langages.
  • D’avoir la possibilité d’arrêter et de reprendre l’exécution d'un calcul, donc de préserver son état (pointeur, données locales).
  • D’allouer une pile suffisamment grande pour pouvoir convenir à tous les langages, puisqu’un thread machine ne sait pas comment chaque langage gère la mémoire.
  • Enfin, ils doivent assigner les exécutions aux CPU disponibles.

Les threads virtuels diffèrent d'une manière assez chouette : tout le contrôle des exécutions (interrompre, reprendre un calcul…) est géré par... un objet Java !

Les objets Java sont mieux taillés pour modéliser l’exécution d'un programme Java efficacement.

Un autre avantage niveau performance est que l'on peut choisir soi-même le scheduler le mieux adapté à notre besoin (par défaut, c’est un work-stealing-fork-join-pool).

En gros, la meilleure aisance du runtime Java avec Java par rapport aux threads machine permet de faire baisser le coût du spawning de thread drastiquement.

Simplicité d'écriture de programmes concurrents

Un des problèmes lié au fait d'utiliser des threads machines est que cette pratique permet tout au plus de spawner quelques milliers de threads simultanément actifs, bien loin des besoins réels d'un serveur Web supportant — par exemple — plusieurs millions de connexions concurrentes.

On est alors obligé d'avoir recours à des stratagèmes compliqués, comme le pooling qui consiste à mutualiser plusieurs threads pour effectuer des tâches simultanément. Mais, aucun thread pool ne saurait représenter l'état complet du programme.

L'avantage des threads virtuels est que la JVM peut en spawner… des masses ; plusieurs millions simultanément sans problèmes.

Dès lors, plus besoin d'architectures compliquées. Pour chaque traitement, on spawn un thread virtuel un point, c’est tout.

Prérequis

Openjdk

Vous pouvez trouver une version du projet loom ici

Config OSX + Jenv

Si vous n'utilisez pas déjà Jenv je vous conseille d'aller jeter un oeil ici

Setup d'IntelliJ

  1. Passer le compilateur à Java 15

Passer le SDK a 17 avec un niveau de langage java 15 pour l'ensemble du projet:

Les fibers / Threads virtuels

Prérequis:

Si vous avez déjà fait des algos concurrents en Java alors vous n'avez pas grand-chose d'autre à savoir. Pensez juste que désormais, spawner un thread ne coute pas grand-chose

Exemple

package fr.guihardbastien.boilerplate;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.locks.ReentrantLock;

public class Main {

    private final ReentrantLock lock = new ReentrantLock();
    private int counter = 0;

    public void syncAdd() {
        lock.lock();
        try {
            counter++;
        } finally {
            lock.unlock();
        }
    }

    public void fibersHelloWorld() throws InterruptedException {
        var a = new ArrayList<Thread>();
        for (int i = 0; i < 1_000_000; i++) {
            var t = Thread.ofVirtual().start(() -> {
                try {
                    Thread.sleep(Duration.ofSeconds(1));
                    this.syncAdd();
                } catch (InterruptedException e) {
                    System.out.println(Thread.currentThread().getName() + "-- Interrupted");
                }
            });
            a.add(t);
        }

        for (var t : a) {
            t.join();
        }
        System.out.println(this.counter);
    }

    public static void main(String[] args) throws InterruptedException {
        var m = new Main();
        m.fibersHelloWorld();
    }
}

Les continuations

Prérequis

Pour rappel les continuations sont des fonctions pouvant être interrompues et stockées sur le tas facilement. Il y a 5 choses à savoir pour espérer utiliser des continuations:

  • Scope: Une continuation s’exécute dans un scope fourni par la classe ContinuationScope (c'est globalement juste une classe qui contient un champs name)
  • Runnable: La classe Continuation prend en arguments un scope et un Runnable (type : () → void)
  • .yield : Yield est la méthode qui permet d'interrompre un continuation.
  • .run : Run est la méthode qui permet de lancer ou reprendre la continuation.
  • .isDone : Is Done renvoie un booléen vrai si l’exécution du runnable est terminée.

Example

public class Main {
    public static void continuationHelloWorld() {
        var scope = new ContinuationScope("scope");
        var continuation1 = new Continuation(scope, () -> {
            System.out.println("start 1");
            Continuation.yield(scope);
            System.out.println("middle 1");
            Continuation.yield(scope);
            System.out.println("end 1");
        });
        var continuation2 = new Continuation(scope, () -> {
            System.out.println("start 2");
            Continuation.yield(scope);
            System.out.println("middle 2");
            Continuation.yield(scope);
            System.out.println("end 2");
        });
        var list = List.of(continuation1, continuation2);

        while (!continuation1.isDone()) {
            list.forEach(Continuation::run);
        }
    }
    
    public static void main(String[] args) {
        continuationHelloWorld();
    }
}

Conclusions

En somme les thread virtuels permettent une construction facile de programmes concurrents et sont très performants, notamment au niveau de la mémoire :

JDK 11 occupied a heap of 6.8 GB with a peak use of 4.7 GB
JDK 16 without Virtual Threads occupied a heap of 4.6 GB with a peak use of 3.7 GB
JDK 16 with Virtual Threads occupied a heap of 2.7 GB with a peak use of 2.37 GB

So, using JDK 16 with these light-weight virtual threads resulted in:

only 50% usage of heap memory compared to JDK 11
only 64% usage of heap memory compared to JDK 16 without virtual threads

https://www.jobrunr.io/en/blog/2020-08-17-jobrunr-loom-virtual-threads/

Pour finir, voilà un petit boilerplate pour commencer à jouer avec !

Ressources