50 votes

Comment les coroutines sont-elles implémentées dans les langages JVM sans support JVM ?

Cette question est apparue après avoir lu le Proposition de métier à tisser qui décrit une approche de la mise en œuvre des coroutines dans le langage de programmation Java.

Cette proposition indique notamment que pour implémenter cette fonctionnalité dans le langage, un support JVM supplémentaire sera nécessaire.

D'après ce que j'ai compris, il existe déjà plusieurs langages de la JVM qui intègrent les coroutines dans leurs fonctionnalités, comme Kotlin et Scala.

Alors, comment cette fonctionnalité est-elle mise en œuvre sans support supplémentaire et peut-elle être mise en œuvre efficacement sans ce support ?

39voto

Jörg W Mittag Points 153275

tl;dr Résumé :

Cette proposition indique notamment que pour mettre en œuvre cette fonctionnalité dans le langage, un support JVM supplémentaire sera nécessaire.

Quand ils disent "requis", ils veulent dire "requis pour être implémenté de manière à être à la fois performant et interopérable entre les langages".

Ainsi, comment cette fonctionnalité est mise en œuvre sans support supplémentaire

Il y a de nombreuses façons de procéder, la plus simple pour comprendre comment cela peut fonctionner (mais pas nécessairement la plus facile à mettre en œuvre) est de mettre en œuvre votre propre VM avec votre propre sémantique au-dessus de la JVM. (Notez qu'est pas comment cela se passe réellement, il s'agit seulement d'une intuition quant à por qué c'est possible).

et peut-il être mis en œuvre efficacement sans lui ?

Pas vraiment.

Une explication un peu plus longue :

Notez que l'un des objectifs du projet Loom est d'introduire cette abstraction. purement comme une bibliothèque. Cela présente trois avantages :

  • Il est beaucoup plus facile d'introduire une nouvelle bibliothèque que de modifier le langage de programmation Java.
  • Les bibliothèques peuvent être utilisées immédiatement par les programmes écrits dans tous les langages de la JVM, alors qu'une fonctionnalité du langage Java ne peut être utilisée que par les programmes Java.
  • Une bibliothèque avec la même API qui n'utilise pas les nouvelles fonctionnalités de la JVM peut être implémentée, ce qui vous permettra d'écrire du code qui fonctionne sur les anciennes JVM avec une simple recompilation (bien qu'avec moins de performance).

Cependant, le fait de l'implémenter en tant que bibliothèque empêche les compilateurs astucieux de transformer les co-routines en quelque chose d'autre. il n'y a pas de compilateur impliqué . En l'absence d'astuces de compilation, il est beaucoup plus difficile d'obtenir de bonnes performances, d'où la "nécessité" d'un support JVM.

Explication plus longue :

En général, toutes les structures de contrôle "puissantes" habituelles sont équivalentes sur le plan informatique et peuvent être mises en œuvre les unes avec les autres.

La plus connue de ces "puissantes" structures universelles de flux de contrôle est la vénérable GOTO et d'autres sont des Continuations. Ensuite, il y a les Threads et les Coroutines, et un élément auquel les gens ne pensent pas souvent, mais qui est également équivalent à GOTO : Exceptions.

Une autre possibilité est une pile d'appels réifiée, de sorte que la pile d'appels est accessible en tant qu'objet pour le programmeur et peut être modifiée et réécrite. (De nombreux dialectes de Smalltalk font cela, par exemple, et c'est aussi un peu comme la façon dont cela est fait en C et en assembleur).

Tant que vous avez un de ceux-là, vous pouvez avoir tous en les mettant en place l'un après l'autre.

La JVM en a deux : les exceptions et GOTO mais le GOTO dans la JVM est pas universelle, elle est extrêmement limitée : elle ne fonctionne que à l'intérieur de une seule méthode. (Elle n'est essentiellement destinée qu'aux boucles.) Il nous reste donc les Exceptions.

Voilà donc une réponse possible à votre question : vous pouvez implémenter des co-routines au-dessus des exceptions.

Une autre possibilité est de ne pas utiliser le flux de contrôle de la JVM. du tout et mettez en place votre propre pile.

Cependant, ce n'est généralement pas le chemin qui est réellement emprunté lors de l'implémentation de co-routines sur la JVM. Le plus souvent, quelqu'un qui implémente des co-routines choisira d'utiliser des Trampolines et de réifier partiellement le contexte d'exécution en tant qu'objet. C'est, par exemple, la façon dont les générateurs sont implémentés en C♯ sur la CLI (pas sur la JVM, mais les défis sont similaires). Les générateurs (qui sont en fait des semi-co-routines restreintes) en C♯ sont mis en œuvre en transformant les variables locales de la méthode en champs d'un objet de contexte et en divisant la méthode en plusieurs méthodes sur cet objet à chaque étape du processus. yield en les convertissant en une machine d'état, et en faisant soigneusement passer tous les changements d'état par les champs de l'objet contextuel. Et avant que async / await est apparu comme une fonctionnalité du langage, un programmeur astucieux a mis en œuvre la programmation asynchrone en utilisant également la même machinerie.

CEPENDANT, et c'est ce à quoi l'article que vous avez cité faisait très probablement référence : toutes ces machines sont coûteuses. Si vous implémentez votre propre pile ou si vous soulevez le contexte d'exécution dans un objet séparé, ou si vous compilez toutes vos méthodes en une seule géant méthode et utilisation GOTO partout (ce qui n'est même pas possible à cause de la limite de taille des méthodes), ou utiliser des exceptions comme flux de contrôle, au moins une de ces deux choses sera vraie :

  • Vos conventions d'appel deviennent incompatibles avec la disposition de la pile de la JVM que les autres langages attendent, c'est-à-dire que vous perdez interopérabilité .
  • Le compilateur JIT n'a aucune idée de ce que fait votre code, et il est confronté à des modèles de code d'octet, des modèles de flux d'exécution et des modèles d'utilisation (par exemple, lancer et attraper gigantesque d'exceptions) qu'il n'attend pas et qu'il ne sait pas comment optimiser, c'est-à-dire que vous perdez performance .

Rich Hickey (le concepteur de Clojure) a dit un jour dans une conférence : "Tail Calls, Performance, Interop. Choisissez-en deux." J'ai généralisé cela à ce que j'appelle Hickey's Maxim : "Advanced Control-Flow, Performance, Interop. Choisissez-en deux."

En fait, il est généralement difficile de réaliser même l'un des l'interopérabilité ou les performances.

En outre, votre compilateur deviendra plus complexe.

Tout cela disparaît lorsque la construction est disponible nativement dans la JVM. Imaginez, par exemple, que la JVM ne dispose pas de Threads. Dans ce cas, chaque implémentation de langage devrait créer sa propre bibliothèque de Threading, ce qui est difficile, complexe, lent, et n'interagit avec aucune autre bibliothèque de Threading. autre la bibliothèque Threading de l'implémentation du langage.

Un exemple récent et concret est celui des lambdas : de nombreuses implémentations de langage sur la JVM disposent de lambdas, par exemple Scala. Ensuite, Java a également ajouté des lambdas, mais comme la JVM ne prend pas en charge les lambdas, ils doivent être utilisés en tant qu'éléments de base. encodé d'une manière ou d'une autre, et l'encodage choisi par Oracle était différent de celui que Scala avait choisi auparavant, ce qui signifiait que vous ne pouviez pas passer un lambda Java à une méthode Scala en attendant un code Scala Function . Dans ce cas, les développeurs de Scala ont complètement réécrit leur codage des lambdas pour qu'il soit compatible avec le codage choisi par Oracle. Cela a en fait rompu la rétrocompatibilité à certains endroits.

23voto

Todd Points 4949

De la Documentation Kotlin sur les coroutines (c'est moi qui souligne) :

Les coroutines simplifient la programmation asynchrone en mettant les complications dans les bibliothèques. La logique du programme peut être exprimée de manière séquentielle dans une coroutine, et la bibliothèque sous-jacente se chargera de l'asynchronisme pour nous. La bibliothèque peut intégrer des parties pertinentes du code utilisateur dans des rappels, s'abonner à des événements pertinents, planifier l'exécution sur différents fils d'exécution. (ou même des machines différentes !), et le code reste aussi simple que s'il était exécuté séquentiellement.

Pour faire court, ils sont compilés en un code qui utilise des rappels et une machine d'état pour gérer les suspensions et les reprises.

Roman Elizarov, le chef de projet, a donné deux conférences fantastiques à la KotlinConf 2017 sur ce sujet. L'une est une Introduction aux coroutines le second est un Plongée en profondeur sur les coroutines .

4voto

s1m0nw1 Points 21698

Coroutines ne pas se fier aux caractéristiques du système d'exploitation ou de la JVM . Au lieu de cela, les coroutines et suspend sont transformées par le compilateur, produisant une machine à états capable de gérer les suspensions en général et de faire circuler les coroutines en suspension en conservant leur état. Ceci est rendu possible par Continuations qui sont ajouté comme paramètre à chaque fonction de suspension par le compilateur ; cette technique est appelée " Style de passage de la continuité " (CPS).

Un exemple peut être observé dans la transformation de suspend fonctions :

suspend fun <T> CompletableFuture<T>.await(): T

La figure suivante montre sa signature après la transformation CPS :

fun <T> CompletableFuture<T>.await(continuation: Continuation<T>): Any?

Si vous voulez connaître les détails, vous devez lire ceci. explication .

2voto

Vadzim Points 4460

El Projet Loom a été précédé par le Quasar bibliothèque du même auteur.

Voici une citation de son docs :

En interne, une fibre est une continuation qui est ensuite planifiée dans un planificateur. Une continuation capture l'état instantané d'un calcul et lui permet d'être suspendu puis repris. instantané d'un calcul, et permet de le suspendre puis de le reprendre ultérieurement à un moment ultérieur à partir du point où il a été suspendu. Quasar crée des continuations en instrumentant (au niveau du bytecode) les méthodes pouvant être suspendables. Pour l'ordonnancement, Quasar utilise ForkJoinPool, qui est un outil très efficace, qui vole du travail et qui est multidimensionnel. très efficace, qui vole le travail et qui est un planificateur multithread.

A chaque fois qu'une classe est chargée, le module d'instrumentation de Quasar (habituellement généralement exécuté en tant qu'agent Java) recherche les méthodes pouvant être suspendues. Chaque méthode f suspendable méthode suspendable f est alors instrumentée de la manière suivante : Elle est analysée pour détecter les appels à d'autres méthodes pouvant être suspendues. Pour chaque appel à une méthode méthode suspendable g, du code est inséré avant (et après) l'appel à g qui l'appel à g qui sauvegarde (et restaure) l'état d'une variable locale à l'adresse locales sur la pile de la fibre (une fibre gère sa propre pile), et enregistre le fait que cette fait que ceci (c'est-à-dire l'appel à g) est un point de suspension possible. Sur la fin de cette "chaîne de fonctions suspendables", nous trouverons un appel à Fiber.park. Fiber.park. park suspend la fibre en lançant une exception SuspendExecution (que l'instrumentation vous empêche d'attraper, même si votre méthode contient un si votre méthode contient un bloc catch(Throwable t)).

Si g bloque effectivement, l'exception SuspendExecution sera capturée par la classe la classe Fiber. Lorsque la fibre est réveillée (avec unpark), la méthode f sera appelée, et alors l'enregistrement de l'exécution montrera que nous sommes bloquée à l'appel à g, donc nous sauterons immédiatement à la ligne dans f où g est appelé, et l'appeler. Enfin, nous atteindrons le véritable point de suspension réel (l'appel à park), où nous reprendrons l'exécution immédiatement après l'appel. Lorsque g revient, le code inséré dans f restaurera les variables locales de f à partir de la pile de fibres.

Ce processus peut sembler compliqué, mais il n'entraîne qu'une surcharge de performance de 3 à 5 % au maximum.

Il semble que presque tous les java purs continuation bibliothèques utilisait une approche similaire d'instrumentation du bytecode pour capturer et restaurer les variables locales sur les trames de la pile.

Seuls les compilateurs Kotlin et Scala ont été assez courageux pour implémenter plus détaché et potentiellement plus performante avec Transformations CPS aux machines à états mentionnées dans d'autres réponses ici.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X