2 points par GN⁺ 2023-07-18 | 1 commentaires | Partager sur WhatsApp
  • 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

 
GN⁺ 2023-07-18
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é go
    Le véritable cas d’usage qui introduit cette complexité, ce sont les itérateurs de fonctions, c’est-à-dire permettre d’utiliser range sur des fonctions de type func() (T, bool). C’est un sujet discuté depuis longtemps dans la communauté Go, et sa signification sera probablement intuitive pour la plupart des programmeurs Go
    Cet 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 coroutines
    Si 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

    • Il faut aussi souligner que, dans certains cas d’usage, les coroutines sont bien plus efficaces que des goroutines complètes. Lors d’un basculement vers une coroutine, il n’y a ni changement de contexte ni réordonnancement nécessaires
      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
    • Je ne vois pas quel est le problème avec for { next := getNext(); ... }. Je me demande quel est l’avantage de l’écrire sous la forme for next := range getNext { ... }
    • Les coroutines de Go pourraient permettre d’utiliser Go comme langage hôte pour définir proprement une simulation à événements discrets. Aujourd’hui, le fait de céder la main à un acteur reste maladroit
    • Je ne vois pas pourquoi il ne suffirait pas d’utiliser range ou switch sur 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 coroutines
    • Au fond, cela ressemble quand même à un ajout façon évier de cuisine
      Cette 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 fonctions
    Dans 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

    • Les coroutines et les goroutines répondent à des besoins différents. Les goroutines occupent déjà l’espace qu’occupaient des choses comme Twisted. Il n’est nullement question ici d’introduire dans Go quelque chose de semblable à async/await
      Les 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
    • Il n’y a nulle part dans l’article de proposition qui corresponde au problème de coloration des fonctions
    • Si vous devez écrire des goroutines et utiliser Context, c’est littéralement de la coloration de fonctions
    • Le « problème » d’autres frameworks asynchrones / de threading tient peut-être au fait qu’ils empilent le threading sur des itérateurs/coroutines. Dans ce cas, les deux sont orthogonaux, donc ce n’est probablement pas aussi mauvais qu’on pourrait le croire
      Bien 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
    • Je serais curieux qu’on me partage des pistes sur de meilleurs patterns de gestion des canaux ou des frameworks
      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

    • Les coroutines ne nous sauvent pas non plus de la terreur qui consiste, pour le programmeur, à devoir expliquer à l’ordinateur tous les détails possibles. En particulier, il faut aussi lui expliquer comment éviter qu’une exécution ne dure éternellement si l’entrée diffère ne serait-ce qu’un peu de ce qu’avait imaginé le programmeur
    • Je trouve un peu surprenant qu’il existe si peu de langages qui parallélisent automatiquement le code autant que possible, mais seulement dans la mesure où les performances mesurées y gagnent réellement
      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
    • Je ne pense pas qu’on puisse aller plus bas. En revanche, il y a peut-être de la marge pour remonter vers le calcul distribué. Si je me souviens bien, dans l’une des premières versions alpha de Go, les channels fonctionnaient aussi entre machines
    • L’étape suivante pourrait être quelque chose du genre « traite cette collection d’éléments de travail comme tu veux ». En plissant un peu les yeux, toute exécution concurrente peut se voir comme une séquence de tâches, et les tâches elles-mêmes peuvent aussi être des collections. Cela reste vrai même s’il n’y a qu’une ou deux tâches
  • 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 yield en Python
    La décision de conception de Go consistant à insérer des points de reprise au niveau des appels et à certains endroits me semblait être un très bon compromis
    On 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 ?

    • Ici, yield et resume ne 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édagogiques
      Le 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 yield plutôt qu’un retour de la fonction d’entrée
    • Le garbage collector de Go est optionnel. Il suffit de définir la variable d’environnement GOGC=off pour désactiver le ramasse-miettes
      Plus 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 équivalente
    Le 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 direction

    • Les coroutines ont besoin d’un support du langage. Que faire si la pile s’épuise dans une coroutine ?
      Avec 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
    • Je préférerais ajouter un paramètre yield aux fonctions afin de mieux interagir avec le système de types actuel
      Pour 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 tout
      L’exemple pourrait devenir quelque chose comme x := func(:z int) { for { z++; :- z } }. : ajoute une signature de cession, et :- cède une valeur
      Une fonction qui ne cède que des valeurs X n’aurait besoin que de : X ou : name X. Pour recevoir à la reprise une valeur typée Y, la signature deviendrait :[Y] X ou :[Y] name X
      L’acceptation devrait être permissive. Là où l’on attend une fonction qui reprend avec Y et cède X, on devrait aussi accepter une fonction qui ne cède que X, sans reprise
      Si les fonctions spéciales du package co fournissent les fonctionnalités de resume et New, on peut conserver le style Go. La syntaxe range pourrait ê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éfaut
    • C’est vraiment horrible. Ce n’est absolument pas intuitif, et ça ne correspond pas du tout à l’un des points forts de Go. Pour écrire ou lire ce genre de code, il faudrait apprendre séparément la sémantique des coroutines en Go
  • Je 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

    • C’est exactement ça. Ceux qui défendent ça ont l’air de ne voir que le très court terme
    • Je ne vois pas de quel changement de langage il s’agirait. Ce n’est qu’une proposition pour normaliser et rendre plus efficace quelque chose que les gens font déjà avec de “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 range
    • Ce n’est pas non plus plus simple d’avoir des dizaines d’implémentations d’itérateurs incompatibles entre elles, comme dans la bibliothèque standard actuelle
      Les 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’utilise Go tous les jours, mais honnêtement, les génériques n’ont pas beaucoup changé mon code
      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
    • Il ne faut pas confondre HN avec la “communauté Go”. L’auteur de cet article est le chef de l’équipe Go
      Le package coro proposé 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 oui
      Dans 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
    • Ils ne sont pas revenus sur leur position à propos des génériques
      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é
    • Les génériques n’ont pas changé 20 % de ma base de code, et dans ces 20 %, il y avait aussi des bibliothèques. Je ne sais pas si c’est une habitude prise avec Go ou avec C, mais les génériques me donnent l’impression d’être juste une bibliothèque de plus où un programmeur, quelque part, a déjà résolu un problème
    • Je pensais que la communauté Go accueillerait favorablement une interface d’itération unifiée
  • 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 suffisent
    On 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à

    • Les goroutines et les channels ajoutent un surcoût énorme. Les utiliser comme itérateurs n’a pratiquement aucun sens
    • C’est abordé dans l’article
  • À 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