Dans mon article pr écédent, nous avons découvert les principales fonctionnalités proposées par Java depuis la première version du langage jusqu’à la version 7. Focalisons-nous sur des versions récentes de Java :
Java 8 : la programmation fonctionnelle fait « coucou »
Comme vous le savez, la version 8 a généré une révolution au niveau du langage Java. Cette nouvelle version a implementé beaucoup d’idées des langages fonctionnels comme Scala, par exemple :
- Classe
Optional
qui permet aux développeurs de créer du code plus auto-documenté en évitant le billion dollar mistake lié à la mauvaise utilisation de la valeur null - API Streams : opérations lazy et opérateurs
map
,flatmap
et compagnie
En ce qui concerne les tâches en arrière plan, de nouvelles fonctionnalités ont été ajoutées.
CompletableFuture
En Java 8, une nouvelle classe fut introduite, appelée CompletableFuture
. Cette classe vient compléter Future
avec de la logique supplémentaire. Cette classe permet, parmi d’autres choses, d’enchaîner des opérations asynchrones d’une manière simple en suivant une approche plus fonctionnelle. Jetons un coup d’œil à la définition de cette classe :
public class CompletableFuture<T> extends Object implements Future<T>, CompletionStage<T> { }
Comme vous pouvez le constater, cette classe implémente l’interface Future
. Ce qui permet, encore une fois grâce à la magie de la POO, de faire évoluer une bibliothèque qui utilise l’interface Future
sans impacter ses clients.
Si vous jetez un coup d’œil à la page javadoc de cette classe, vous vous rendrez compte qu’il s’agit d’une classe très complexe qui propose pas mal de méthodes. Par conséquent, je voudrais mettre l’accent sur certaines d’entre elles :
join()
On peut dire que get()
est à Future
ce que join()
est à CompletableFuture
. Autrement dit, join()
permet de récupérer la valeur wrappée par un CompletableFuture
. Étant donné que CompletableFuture
implémente Future
, pourquoi a-t-on besoin d’une nouvelle fonction ? La raison principale est la gestion des exceptions. Examinons plus en détail la signature des fonctions get()
sans et avec timeout :
public V get() throws InterruptedException, ExecutionException public T get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException
Comme vous pouvez le constater, plusieurs exceptions sont déclarées dans leurs signatures. En plus, il s’agit d’exceptions dites checked
. Par conséquent, pour chaque appel get()
, on doit encapsuler l’appel dans un bloc try-catch
ou alors gérer l’exception dans un niveau supérieur de notre application.
Regardons maintenant la documentation de la fonction join()
:
public
Tjoin()
Returns the result value when complete, or throws an (unchecked) exception if completed exceptionally. To better conform with the use of common functional forms, if a computation involved in the completion of this CompletableFuture threw an exception, this method throws an (unchecked)
CompletionExceptionwith the underlying exception as its cause.
En effet, cette fonction lève une exception dite unchecked
si une erreur se produit. Par conséquent, on n’a pas besoin de truffer notre code de try-catch
et on peut utiliser une syntaxe plus propre et plus fluent. Pour finir, à l’image de la fonction Future.get()
et ses variantes, la fonction CompletableFuture.join()
bloque le thread courant.
runAsync()
Cette factory method permet de créer une instance de CompletableFuture
à partir d’un Runnable. Cette fonction est idéale pour lancer des traitements asynchrones où on n’a pas besoin de récupérer une valeur à la fin.
supplyAsync()
On peut créer un CompletableFuture
à partir d’un Supplier
. Voici un exemple :
CompletableFuture<Integer> completable = CompletableFuture.supplyAsync(() -> longComputation());
thenCombineAsync()
CompletableFuture
permet de composer un nouveau CompletableFuture
à partir des deux CompletableFuture
indépendants. Voici un exemple de code :
CompletableFuture<String> helloCompletable = CompletableFuture.supplyAsync(() -> "Hello"); CompletableFuture<String> worldCompletable = CompletableFuture.supplyAsync(() -> "World"); CompletableFuture<String> result = helloCompletable.thenCombineAsync(worldCompletable, (hello, world) -> hello + " " + world); String resultString = result.join();
Dans cet exemple, on combine les deux CompletableFuture
grâce à la fonction thenCombineAsync
. A la fin, la variable resultString
contiendra le chaîne de caractères Hello World. Rien d’original.
thenApplyAsync()
Permet de lancer un traitement asynchrone à partir d’un autre CompletableFuture
. Ce nouveau traitement sera lancé lorsque le premier CompletableFuture
sera complété.
CompletableFuture<Integer> completable = CompletableFuture.supplyAsync(() -> longComputation()) .thenApplyAsync(i -> i * 2);
Dans cet exemple de code, la fonction lambda qui multiplie la valeur par 2 ne sera planifiée dans l’ExecutorService
sélectionné que lorsque la fonction longComputation
, aussi planifiée sur l’ExecutorService
sera complétée. Si vous êtes à l’aise avec l’API Streams de Java, cette fonction est équivalente à un map asynchrone.
allOf() / anyOf()
Lorsqu’on a présenté les défauts de l’interface Future
, on avait dit qu’on ne peut pas attendre qu‘une série de Future
soit complétée sans faire de l’active polling. Ceci peut être fait avec les fonctions allOf()
et anyOf()
de CompletableFuture
ExecutorService executor = Executors.newCachedThreadPool(); List<CompletableFuture<Integer>> futures = Stream.of(0, 1, 2, 3) .map(index -> CompletableFuture.supplyAsync(() -> longComputation(false))) .collect(Collectors.toList()); System.out.println("Waiting for completion"); CompletableFuture<Void> result = CompletableFuture.allOf(futures.toArray(new CompletableFuture[]{})); result.join(); System.out.println("Done");
Dans ce code, on construit une liste de CompletableFuture
pour ensuite appeler la fonction CompletableFuture.allOf()
. Comme vous pouvez le remarquer, cette fonction accepte en paramètre un array de CompletableFuture
et elle renvoie un CompletableFuture
. Ce CompletableFuture
nous permet de faire un join()
pour bloquer le thread courant jusqu’à que tous les CompletableFuture
seront complétés. Je pense qu’on est tous d’accord, cette solution est bien plus élégante que celle proposé par Future
Parallels Streams
Comme vous le savez, Java 8 a intégré l’interface Streams
qui permet d’ajouter du mojo fonctionnel à Java en ce qui concerne le traitement des collections. On peut voir un Stream
comme une séquence finie d’éléments du type T
que l’on peut manipuler à l’aide d’une syntaxe déclarative. La conséquence la plus évidente de ce changement, c’est que cela permet d’augmenter l’expressivité de notre code en adoptant le lexique des langages fonctionnels. Néanmoins, je voudrais mettre l’accent sur un changement de paradigme moins évident à première vue.
Lorsqu’on voulait itérer sur une collection en Java 7, on avait 2 options :
- Utiliser
iterator()
oulistIterator()
(si disponible) - Utiliser une boucle
for-each
(utiliseiterator()
),while
, etc.
Dans les deux cas, il s’agit d’une itération dite externe, cela veux dire que c’est au développeur de concevoir le code nécessaire pour accomplir l’itération. Streams
propose un mode d’itération dit interne, c’est-à-dire, l’itération est contrôlée par la JVM et le développeur doit se concentrer uniquement sur les transformations à faire sur la collection. Ceci permet à la JVM de faire des optimisations sous le capot de manière transparente pour les développeurs. La fonctionnalité qui nous intéresse le plus par rapport à cet article est les streams parallèles. Prenons cet exemple :
Stream.iterate(1L, i -> i + 1) .limit(50) .parallel() .reduce(0L, Long::sum);
Ignorons pour le moment l’appel à parallel()
. Qu’est-ce que ce code fait ? Il génère un Stream
contenant les 50 premiers nombres, pour ensuite les additionner via une opération de reduce. L’appel à parallel()
implique :
- Le stream est divisé en morceaux grâce à l’interface Spliterator. Si vous souhaitez en savoir plus, je vous invite à lire cet article de mon collège Joaquim Rousseau
- Pour chaque morceau, une tâche est soumise dans le
ForkJoinPool
- Chaque tâche est traitée par un thread worker, comme nous l’avons évoqué dans la section consacrée au framework
Fork/Join
- On combine au fur est à mesure les résultats partiels des morceaux qui ont été déjà traités
Vu qu’on utilise Fork/Join
pool, on n’a pas la garantie que les différents morceaux seront traités dans l’ordre, par conséquent, ce peut être une limitation pour intégrer cette approche dans notre code. Dans ce cas particulier, ceci n’a pas d’importance car :
- L’opération « addition » est associative et commutative
- Le traitement de chaque morceau est indépendant des autres morceaux
Observations par rapport à la performance
Comme on l’a vu tout à l’heure, transformer un stream séquentiel en parallèle est trivial, cependant ce n’est pas toujours une bonne idée. L’utilisation de ce type de Streams
créé un surcoût par rapport aux Streams
séquentiels, dû à l’utilisation de ForkJoinPool
. Cependant, cela s’avère utile lorsqu’on doit traiter beaucoup d’éléments ou quand le traitement de chaque élément est coûteux. Dans la bibliographie, on trouve souvent la formule suivant: N * Q = 10000
où :
- N : Nombre d’éléments à traiter
- Q : Coût de traitement de chaque élément
Voici un lien intéressant sur StackOverflow à ce sujet:
L’un des avantages des Streams est qu’ils permettent d’itérer facilement sur des collections mais, ces dernières ne proposent pas les mêmes performances lors d’une itération. Par exemple, tous les objets qui s’appuient sur des Comme vous pouvez le constater, l’introduction de la classe Pour cette raison, plusieurs libraires tierces ont vu la lumière du jour. Voici quelques exemples : Ces bibliothèques ont gagné beaucoup d’attention au fil du temps. Cependant, elles ont évolué de manière indépendante, ce qui rendait leur interopérabilité très difficile. C’est à ce moment où l’initiative Avant de commenter plus en détail toutes les interfaces, voici un diagramme de séquence qui explique tout ce processus de manière simple : Dans cette interface, on peut trouver les 4 callbacks utilisées par le publisher pour notifier des événements à ses subscribers : On arrive au joyau de la couronne, l’interface Comme nous l’avons déjà vu, une instance de cette classe est créée par le publisher lorsqu’un subscriber demande une souscription. Cette instance permet au subscriber de remonter des informations au publisher de manière découplée. Étant donné que le publisher et le subscriber peuvent être exécutés sur différents threads, il se peut que le publisher produise des événements plus rapidement que le subscriber n’est capable d’en consommer. Ceci est dangereux car il peut surcharger le subscriber. Pour éviter ceci, le subscriber indique de manière explicite au publisher le nombre maximum d’évènements qu’il est capable de traiter en appelant la méthode Évidemment, la méthode Toutes les interfaces présentées jusqu’à l’heure permettent de mettre en relation un publisher avec plusieurs subscribers d’une manière découplée. Dans cette communication, on a un publisher qui produit des événements et des subscribers qui réagissent à ces événements. Deux rôles bien définis et bien distincts. L’interface Attendez une minute ! Si l’article s’intitule « asynchronisme en Java », pourquoi on n’a parlé ni de threads ni d’ Project Reactor est une implémentation du standard Reactive Streams portée par Pivotal, la société derrière la galaxie Spring. Reactor propose deux types réactifs : Ces types peuvent être manipulés via des operators comme Assemblage : À l’image de l’API Streams de Java, Reactor propose une API fluent qui permet de manipuler nos données en entrée via des operators. À la fin de cette phase, le publisher ne fait rien et il attend la souscription d’un subscriber pour commencer à émettre des événements. De même que les Streams Java, les pipelines Reactor sont lazy. Souscription : Cette phase est initiée par un subscriber lorsqu’il souscrit à un publisher. C’est à ce moment que le publisher commence à envoyer des événements. Nothing happens until subscribe. Runtime : Les signaux entre le publisher et le subscriber sont échangés Voici un petit schéma qui explique les trois phases : Dans la documentation de Reactor, cette bibliothèque est décrite comme concurrency-agnostic. Pour ce faire, cette bibliothèque se sert des Schedulers et des opérateurs On peut considérer les Comme vous pouvez le constater, on a créé un Les transformations faites après cet opérateur seront exécutées par un thread déterminé par le Voici la sortie : Comme vous pouvez le constater, le pipeline est exécuté dans le thread principal nommé worker jusqu’à ce qu’on arrive à l’opérateur Modifions l’exemple précédent pour utiliser Voici la sortie : Comme vous pouvez le constater, l’utilisation de l’opérateur Bien entendu, on n’a fait que gratter la surface car les possibilités de Reactor sont vastes. Si vous avez envie d’approfondir sur ce sujet, je vous invite à voir le talk Flight of the Flux de Simon Baslé, Software Engineer chez Pivotal. Dans ces deux articles consacrés à l’asynchronisme en Java, nous avons présentés les différents solutions proposées par le langage pour lancer des opérations asynchrones sur la JVM. Voici quelques réflexions : Comme on dit qu’une image vaut mille mots, je me suis permis d’ajouter ce slide du talk Project Loom : Fibers and Continuations for Java de Alan Bateman
Arrivés à ce point, il faut se poser LA question : Vu la complexité de la programmation asynchrone et ses inconvénients, est-ce que ça vaut le coup d’adopter ce paradigme ? Les architectes de Java se sont posé la même question et ils ont commencé à travailler sur la prochaine révolution de l’asynchronisme en Java. Ce projet a été baptisé Projet Loom. Il a pour but de mettre à disposition des développeurs des outils pour faire de l’asynchronisme en utilisant une syntaxe synchrone. Sacré défi ! Comme d’habitude, les architectes ont pris des idées des autres langages. DISCLAIMER : À ce jour, ce projet est en incubation et il n’y a pas de date de sortie prévue. Par conséquent, on ne verra pas de code. Cependant, on peut discuter des idées principales derrière ce projet. Ce projet s’appuie sur plusieurs concepts : Les continuations constituent la base du projet Loom et on peut les voir comme des instances de Runnable qui peuvent être arrêtées et redémarrées par un Dans les premières versions de la JVM, les threads étaient schedulés par la JVM elle même sans dépendre du système d’exploitation sous-jacent. Ces threads s’appellaient green thread ou user threads. Au fil de versions, cette approche a été abandonnée au profit de l’utilisation de threads natifs. Les raisons ? Les voici : Cependant, ils présentaient plusieurs avantages par rapport aux threads natifs liés au fait qu’on ne devait pas passer par le système d’exploitation : À partir de la version 1.2, il faut savoir que, lorsque la JVM crée un thread, elle crée un mapping 1:1 avec un thread système. Autrement dit, une instance de la classe Thread est une abstraction d’un thread natif. Bien que l’approche des green threads a été vite abandonnée, on peut toujours repérer leur existence dans la classe Thread
Ce dont on a besoin est d’un système léger de création de thread gérés par la JVM. Dans le projet Loom ce concept s’appelle Fiber. Une Fiber est composée d’une Continuation et un Scheduler (ForkJoinPool par défaut). Technique héritée des langages fonctionnels qui permet de diminuer la mémoire réservée pour le stack lorsque l’on utilise la récursivité. À ce jour, aucune action a été faite à ce sujet, mais vous pouvez en apprendre un peu plus sur ce sujet pour votre culture personnelle dans cette page sur StackOverflow Dans cette série d’articles, nous avons parcouru ensemble les principaux mécanismes que Java nous propose pour ajouter de la « sauce asynchrone » dans notre code. Comme vous avez pu constater, de plus en plus d’outils ont été ajoutés afin de rendre cette fonctionnalité plus simple à mettre en œuvre. Malgré cette évolution manifeste, certains pensent que le langage Java stagne car son évolution est bien plus lente que celle d’autres langages qui orbitent autour de la JVM. Ils ont raison… en partie. Le 23 mai 2020, Java a fêté ses 25 ans et l’un des piliers qui ont marqué le devenir de ce langage est la rétrocompatibilité avec les versions précédentes du langage. Ceci a généré du passif qui empêche au langage d’évoluer plus rapidement. Dans le monde de la technologie, cette situation a été présentée sous le nom de l’innovators dilemma : Dans le livre indispensable Modern Java in action, on décrit l’interaction entre les langages de la JVM et Java d’une manière très originale : en le comparant avec le fameux effet serre impliqué dans le changement climatique. Voici une illustration incluse dans le livre :
Les langages « de niche » expérimentent avec des idées nouvelles qui sont ensuite portées sur les langages majoritaires, dont Java fait partie. La symbiose appliquée aux langages de programmation, n’est-ce pas beau ?Arrays
sont facilement divisibles car tous les éléments sont contigus en mémoire. Néanmoins, dans le cas des listes, on doit les traverser pour récupérer tous les éléments. Voici un tableau pour bien choisir son type de collection :Java 9 : Reactive Streams: One ring to rule them all!
CompletableFuture
a été une belle évolution par rapport à Future
. Cependant elle reste une classe difficile à utiliser car, parmi d’autre choses, les noms choisis ne sont pas très bons. C’est pour cela, qu’une nouvelle approche était nécessaire.There are only two hard things in Computer Science: cache invalidation and naming things.
-- Phil Karlton
Avengers Reactive Streams est née. Son but ? Proposer un cadre de travail commun pour les bibliothèques réactives afin de rendre plus facile leur cohabitation. Ce standard se base sur le pattern Publisher-Subscriber qui permet une communication entre émetteurs de données Publishers
et récepteurs de données Subscribers
d’une manière faiblement couplée. Pour ce faire, Java 9 propose quelques interfaces que les fournisseurs compatibles doivent implémenter. Suite à la publication de ce standard, les bibliothèques déjà existantes s’y sont adaptées, par exemple RxJava est compatible avec ce standard depuis la version 2. Les plus anciens se souviendront d’un cas similaire qui s’est produit avec la parution de Hibernate et la publication ultérieure de la spécification JPA. Toutes ces interfaces peuvent être trouvées dans la classe Flow
.Publisher
Publisher
est une interface fonctionnelle qui permet a un subscriber de s’inscrire lui-même afin de commencer à recevoir des événements produits par le publisher. Lorsqu’un subscriber appelle cette méthode, il indique de manière explicite qu’il veut recevoir dorénavant tous les événements produits par ce publisher. À partir de ce moment, le subscriber est manipulé par le publisher via les callbacks définis dans l’interface Subscriber
Subscriber
onSubscribe
: Ce callback est immédiatement invoqué par le publisher après une souscription réussie. Le but ? Partager avec le subscriber une instance de l’interface Subscription. Cette instance est fondamentale pour le fonctionnement de toute l’API reactive streams, car elle permet d’implémenter le mécanisme de backpressure. Nous allons approfondir sur ce sujet plus tard.onComplete
: Callback invoqué par le publisher quand il a fini d’envoyer tous les événements. Dans le cadre d’un Stream infini, cette méthode n’est jamais invoquée.onError
: Callback invoqué par le publisher lorsqu’une exception a été levée lors du traitement. Cet événement est particulier car il s’agit d’un événement terminal. Autrement dit, aucun nouvel événement ne sera envoyé par le publisher à partir de celui-cionNext
: Callback invoqué par le publisher afin d’envoyer un nouvel événement au subscriberSubscription
Subscription
! Comme vous avez pu le constater, dès l’instant où un subscriber appelle la méthode subscribe d’un publisher, c’est ce dernier qui prend le contrôle. C’est lui qui envoie les événements en appelant les différents callbacks proposés par Subscriber tandis que le subscriber joue un rôle plutôt passif. La communication entre publisher-subscriber serait-elle unidirectionnelle ? Pas du tout ! La réponse est dans cette interface :public interface Subscription {
void request(long n);
void cancel();
}
request(n)
de l’objet subscription. Le publisher, de son côté, s’engage à ne pas envoyer plus d’événements qu’indiqué par le subscriber. Ce mécanisme est connu sous le nom de backpressure.cancel()
sert au subscriber pour notifier le publisher qu’il n’est plus intéressé par ses événements. À partir de ce moment, le publisher n’enverra plus d’événement à ce subscriber. On dira qu’il s’agit d’une rupture amicale.Processor<T, R>
Processor<T, R>
, par contre, permet de définir des objets qui se comportent en publishers et en subscribers au même temps. Pourquoi faire ? Voici les raisons :
ExecutorService
ni de ForkJoinPool
dans cette section ? Le standard Reactive Streams n’impose aucun modèle d’asynchronisme et délègue cette partie aux différentes implémentations. Ceci accorde aux implémentations plus de liberté pour gérer les threads et propose aux développeurs des outils différents et innovants. À ce jour, les implémentations les plus connues sont: RxJava et Project Reactor. Voyons un cas d’utilisation réel :Cas d’utilisation : Project Reactor
Flux
et Mono
. Flux
est utilisé pour des séquences de n éléments (potentiellement infinis) et Mono
pour les séquences de 0 ou 1 éléments. Ces deux types implémentent l’interface Publisher
de Reactive Streams. Vous pouvez trouver plus d’information sur la documentation de référence officielle.map
, flatMap
, etc. afin de créer des pipelines. Le cycle de vie des pipelines réactifs a trois phases :publishOn
et subscribeOn
.Scheduler
Schedulers
de Reactor comme un ExecutorService
sous stéroïdes. Aux fonctionnalités de base d’un ExecutorService
, un Scheduler
propose un timer interne qui permet manipuler le temps dans le cadre des tests, par exemple :StepVerifier.withVirtualTime(() -> Flux.range(1, 4).delayElements(Duration.ofHours(5)))
.expectSubscription()
.thenAwait(Duration.ofDays(1))
.expectNextCount(4)
.verifyComplete();
Flux
qui émet les nombres entre 1 et 4 avec un délai entre chaque élément de 5 heures. Le fait d’avoir un timer virtuel nous permet de tester de manière instantanée ce genre de scénarios. Pour plus d’information concernant les Schedulers de Reactor, vous pouvez consulter la documentation officiellepublishOn
Scheduler
utilisé. Voyons un exemple de code :private Integer intenseCalculation(String str) {
sleep(300); // Sleep for 300 ms
return str.length();
}
Flux<Integer> input = Flux.just("Hello", "world!")
.doOnNext(s -> System.out.printf("Before first map: Content=%s, Thread=%s\n", s, Thread.currentThread().getName()))
.map(String::toUpperCase)
.doOnNext(s -> System.out.printf("Before publishOn: Content=%s, Thread=%s\n", s, Thread.currentThread().getName()))
.publishOn(Schedulers.parallel())
.doOnNext(s -> System.out.printf("After publishOn: Content=%s, Thread=%s\n", s, Thread.currentThread().getName()))
.map(this::intenseCalculation);
input.subscribe(length -> System.out.printf("Subscriber1: Content=%d, Thread=%s\n", length, Thread.currentThread().getName()));
Before first map: Content=Hello, Thread=Test worker
Before publishOn: Content=HELLO, Thread=Test worker
Before first map: Content=world!, Thread=Test worker
After publishOn: Content=HELLO, Thread=parallel-1
Before publishOn: Content=WORLD!, Thread=Test worker
Subscriber1: Content=5, Thread=parallel-1
After publishOn: Content=WORLD!, Thread=parallel-1
Subscriber1: Content=6, Thread=parallel-1
publishOn
. À partir de ce moment, toutes les autres opérations sont exécutées sur un thread different parallel-1
.subscribeOn
subscribeOn
au lieu de publishOn
:Flux<Integer> input = Flux.just("Hello", "world!")
.doOnNext(s -> System.out.printf("Before first map: Content=%s, Thread=%s\n", s, Thread.currentThread().getName()))
.map(String::toUpperCase)
.doOnNext(s -> System.out.printf("Before subscribeOn: Content=%s, Thread=%s\n", s, Thread.currentThread().getName()))
.subscribeOn(Schedulers.parallel())
.doOnNext(s -> System.out.printf("After subscribeOn: Content=%s, Thread=%s\n", s, Thread.currentThread().getName()))
.map(this::intenseCalculation);
input.subscribe(length -> System.out.printf("Subscriber1: Content=%d, Thread=%s\n", length, Thread.currentThread().getName()));
Before first map: Content=Hello, Thread=parallel-1
Before publishOn: Content=HELLO, Thread=parallel-1
After publishOn: Content=HELLO, Thread=parallel-1
Subscriber1: Content=5, Thread=parallel-1
Before first map: Content=world!, Thread=parallel-1
Before subscribeOn: Content=WORLD!, Thread=parallel-1
After subscribeOn: Content=WORLD!, Thread=parallel-1
subscribeOn
fait que tout le pipeline est exécuté dans un thread différent du thread principal.Futur de la plateforme: Project Loom
Continuations
Scheduler
. Ces continuations sont exécutées par des threads et le choix de quelle continuation est exécutée est réalisé par un scheduler. Lorsqu’une continuation bloque pour une opération I/O, elle peux céder sa place à une autre continuation qui est prête à être exécutée. D’autres langages ont déjà proposé de solutions similaires comme Kotlin et ses coroutines.Fibers : green threads are cool again
Tail-call optimization
Conclusion