web-dev-qa-db-fra.com

Comment les coroutines sont-elles implémentées dans les langages JVM sans prise en charge JVM?

Cette question est apparue après avoir lu la proposition Loom , qui décrit une approche d'implémentation des coroutines dans le langage de programmation Java.

En particulier, cette proposition indique que pour implémenter cette fonctionnalité dans la langue, un support JVM supplémentaire sera nécessaire.

Si je comprends bien, il existe déjà plusieurs langues sur la JVM qui ont des coroutines dans le cadre de leur ensemble de fonctionnalités telles que Kotlin et Scala.

Alors, comment cette fonctionnalité est-elle implémentée sans support supplémentaire et peut-elle être implémentée efficacement sans elle?

49
marknorkin

tl; dr Résumé:

En particulier, cette proposition indique que pour implémenter cette fonctionnalité dans la langue, le support JVM supplémentaire sera nécessaire.

Quand ils disent "requis", ils signifient "requis pour être mis en œuvre de manière à être à la fois performant et interopérable entre les langues".

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

Il existe de nombreuses façons, la plus facile à comprendre comment cela peut éventuellement fonctionner (mais pas nécessairement la plus facile à implémenter) est d'implémenter votre propre VM avec votre propre sémantique au-dessus de la JVM. (Remarque c'est pas comment cela se fait réellement, ce n'est qu'une intuition pour pourquoi ça peut être fait.)

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

Pas vraiment.

Explication légèrement plus longue:

Notez que l'un des objectifs de Project Loom est d'introduire cette abstraction purement en tant que bibliothèque. Cela présente trois avantages:

  • Il est beaucoup plus facile d'introduire une nouvelle bibliothèque que de changer le langage de programmation Java.
  • Les bibliothèques peuvent être immédiatement utilisées par des programmes écrits dans chaque langue de la machine virtuelle Java, alors qu'une fonction de langage Java 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 JVM peut être implémentée, ce qui vous permettra d'écrire du code qui s'exécute sur des machines JVM plus anciennes avec une simple recompilation (quoique avec moins de performances).

Cependant, l'implémenter en tant que bibliothèque empêche les astuces de compilation intelligentes de transformer les co-routines en autre chose, car aucun compilateur n'est impliqué . Sans astuces de compilateur intelligentes, obtenir de bonnes performances est beaucoup plus difficile, ergo, "l'exigence" pour le support JVM.

Explication plus longue:

En général, toutes les structures de contrôle "puissantes" habituelles sont équivalentes au sens du calcul et peuvent être implémentées les unes par rapport aux autres.

La plus connue de ces structures de contrôle-flux universelles "puissantes" est la vénérable GOTO, une autre est Continuations. Ensuite, il y a les Threads et les Coroutines, et ceux auxquels les gens ne pensent pas souvent, mais qui sont également équivalents à GOTO: Exceptions.

Une autre possibilité est une pile d'appels ré-ifiée, de sorte que la pile d'appels soit accessible en tant qu'objet au programmeur et puisse être modifiée et réécrite. (De nombreux dialectes Smalltalk le font, par exemple, et c'est aussi un peu comme comment cela se fait en C et en Assembly.)

Tant que vous en avez un , vous pouvez avoir tous tous les ceux-ci, en mettant simplement en œuvre l'un sur l'autre.

La JVM en a deux: Exceptions et GOTO, mais la GOTO dans la JVM n'est pas universelle, elle est extrêmement limité: il ne fonctionne qu'à l'intérieur d'une seule méthode. (Il est essentiellement destiné uniquement aux boucles.) Donc, cela nous laisse avec des exceptions.

C'est donc une réponse possible à votre question: vous pouvez implémenter des co-routines en plus des exceptions.

Une autre possibilité est de ne pas utiliser du tout le flux de contrôle de la JVM et d'implémenter votre propre pile.

Cependant, ce n'est généralement pas le chemin qui est réellement pris lors de l'implémentation de co-routines sur la JVM. Très probablement, quelqu'un qui implémente des co-routines choisirait d'utiliser des trampolines et de ré-analyser partiellement le contexte d'exécution en tant qu'objet. C'est, par exemple, comment les générateurs sont implémentés en C♯ sur la CLI (pas la JVM, mais les défis sont similaires). Les générateurs (qui sont essentiellement des semi-co-routines restreintes) en C♯ sont implémentés en soulevant les variables locales de la méthode dans les champs d'un objet de contexte et en divisant la méthode en plusieurs méthodes sur cet objet à chaque instruction yield , en les convertissant en une machine à états et en intégrant soigneusement tous les changements d'état dans les champs de l'objet contextuel. Et avant que async/await apparaisse comme une fonction de langage, un programmeur intelligent implémentait une programmation asynchrone utilisant également la même machine.

CEPENDANT , et c'est ce à quoi l'article que vous avez fait allusion le plus probablement fait référence: toutes ces machines sont coûteuses. Si vous implémentez votre propre pile ou soulevez le contexte d'exécution dans un objet séparé, ou compilez toutes vos méthodes dans une méthode géante et utilisez GOTO partout (ce qui n'est même pas possible à cause de la taille limite des méthodes), ou utilisez 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 JVM que les autres langues attendent, c'est-à-dire que vous perdez l'interopérabilité .
  • Le compilateur JIT n'a aucune idée de ce que fait votre code et est présenté avec des modèles de code d'octets, des modèles de flux d'exécution et des modèles d'utilisation (par exemple, lancer et attraper ginormous quantités d'exceptions) il ne s'attend pas et ne sait pas comment optimiser, c'est à dire que vous perdez les performances .

Rich Hickey (le concepteur de Clojure) a dit un jour dans une conférence: "Tail Calls, Performance, Interop. Pick Two." J'ai généralisé ceci à ce que j'appelle Maxim de Hickey : "Flux de contrôle avancé, performances, interopérabilité. Choisissez deux."

En fait, il est généralement difficile d'obtenir même l'un des interopérabilité ou performances.

De plus, votre compilateur deviendra plus complexe.

Tout cela disparaît lorsque la construction est disponible en natif dans la JVM. Imaginez, par exemple, si la JVM n'avait pas de threads. Ensuite, chaque implémentation de langage créerait sa propre bibliothèque Threading, qui est difficile, complexe, lente et n'interagit avec aucune autre bibliothèque Threading d'implémentation de langage .

Un exemple récent et réel est les lambdas: de nombreuses implémentations de langage sur la JVM avaient des lambdas, par ex. Scala. Ensuite Java a ajouté des lambdas également, mais comme la JVM ne prend pas en charge les lambdas, ils doivent être encodés d'une manière ou d'une autre, et l'encodage qu'Oracle a choisi était différent de celui Scala avait choisi auparavant, ce qui signifiait que vous ne pouviez pas passer un Java lambda à un Scala méthode attendant un Scala Function. La solution dans ce cas était que les développeurs Scala complètement réécrits) leur encodage de lambdas pour être compatible avec l'encodage choisi par Oracle. Cela a en fait rompu la compatibilité descendante à certains endroits.

39
Jörg W Mittag

Extrait de la Kotlin Documentation on 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 séquentiellement dans une coroutine, et la bibliothèque sous-jacente déterminera l'asynchronie pour nous. La bibliothèque peut encapsuler les parties pertinentes du code utilisateur dans des rappels, s'abonner à des événements pertinents, planifier l'exécution sur différents threads (ou même sur 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 code qui utilise des rappels et une machine d'état pour gérer la suspension et la reprise.

Roman Elizarov, le chef de projet, a donné deux conférences fantastiques à KotlinConf 2017 sur ce sujet. L'un est un Introduction aux Coroutines , le second est un Deep Dive on Coroutines .

22
Todd

Coroutines ne comptez pas sur les fonctionnalités du système d'exploitation ou de la JVM. Au lieu de cela, les coroutines et les fonctions suspend sont transformées par le compilateur produisant une machine à états capable de gérer les suspensions en général et de contourner les suspensions des coroutines en conservant leur état. Ceci est activé par Continuations , qui sont ajoutées en tant que paramètre à chaque fonction de suspension par le compilateur; cette technique est appelée " Style de passage continu " (CPS).

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

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

Ce qui suit 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 difficiles, vous devez lire ceci explication .

4
s1m0nw1

La Project Loom a été précédée de la bibliothèque Quasar du même auteur.

Voici une citation de c'est docs :

En interne, une fibre est une continuation qui est ensuite planifiée dans un ordonnanceur. Une continuation capture l'état instantané d'un calcul, et permet de le suspendre puis de le reprendre ultérieurement à partir du point où il a été suspendu. Quasar crée des suites en instrumentant (au niveau du bytecode) des méthodes suspendables. Pour la planification, Quasar utilise ForkJoinPool, qui est un planificateur multithread très efficace et volant le travail.

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

Si g bloque effectivement, l'exception SuspendExecution sera interceptée par la classe Fibre. Lorsque la fibre est réveillée (avec unpark), la méthode f sera appelée, puis l'enregistrement d'exécution montrera que nous sommes bloqués lors de l'appel à g, nous allons donc immédiatement passer à la ligne en f où g est appelé, et appelez-le. Enfin, nous atteindrons le point de suspension réel (l'appel au parcage), où nous reprendrons l'exécution immédiatement après l'appel. Lorsque g revient, le code inséré dans f restaure les variables locales de f à partir de la pile de fibres.

Ce processus semble compliqué, mais il entraîne des frais généraux de performance ne dépassant pas 3% -5%.

Il semble que presque tous les Java suitebibliothèques pures ont utilisé une approche d'instrumentation de bytecode similaire pour capturer et restaurer les variables locales sur les trames de pile.

Seuls Kotlin et Scala ont été assez courageux pour implémenter plus détaché et une approche potentiellement plus performante avec transformations CPS pour les machines d'état mentionnées dans certains autres réponses ici.

2
Vadzim