- Dans Go, il existe des modèles de concurrence qui s’expriment maladroitement avec les seuls goroutines et canaux ; les coroutines sont proposées pour aider à structurer les programmes en transférant explicitement le flot d’exécution, sans parallélisme
- Les coroutines s’échangent l’exécution via
resume et yield, et comme une seule s’exécute à la fois, elles évitent les races sur les données partagées, tandis que les points de bascule deviennent des points de synchronisation
- Les générateurs Python et les itérateurs CLU ressemblent aux coroutines, mais la position de
yield y est limitée ; si l’on transpose telle quelle une traversée d’arbre imbriquée à la manière de Lua, certaines valeurs disparaissent
coro.New de Go peut aussi s’exprimer avec des canaux et des goroutines, et coro.Pull transforme un itérateur push en itérateur pull qui extrait une valeur à chaque appel
- Une implémentation fondée sur les canaux prenait environ 190 ns par bascule, tandis qu’une bascule directe par le runtime réduisait ce coût à environ 20 ns par bascule et environ 40 ns par valeur avec
coro.Pull, avec pour objectif d’éviter d’en faire un goulot d’étranglement en usage réel
Modèle d’exécution des coroutines
- Une coroutine ressemble à un appel de fonction, mais elle s’exécute sur une pile différente et ne s’exécute pas en même temps qu’une autre
- Même si
F démarre G, G ne s’exécute pas immédiatement : F doit explicitement faire resume pour qu’elle s’exécute
- Pendant son exécution,
G peut à tout moment rendre la main à F via yield
- Quand
G retourne, elle est nettoyée, et F reçoit le signal qu’il ne doit plus faire resume sur G
- Dans ce modèle, une seule coroutine à la fois est en cours d’exécution, et l’appelant attend sur une autre pile
- Les bascules d’exécution ne se produisent qu’en des points précis du programme, de sorte que plusieurs flots s’exécutent à tour de rôle de manière coordonnée
Les coroutines à travers un exemple Lua
- L’exemple en Lua 5 compare si deux arbres binaires de structure différente possèdent la même séquence de valeurs
t1 et t2 contiennent 1, 2, 3, 4, 5
t3 contient 1, 2, 3, 4, 6
visit(t) parcourt l’arbre en ordre infixe et émet chaque valeur avec coroutine.yield(t.value)
- La fonction de comparaison crée deux coroutines
visit et appelle alternativement coroutine.resume pour lire la valeur suivante
- Si l’état de fin ou les valeurs des deux coroutines diffèrent, elle retourne
false
- Si les deux sont terminées, elle retourne
true
- Un code Lua plus idiomatique utilise
coroutine.wrap pour obtenir une fonction next qui masque l’objet coroutine
- Quand la coroutine est terminée, la fonction
next retourne nil
- Le code complet est disponible sur Gist
Les limites des générateurs Python et des itérateurs CLU
- Un générateur Python ressemble à une coroutine Lua, mais ne suit pas le même modèle
- Si l’on traduit directement l’exemple Lua en Python,
visit(t['left']) n’exécute pas réellement la traversée : cela crée simplement un objet générateur qui est ensuite jeté
- Dès lors qu’un
yield apparaît dans le corps de la fonction, def visit ne définit plus une fonction ordinaire mais un générateur
- Dans l’exemple de traduction naïve, seul 4 est affiché depuis l’arbre, et 1, 2, 3, 5 sont perdus
- Le bon code Python doit itérer explicitement sur les générateurs imbriqués et refaire un
yield
- Le
yield from de Python 3.3 simplifie ce motif
- Un objet générateur Python ne contient que l’état d’un seul appel à
visit
- Les valeurs des variables locales et la ligne en cours d’exécution sont stockées dans l’objet générateur
- Lors de la reprise, cet état est replacé sur la pile d’appel, puis ressort de nouveau dans l’objet générateur au
yield
yield n’est possible que dans le cadre d’appel de niveau supérieur
- CLU appelait cette abstraction un iterator et distinguait statiquement
iter et proc
- Grâce aux informations de type, le compilateur pouvait diagnostiquer l’usage incorrect d’un iterator appelé comme une fonction ordinaire
- L’article de 1977 de Barbara Liskov et al., “Abstraction Mechanisms in CLU”, explique que les iterators sont une forme restreinte de coroutine implémentée uniquement à l’aide de la pile du programme
Différences entre coroutines, threads et générateurs
- Ces trois notions fournissent toutes une certaine forme de concurrence, mais avec des capacités et des coûts différents
-
Coroutines
- Elles offrent de la concurrence sans parallélisme
- Lorsqu’une coroutine s’exécute, ni celle qui l’a reprise ni celle à laquelle elle a cédé la main ne s’exécutent en même temps
- Les points de bascule étant explicites, le partage de données n’introduit pas de race
- Une bascule comme un appel à
coroutine.resume ou à next devient un point de synchronisation et crée une relation happens-before
- Comme l’ordonnancement est explicite et sans système d’exploitation, la bascule peut descendre à environ 10 ns ou moins
-
Threads
- Ils sont plus puissants que les coroutines, et cette puissance supplémentaire est le parallélisme
- En contrepartie, ils impliquent un surcoût d’ordonnancement, des changements de contexte plus coûteux et une forme de préemption
- Une bascule de thread classique se situe typiquement à l’échelle de quelques microsecondes
-
Goroutines Go
- Dans cette classification, elles se rapprochent de threads bon marché
- Le runtime Go prend en charge une partie de l’ordonnancement, ce qui place la bascule autour de quelques centaines de ns
- Elles offrent, comme les threads, parallélisme et préemption
- Les nouvelles lightweight threads de Java sont, fondamentalement, équivalentes aux goroutines
-
Générateurs
Cas où Go a besoin de coroutines
- Les bibliothèques de concurrence existantes de Go ne fournissent pas directement le modèle des coroutines
- Les goroutines sont souvent suffisamment proches, mais leur parallélisme et leur préemption peuvent conduire à des résultats différents de ceux des coroutines
- La présentation de Rob Pike en 2011, “Lexical Scanning in Go”, traite de la conception initiale du lexer et du parser du package
text/template
- Le lexer et le parser s’exécutaient dans des goroutines séparées et étaient reliés par des canaux
- C’était une imitation incomplète d’une paire de coroutines
- Le lexer anticipait le jeton suivant pendant que le parser traitait le jeton précédent
- Un générateur ne suffisait pas pour un lexer qui doit faire
yield de valeurs depuis plusieurs fonctions
- Le parallélisme des goroutines introduisait des races, et la conception a fini par évoluer vers un stockage de l’état du lexer dans un objet
- Avec de vraies coroutines, il aurait été possible d’éviter les races tout en étant plus efficace qu’avec des goroutines
- Un cas d’usage futur serait le parcours de collections génériques
- Aujourd’hui déjà, Go peut implémenter un itérateur push à l’aide de valeurs fonction
- Par exemple :
func (t *Tree[V]) All(yield func(v V))
- On peut aujourd’hui l’appeler sous la forme
t.All(func(v V) { fmt.Println(v) })
- À l’avenir, une forme comme
for v := range t.All pourrait devenir possible
- Le problème apparaît avec des parcours qui ne se prêtent pas à une simple boucle
for
- Comme pour la comparaison d’arbres binaires, il faut parfois imbriquer deux parcours l’un dans l’autre
- Les coroutines peuvent transformer un itérateur push comme
(*Tree).All en itérateur pull qui renvoie une valeur à chaque appel
coro.New exprimé en Go pur
- Si l’on ajoutait des coroutines à Go, cela devrait être possible sans modifier le langage, et leur fonctionnement devrait pouvoir être compris et implémenté en Go ordinaire.
- Un
coro.New simple s’exprime avec des channels et une goroutine.
cin transmet les valeurs d’entrée.
cout renvoie les valeurs de sortie.
resume envoie une valeur à cin et attend le résultat sur cout.
- La nouvelle goroutine est initialement bloquée sur
<-cin, donc il n’y a aucune possibilité d’exécution en parallèle.
- En ajoutant
yield, f peut émettre une valeur pendant son exécution, et l’appelant peut renvoyer la valeur suivante au resume suivant.
yield(out) envoie une valeur à cout puis attend l’entrée suivante sur cin.
- Là encore, comme il s’agit d’une paire send-receive, il n’y a pas de parallélisme.
- Ce schéma de communication contraint la goroutine à se comporter comme une coroutine.
- En pratique, c’est bien une goroutine, mais
resume et yield jouent le rôle d’opérations de bascule.
Exemple de parseur de chaîne
- Le problème de « Storing Data in Control Flow » consiste à exécuter
func parseQuoted(read func() byte) bool dans un flux de contrôle séparé, tout en fournissant les octets un par un via une méthode Write.
- Avec
coro.New, on peut l’écrire à un niveau plus élevé que l’implémentation temporaire à base de channels de l’article précédent.
Init définit la fonction coparse.
read fait un yield de NeedMoreInput, puis renvoie l’octet envoyé par l’appelant.
- Le résultat booléen de
parseQuoted(read) est converti en BadInput ou Success.
p.resume(0) fait avancer l’exécution jusqu’au premier read de parseQuoted.
Write(c byte) devient un simple wrapper autour de p.resume(c).
- Le code complet est disponible sur Go Playground.
Exemple de crible des nombres premiers
- Le crible concurrent des nombres premiers de Doug McIlroy est un pipeline avec une coroutine par nombre premier
p.
- Chaque filtre reçoit des nombres de son voisin de gauche et, s’ils ne sont pas divisibles par
p, les transmet à son voisin de droite.
- À l’extrémité gauche, le counter produit 2, 3, 4, ...
- À l’extrémité droite, la coroutine de sortie lit les nombres premiers, les affiche et crée une nouvelle coroutine de filtre.
counter est une fonction qui encapsule dans coro.New une boucle faisant des yield de valeurs.
more bool indique s’il faut continuer à générer.
yield(i) émet une valeur et reçoit ensuite l’indication de continuation.
filter(p, next) récupère les valeurs depuis next(true) de la coroutine de gauche et ne fait yield(n) que si n%p != 0.
main conserve dans next la sortie courante du pipeline.
- Il lit un nombre premier
p.
- Il affiche
p.
- Il ajoute à droite du pipeline un nouveau filtre qui élimine les multiples de
p.
- La relation d’appel entre les coroutines peut changer pendant l’exécution.
- Le premier
yield du counter va vers main, mais les suivants vont vers le filtre de 2.
- La première sortie de chaque filtre de
p va vers main comme prochain nombre premier, puis les sorties suivantes vont vers le filtre suivant.
- Le code complet est disponible sur Go Playground.
Relation entre goroutine et coroutine
- Le flux de contrôle construit ici est, à strictement parler, une goroutine.
- Il peut faire tout ce qu’une goroutine ordinaire peut faire : mutex, channel, attente d’un appel système, etc.
coro.New crée une goroutine capable d’utiliser des opérations de bascule de coroutine à l’intérieur de yield et resume.
- L’instruction
go crée un nouveau flux de contrôle concurrent et parallèle, tandis que coro.New crée un nouveau flux de contrôle concurrent mais non parallèle.
- Si l’on exécute 10 instructions
go, 11 goroutines, main comprise, peuvent s’exécuter simultanément.
- Si l’on appelle
coro.New 10 fois, il y a bien 11 flux de contrôle, mais le parallélisme du programme reste inchangé et un seul s’exécute à la fois.
- Le rôle de coroutine « non parallèle » joué par une goroutine donnée peut changer pendant l’exécution.
- C’est analogue au fait que la goroutine en train d’envoyer ou de recevoir sur un channel peut changer au cours de l’exécution.
Un resume plus robuste
- Le
coro.New initial provoque un deadlock si l’on appelle resume une fois la fonction terminée.
- Pour corriger cela,
resume renvoie un bool en plus du résultat.
true signifie que le résultat provient de yield.
- Si la fonction retourne,
resume renvoie la valeur de retour et false.
- Si l’on appelle
resume après la fin de la coroutine, il renvoie la zero value et false.
- La variable
running permet de suivre si f est en cours d’exécution.
- Comme
resume et la coroutine s’exécutent en alternance, le partage de running n’introduit pas de race.
- L’exemple affiche
"hello" true, "world" true, "done" false, "" false.
Conversion d’iterator avec coro.Pull
coro.Pull transforme un push iterator en pull iterator.
- Le push iterator d’entrée a la forme suivante.
push func(yield func(V) bool)
- La valeur de retour booléenne de
yield indique s’il faut continuer.
- Le pull iterator visé a la forme suivante.
pull func() (V, bool)
- Comme un receive sur channel ou une lookup dans une map, il renvoie une valeur et indique si l’itération est terminée.
- Pour permettre l’arrêt anticipé,
Pull renvoie non seulement pull, mais aussi stop.
- L’implémentation se résume à créer avec
coro.New un petit wrapper qui exécute le push iterator.
pull appelle resume(true).
stop appelle resume(false).
- La méthode
All de l’arbre est modifiée pour utiliser le résultat bool de yield.
- Le parcours à gauche, le
yield de la valeur courante et le parcours à droite sont reliés par && pour propager l’arrêt anticipé.
- La fonction de comparaison d’arbres crée deux
coro.Pull et compare les valeurs une à une.
defer stop1() et defer stop2() arrêtent les coroutines en cas de sortie anticipée.
- Si les valeurs ou l’état de fin diffèrent, elle renvoie
false.
- Si les deux se terminent, elle renvoie
true.
- Le code complet est disponible sur Go Playground.
Propagation des panic et annulation
- Un
panic survenant dans une coroutine peut être renvoyé à l’appelant qui a le plus récemment fait resume sur cette coroutine
- Avec une goroutine classique, il est difficile de savoir quelle goroutine prévenir et si elle est prête à le recevoir
- Avec une coroutine, l’appelant est bloqué en attente dans
resume, donc la cible à laquelle transmettre le panic est claire
- L’implémentation utilise
cout pour transmettre un message contenant soit une valeur, soit un panic
- Le
defer de la nouvelle coroutine intercepte le panic
- Le
resume en attente déclenche à nouveau un panic avec la même valeur de panic
- Dans l’exemple, la coroutine fait un
yield de "hello" puis déclenche un panic avec "world"
- Le
panic se propage jusqu’à la goroutine principale et, dans la pile, semble provenir de l’appel à resume
- Le code complet est disponible sur Go Playground
- Une fonction
cancel est ajoutée pour informer la coroutine quand l’appelant s’arrête prématurément
cancel ressemble à resume, mais fait en sorte que yield déclenche un panic au lieu de renvoyer une valeur
- Le
panic d’annulation utilise un wrapper d’erreur unique satisfaisant ErrCanceled
- Le
panic provoqué par cancel n’est pas repropagé, mais si la coroutine déclenche un autre panic pendant l’annulation, celui-ci est propagé
- Si
resume n’a pas encore été appelé, cancel empêche complètement l’exécution de f
- Pour arrêter un itérateur, un
bool explicite est plus clair qu’un panic, donc Pull conserve un arrêt fondé sur un booléen
Retour au crible des nombres premiers : nettoyage et propagation des erreurs
- Avec la nouvelle API,
counter et filter renvoient ensemble une fonction resume et une fonction cancel
primes(n int) crée un compteur et enregistre defer cancel()
- Il lit et affiche chaque nombre premier
- Chaque fois qu’un nouveau filtre est ajouté, le
cancel de ce filtre est aussi enregistré via defer
- Quand la fonction obtient
n nombres premiers puis retourne, les appels cancel différés nettoient les coroutines créées
- Si une coroutine déclenche un
panic, celui-ci se propage à la coroutine qui attendait
- Si c’est une coroutine que
primes a reprise directement avec next, le panic revient à primes
- Si c’est une coroutine qu’un filtre a reprise avec
next, le panic remonte la chaîne de filtres jusqu’à p := next(true) dans primes
- Ensuite, les
cancel différés de primes nettoient les coroutines restantes
- Le code complet est disponible sur Go Playground
Forme finale de l’API
New crée une nouvelle coroutine initialement suspendue et la prépare à exécuter la fonction f
- La nouvelle coroutine est une goroutine, mais ne s’exécute pas d’elle-même
- Elle ne s’exécute que pendant qu’une autre goroutine appelle
resume ou cancel et attend
resume(in) suspend la goroutine appelante et bascule vers la nouvelle coroutine
- Le premier appel démarre
f(in, yield)
resume reste bloqué jusqu’à ce que f appelle yield(out) ou retourne out
- Si
yield est appelé, resume renvoie out, true
- Si
f retourne, resume renvoie out, false
- L’appel suivant à
resume(in) fait en sorte que le yield qui était bloqué renvoie in
cancel arrête l’exécution de f et termine la coroutine
- Si
resume n’a jamais été appelé, f n’est pas exécutée
- Sinon, le
yield bloqué déclenche un panic avec une erreur satisfaisant ErrCanceled
- Si
f déclenche un panic non récupéré, ce panic est transféré à la goroutine en attente dans resume ou cancel, qui déclenche à nouveau un panic avec la même valeur
- Toutefois,
cancel ne redéclenche pas le panic d’annulation qu’il a lui-même provoqué
- Quand
f retourne ou déclenche un panic, la coroutine n’existe plus
- Les appels ultérieurs à
resume renvoient la valeur zéro et false
- Les appels ultérieurs à
cancel retournent simplement
resume, cancel et yield peuvent être transmis à d’autres goroutines pour y être utilisés
- En conséquence, la goroutine qui est la « coroutine » peut changer dynamiquement
New crée une nouvelle goroutine, mais maintient l’invariant selon lequel une seule goroutine est toujours bloquée dans resume, cancel, yield ou dans l’état d’attente initial
- Cet invariant est conservé jusqu’au retour de
f
- Par conséquent,
coro.New crée une nouvelle concurrence, mais pas un nouveau parallélisme
- La signature finale est la suivante
func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) (Out, bool), cancel func())
Efficacité
- Il faut pouvoir définir des coroutines avec une implémentation pure Go, mais pour un usage réel, une implémentation optimisée dans le runtime est nécessaire
- Sur un MacBook Pro 2019,
coro.New fondé sur des canaux prend environ 190 ns par bascule pour un aller-retour de valeur
- Dans
coro.Pull, cela représente environ 380 ns par valeur
coro.Pull n’est pas la manière standard d’utiliser un itérateur
- La manière standard consiste à appeler directement l’itérateur, auquel cas il n’y a aucun surcoût de coroutine
coro.Pull est nécessaire lorsqu’il faut traiter les valeurs progressivement plutôt qu’avec une seule boucle for
- La première tentative d’optimisation consiste à faire en sorte que le compilateur marque les paires send-receive et laisse un indice pour que le runtime les fusionne en une seule opération
- Le runtime des canaux peut contourner l’ordonnanceur et sauter directement vers une autre coroutine
- Cela prend environ 118 ns par bascule et environ 236 ns par valeur extraite
- C’est 38 % plus rapide que l’implémentation d’origine fondée sur les canaux
- La deuxième implémentation évite complètement les canaux et ajoute directement la bascule de coroutine au runtime
- La bascule de coroutine se réduit à 3 atomic compare-and-swap
- L’un sert à la structure de données de la coroutine, un autre au scheduler status de la coroutine bloquante, et un autre encore au scheduler status de la coroutine reprise
- Cela prend environ 20 ns par bascule et environ 40 ns par valeur extraite
- C’est environ 10 fois plus rapide que l’implémentation d’origine fondée sur les canaux
- Un coût de 40 ns par valeur est considéré comme suffisamment faible en valeur absolue pour ne pas devenir un goulot d’étranglement dans le code qui a besoin de
coro.Pull
1 commentaires
Avis sur Hacker News
Beaucoup de gens semblent passer à côté de l’essentiel ici. Il est vrai qu’une bibliothèque de coroutines constitue une manière plus mauvaise et plus contraignante de gérer la concurrence que le mot-clé
goLe véritable cas d’usage qui introduit cette complexité, ce sont les itérateurs de fonctions, c’est-à-dire permettre d’utiliser
rangesur des fonctions de typefunc() (T, bool). C’est un sujet discuté depuis longtemps dans la communauté Go, et sa signification sera probablement intuitive pour la plupart des programmeurs GoCet article traite du problème suivant, à savoir : si les itérateurs de fonctions sont ajoutés au langage, comment écrire des itérateurs utilisables dans une boucle
for. Il part du constat que les itérateurs push sont souvent plus faciles à écrire, puis élargit cela à un adaptateur push-pull, cet adaptateur étant construit sur des coroutinesSi tout cela est intégré, utiliser des coroutines pour autre chose que l’itération risque d’être une mauvaise pratique, un peu comme utiliser des canaux/goroutines là où un mutex suffit
Lorsque deux tâches coopèrent logiquement de manière synchrone, par exemple dans un itérateur, il est bien plus efficace de tout exécuter sur le même CPU. Le noyau n’a rien à réordonner, pas besoin d’éteindre ou de réveiller des cœurs CPU, et les données restent dans le cache CPU, ce qui améliore la latence du cache et le taux de réussite
Cela peut aussi arriver par hasard avec des goroutines, mais ce n’est pas garanti, et il y a au minimum le coût du passage par l’ordonnanceur de goroutines du runtime Go. C’est rapide, mais pas aussi rapide que d’exécuter un autre contexte de code dans la même goroutine
Avec les coroutines, on sait que la tâche A bascule directement vers la tâche B, donc le comportement d’ordonnancement est plus prévisible. À la fin de l’article, Russ montre qu’une implémentation optimisée des coroutines dans le runtime est 10 fois plus rapide qu’une simulation avec des goroutines
Google dispose en interne de correctifs noyau implémentant ce type de multithreading coopératif, qu’ils appellent en interne des fibers. Ils existent pour améliorer la latence et rendre l’ordonnancement prévisible. Paul Turner a aussi donné une présentation au LPC il y a environ 10 ans expliquant cette motivation : https://www.youtube.com/watch?v=KXuZi9aeGTw
for { next := getNext(); ... }. Je me demande quel est l’avantage de l’écrire sous la formefor next := range getNext { ... }rangeouswitchsur un canal, puis de lancer une goroutine qui pousse les valeurs dans ce canal. Je ne suis toujours pas convaincu de la nécessité des coroutinesCette fois, on n’a pas l’impression d’une réflexion prudente ni d’une bonne solution 80/20, mais plutôt de : « pour faire ça correctement, il va nous falloir des coroutines, ajoutons-les simplement »
Pour les génériques, ils ont vraiment réfléchi longtemps et en profondeur, et ils ont proposé un compromis innovant remarquablement équilibré
Ici aussi, je m’attendais à une approche du type « ajouter une capacité aux goroutines pour les contrôler dans certains cas ». Cela aurait semblé préférable à « aborder ce problème de manière globale comme Rust et simplement l’ajouter »
J’utilise Go à titre professionnel depuis des années, mais je ne veux pas qu’il devienne comme Python avec Twisted / Tornado / d’autres frameworks du même genre
Le mot-clé
goévite assez bien le douloureux problème de coloration des fonctionsDans les contextes haute performance, on peut vouloir faire des choses comme le partitionnement des données par cœur CPU, mais cette proposition ne répond pas à ce besoin
async/awaitLes coroutines occuperaient un autre espace, plus proche des générateurs de Python. Dans les cas où il faut assembler des pipelines de composants composables, elles peuvent souvent réduire fortement l’usage mémoire et la complexité du code. La conception semble entièrement synchrone
Context, c’est littéralement de la coloration de fonctionsBien sûr, il est presque certain qu’un développeur malin quelque part dans le monde va construire quelque chose de stupide avec ça, et que cela deviendra très populaire. Mon pronostic : unifier les goroutines et les coroutines dans une seule abstraction
En général, le code lié aux goroutines me paraît désordonné à cause des canaux, et je ne sais pas comment le rendre plus « propre ». Si vous avez des indications sur des patterns dont la maintenabilité a été démontrée, je serais vraiment preneur
Les systèmes multitâches nous ont donné les processus
Mais c’était trop lourd
Alors sont apparus les threads, c’est-à-dire des processus qui partagent l’espace d’adressage, la table des fichiers et quelques autres éléments. Le planificateur peut basculer plus facilement entre des threads qu’entre des processus, et le partage de données entre threads ne nécessite pas de sérialisation
Mais c’était encore trop lourd
Alors sont apparus les threads en espace utilisateur. Ce sont des threads d’exécution logiques que le runtime pilote entièrement en espace utilisateur. Le runtime insère des hooks d’ordonnancement dans toutes les fonctions d’E/S de la bibliothèque standard, ou préempte les threads logiques via des API système comme les signaux Unix. Il n’y a pas besoin de changement de contexte au niveau système, et ils peuvent être très légers
Mais c’était encore trop lourd
Alors sont apparues les coroutines. Elles permettent au programmeur de définir des « threads » logiques qui interagissent de manière coopérative. Elles ne supposent pas l’existence d’un planificateur. Le programmeur utilise directement une boucle d’événements, ou appelle la boucle d’événements d’une bibliothèque depuis un « vrai » thread logique
Je me demande ce qui viendra ensuite. Du point de vue des [processus séquentiels communicants][1], il se pourrait que les coroutines coopératives soient le niveau le plus bas où l’on puisse descendre
[1]: https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf
Il faut une sémantique compatible avec la concurrence. Par exemple, les itérations ne devraient pas être ordonnées par défaut, et cela dépend aussi d’une analyse globale du flot du programme, mais je ne vois pas d’obstacle de principe
Microsoft n’avait-il pas étudié un langage de ce genre il y a quelques années ? Il y a aussi ParaSail, créé par quelqu’un de la communauté Ada. Je me demande ce qu’il est advenu de ces projets et si personne ne les utilise
J’avais toujours pensé que l’intérêt des green threads était d’obtenir un bon ordonnancement coopératif sans avoir à utiliser des mots-clés comme
yielden PythonLa décision de conception de Go consistant à
insérer des points de reprise au niveau des appels et à certains endroitsme semblait être un très bon compromisOn expose un contrôle de plus en plus proche du matériel. À partir d’un certain point, je me demande si on n’est pas simplement en train de recréer Zig. La prochaine étape, c’est un garbage collector optionnel ?
yieldetresumene sont pas des mots-clés, mais de simples variables. Ce sont juste des références de closures ordinaires nommées ainsi à des fins pédagogiquesLe point inhabituel, c’est la partie où l’on crée et utilise un callback d’annulation. Je ne fais pas beaucoup de Go, donc je ne sais pas si c’est une optimisation pour récupérer plus vite l’état d’un itérateur abandonné, ou si c’est nécessaire parce que Go ne met pas un goroutine en garbage collection lorsque ce goroutine en attente sur un channel détient l’unique référence vers ce channel
En Lua, ce genre de chose n’est pas nécessaire. Les coroutines/threads sont ramassés comme n’importe quel autre objet, donc si toutes les références disparaissent, ils sont récupérés même si leur dernière opération était
yieldplutôt qu’un retour de la fonction d’entréeGOGC=offpour désactiver le ramasse-miettesPlus de détails sur GOGC : https://dave.cheney.net/tag/gogc
Je pense que je préférerais que les coroutines relèvent d’un support du langage plutôt que d’une bibliothèque
J’imagine quelque chose comme
x := co func(){ var z int; for { z++; yield z } }, ou une forme équivalenteLe fait que ce soit possible en pur Go est élégant, et je comprends aussi l’intérêt de le fournir sous forme de package de bibliothèque standard avec un runtime optimisé, plutôt que de complexifier la spécification du langage. Au final, si c’est faisable en pur Go, d’autres implémentations peuvent aussi démarrer rapidement
Comme quelqu’un qui utilise Go tous les jours au
$work, l’une ou l’autre option me va, mais je préfère tout de même une solution intégrée au langage. Les primitives de concurrence de Go ont toujours été un point fort, donc autant pousser dans cette directionAvec une solution de bibliothèque, il faut soit tuer la coroutine, soit tuer le programme. Si l’on veut que la pile s’agrandisse de manière transparente, seul le code généré peut le faire. Il doit surveiller l’utilisation de la pile et l’agrandir si nécessaire. Il me semble que les goroutines ont déjà ce mécanisme
Peut-être qu’une solution de bibliothèque pourrait quand même placer une page de garde à la fin de la pile. Une fois atteinte, un gestionnaire d’erreurs pourrait tenter d’étendre la pile. Mais cela ne fonctionnerait probablement pas si quelqu’un conservait un pointeur vers une variable de pile
yieldaux fonctions afin de mieux interagir avec le système de types actuelPour qu’une fonction puisse céder une valeur, elle devrait avoir ce qu’on pourrait appeler un paramètre
yield, et elle ne pourrait céder des valeurs qu’à l’intérieur de fonctions ayant la même signature de cession, ou aucune signature du toutL’exemple pourrait devenir quelque chose comme
x := func(:z int) { for { z++; :- z } }.:ajoute une signature de cession, et:-cède une valeurUne fonction qui ne cède que des valeurs
Xn’aurait besoin que de: Xou: name X. Pour recevoir à la reprise une valeur typéeY, la signature deviendrait:[Y] Xou:[Y] name XL’acceptation devrait être permissive. Là où l’on attend une fonction qui reprend avec
Yet cèdeX, on devrait aussi accepter une fonction qui ne cède queX, sans repriseSi les fonctions spéciales du package
cofournissent les fonctionnalités deresumeetNew, on peut conserver le style Go. La syntaxerangepourrait être étendue avec-:pour transmettre une valeur de reprise, et si aucune valeur n’est fournie, la reprise se ferait avec la valeur zéro par défautJe n’aime pas vraiment ça. À voir les exemples, j’ai l’impression que ça rend le langage bien plus difficile à lire et à suivre. Bien sûr, c’est peut-être à cause de mon cerveau et de mes préjugés
En plus, je n’ai pas l’impression que ça permette de faire des choses impossibles aujourd’hui avec des channels bloquants ou avec l’état
J’ai déjà essayé d’utiliser des itérateurs similaires à ceux de cet article pour éviter des allocations sur des chemins de code critiques. Avec cette approche, ce genre de code serait beaucoup moins maladroit. Surtout avec le changement de langage à venir sur les itérateurs
rangeLes channels sont inutilement lents quand ils ne servent pas à encapsuler des opérations bloquantes
Lire les commentaires me laisse un goût amer
Beaucoup de gens considèrent presque les coroutines et les green threads comme la même chose, alors que les deux ont leurs avantages et leurs inconvénients
Le fait que l’absence d’itérateurs dans la communauté Go puisse être acceptée me désole. On dirait que toute fonctionnalité susceptible de rendre le langage ne serait-ce qu’un peu plus complexe est rejetée délibérément au nom de la simplicité. Au moins, ils sont revenus sur leur position à propos des génériques
Ça me rappelle encore une fois que Go n’est probablement pas mon langage
J’en utilise un peu maintenant, mais surtout parce que la commodité syntaxique est légèrement meilleure au point d’appel. Au point de définition, comme prévu, ça reste assez laid, même si la syntaxe de Go est probablement parmi les meilleures que j’ai vues dans d’autres langages
Au final, j’ai l’impression qu’on a perdu pas mal en simplicité juste pour faire taire les plaintes du type “il n’y a pas de génériques”. Ce n’était pas un bon échange
Le package
coroproposé dans l’article sera-t-il ajouté tel quel ? Peut-être, mais probablement pas exactement sous cette forme. Est-ce que quelque chose de similaire sera ajouté ? Si je pouvais parier, je dirais que ouiDans combien de temps ? Au moins un an, donc autour de la sortie de Go 1.23 en août 2024. Peut-être un peu plus. Beaucoup moins me paraît difficile
Si les génériques ont été acceptés par la communauté, c’est parce qu’ils sont entièrement rétrocompatibles avec le code existant et qu’on peut les ignorer sans risque si on n’en a pas besoin
Sans surprise, c’est encore ce que fait la majorité du code Go. En dehors de plusieurs sortes de “collections”, il n’est pas si facile de trouver des usages vraiment pratiques des génériques. À la base, la plupart du code n’a jamais affaire à plus d’un type, et au-delà de deux types, c’est déjà assez rare
Au contraire, l’ajout des génériques a surtout montré à une communauté plus large à quel point ces fonctionnalités encensées que les gens réclament comme “indispensables” sont en réalité moins nécessaires dans un bon langage largement adopté
C’est sans doute une bonne chose qu’on s’intéresse enfin à des langages de programmation comme CLU
En revanche, après avoir utilisé les coroutines de .NET et de C++, ainsi que les Active Object de Symbian C++ et d’Active Oberon, je ne suis pas certain que cela vaille vraiment la peine d’ajouter ça à Go
Comme l’a reconnu l’équipe .NET cette année à BUILD, s’ils pouvaient revenir en arrière, il aurait mieux valu que le runtime gère cela à la manière de Go. Beaucoup de développeurs ont encore du mal à comprendre async/await
Je ne sais pas si c’est vraiment nécessaire. Dans la plupart des cas, les goroutines suffisent dans Go, et pour la sémantique
yield/resume, deux channels bloquants suffisentOn dirait de la complexité ajoutée pour le principe, et je ne suis même pas sûr que cela apporte réellement une capacité nouvelle que Go n’avait pas déjà
À titre de comparaison, dans une présentation récente, ils ont lancé 1 million de threads Elixir (BEAM VM), envoyé le message
"Hello!"à chacun, puis fait une démo où chaque thread attendait un délai aléatoire entre 0 et 2 secondes avant de renvoyer"Process received message !"En parallèle, Erlang observer était affiché à côté pour observer la consommation CPU et mémoire, ainsi que la rapidité de récupération après le garbage collection
Le plus gros goulot d’étranglement ici, c’est la capacité du terminal à suivre, mais observer semble refléter la situation réelle avec une assez bonne fidélité
https://www.youtube.com/watch?v=yxyYKnashR0
Code utilisé : https://gist.github.com/pmarreck/4cc8f2f55a561ebce2012085a3a...
Ce genre de fonctionnalité est intégré à Erlang depuis les années 1980, et donc aussi à Elixir. Vous avez probablement beaucoup entendu parler du modèle acteur ou de l’implémentation “légendaire” d’Erlang, mais je ne sais pas combien l’ont réellement vu à l’œuvre avec les outils de monitoring affichés en même temps
Ce serait bien si Go offrait ce genre de prise en charge au niveau du langage, mais l’implémentation des threads dans la BEAM VM est extrêmement efficace en ressources, tant à la création qu’à l’exécution, et cela se combine à la simplicité de la concurrence due au fait qu’elle n’autorise que des valeurs immuables ; j’ai donc l’impression que Go aura beaucoup de mal à rivaliser un jour