1 points par GN⁺ 2024-08-29 | 1 commentaires | Partager sur WhatsApp
  • DoltHub a créé un exemple volontairement absurde en imbriquant à l’excès les channels couramment utilisés dans la concurrence en Go, jusqu’à envoyer un channel dans un channel
  • Dans du code réellement hérité, il y avait bien un chan chan struct{}, utilisé pour un pattern de fan-out transmettant de nouveaux channels à des goroutines workers, mais le code a été réécrit car il était difficile à raisonner et à maintenir
  • L’exemple étend à chan la blague du “4-star programmer” liée à int**** en C et langages apparentés, en utilisant _4chan:= make(chan chan chan chan int) comme channel de plus haut niveau
  • Avec factor = 3, il fait bifurquer producteurs et consommateurs à chaque niveau de channel, puis additionne les valeurs int finales pour afficher 243, soit 3 puissance 5
  • En pratique, l’approche est inadaptée à cause de la difficulté d’implémentation et de débogage, de la gestion de fermeture des channels, de la nécessité de sync.WaitGroup et des fuites de goroutines ; l’exemple s’appuie sur time.Sleep() au lieu d’une vraie logique d’arrêt

Un cas d’imbrication de channels chez Dolt

  • DoltHub développe en Go Dolt, la première base de données SQL versionnée au monde
  • Comme dans beaucoup de codebases Go, l’équipe utilise les channels et les goroutines pour implémenter l’exécution concurrente
  • La programmation concurrente étant déjà difficile en soi, on manipule généralement channels et goroutines de manière simple et intuitive
  • À une époque, du code repris d’un autre projet open source contenait un channel envoyant des channels, comme ceci
    • var c chan chan struct{}
  • Cette structure servait à implémenter un pattern de fan-out entre goroutines en transmettant des channels
    • Le channel intermédiaire jouait le rôle d’intermédiaire en remettant les channels nouvellement créés aux workers qui effectuaient le vrai travail
    • Cela fonctionnait, mais restait difficile à raisonner et à manipuler, surtout en tenant compte des fuites de goroutines
    • Le code concerné a été réécrit, et chan chan struct{} a disparu

La version Go de la blague du “4-star programmer”

  • À l’époque où le C et ses dérivés étaient largement utilisés, on plaisantait en parlant de “4-star programmer” à propos des débutants qui avaient du mal à comprendre les pointeurs
  • L’exemple classique était du code utilisant plusieurs niveaux d’indirection de pointeurs, comme int****
  • Go dérivant aussi en grande partie du C, on peut écrire le même genre de code avec des pointeurs
    • transmettre successivement *int, **int, ***int, ****int
    • si la dernière fonction exécute ****i = 100, le programme affiche i is now 100
  • Mais Go possède en plus chan, absent du C, ce qui permet d’étendre la blague à une indirection par channels

Calculer une puissance cinquième avec 4 niveaux de channels

  • Le channel de plus haut niveau est déclaré ainsi
    • _4chan := make(chan chan chan chan int)
  • Comme un identifiant Go ne peut pas commencer par un chiffre, l’exemple utilise le nom _4chan
  • Les valeurs envoyées dans _4chan sont des channels de niveau 3
    • _3chan := make(chan chan chan int)
  • En descendant ainsi de niveau en niveau, on finit par atteindre le channel de valeurs chan int
  • À chaque niveau d’indirection, des producteurs sont créés selon la constante factor
    • dans l’exemple, const factor = 3
    • sendChanChanChan lance en goroutine le producteur de 3-channel
  • Côté consommateurs, chaque niveau reçoit le channel entrant et lance factor consommateurs du niveau suivant
    • receiveChanChanChan reçoit _3chan depuis _4chan, puis démarre les consommateurs 3-channel

Transmission des valeurs au dernier niveau et somme finale

  • Au niveau le plus bas, on ne transmet plus des channels mais de vraies valeurs int
  • La fonction send envoie _1chan dans _2chan, puis démarre factor producteurs d’entiers
  • Chaque producteur d’entiers crée à son tour factor goroutines exécutant _1chan <- 1
  • Le consommateur additionne les entiers reçus dans la variable globale sum
    • sum est déclarée en atomic.Int32
    • receive(c chan int) lit les valeurs du channel et exécute sum.Add(int32(s))

Résultat d’exécution et nombre de branches

  • Le programme complet crée _4chan, lance en goroutines les couches d’envoi et de réception, puis attend pendant 500 * time.Millisecond
  • La sortie de l’exemple est la suivante
    • 3 ^ 5: 243
  • Ce programme est un exemple généralisé calculant la cinquième puissance d’un nombre de la manière la plus distribuée possible
  • Un exemple exécutable est disponible sur Go Playground, et une version avec coloration syntaxique sur GitHub Gist
  • Avec un factor plus grand, il peut être nécessaire d’augmenter la durée du Sleep pour laisser le programme se terminer
  • En activant les logs, on peut voir le nombre de branches à chaque couche de production et de consommation des channels
    • starting 3chan producer : 3 fois
    • starting 2chan producer : 9 fois
    • starting 3chan consumer : 9 fois
    • starting 2chan consumer : 27 fois
    • starting chan producer : 27 fois
    • starting 1chan consumer : 81 fois
    • starting int producer : 81 fois
    • sending int : 243 fois
    • received int : 243 fois

Pourquoi il faut éviter cela dans du vrai code

  • Cette approche est difficile à implémenter et à déboguer dans du code réel
  • Envoyer des channels dans des channels rend difficile de déterminer quand chacun doit être fermé
  • Dans un cas d’usage réel, il faudrait fermer les channels, mais cela impose de suivre la fin de tous les envois
  • Pour gérer proprement l’arrêt, il a fallu ajouter des sync.WaitGroup un peu partout, ce qui rendait cet exemple humoristique difficile à lire
  • L’exemple final a donc été simplifié en remplaçant la logique d’arrêt par time.Sleep(), au prix de nombreuses fuites de goroutines

1 commentaires

 
GN⁺ 2024-08-29
Avis de Hacker News
  • En tant que scientifique qui travaille étroitement avec de vrais ingénieurs logiciel professionnels, une grande partie de ce qu’ils font ressemble à ça, et il m’est très difficile de comprendre pourquoi ils procèdent ainsi.
    J’ai vu une ligne de code passer successivement par quatre fonctions d’interface avant d’être réellement appelée, ces fonctions étant dispersées dans différents fichiers, eux-mêmes dans différents dossiers.
    Lire ce que fait le code devient donc épuisant, et au bout de quelques niveaux on finit par se demander si l’on regarde au bon endroit, et si l’on arrivera un jour à l’endroit où le calcul réel est effectué.

    • C’est vraiment une mauvaise pratique, et ça ressemble plutôt à la manière dont un ingénieur junior trop zélé écrit du logiciel.
      Votre impression que c’est excessif et confus n’est pas fausse : quand on écrit pour la première fois du code « intéressant », cela peut sembler techniquement complexe, voire élégant, mais dans un logiciel qui doit réellement grossir, cela devient un cauchemar technique.
      J’ai passé près de deux ans à nettoyer des abus de channels dans du code Go ; les channels sont en réalité rarement nécessaires, mais au début ils sont faciles à utiliser pour toutes sortes de choses, ce qui devient un problème.
      Pour savoir si l’on utilise correctement un channel, il faut pouvoir répondre non à « ne peut-on pas faire ça avec un appel de fonction direct ? », « ne peut-on pas faire ça avec un wait group ou un mutex ? », et oui à « le gain en concurrence/parallélisme est-il assez important pour justifier la complexité du débogage de code concurrent ? ».
    • Il y a pire. Ce n’est pas un exemple très exagéré : https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpris...
      On rencontre parfois un manque terrifiant de compréhension et de compétence là où il devrait y en avoir.
      Pendant mes études de premier cycle, j’ai fait environ 30 minutes de pair programming avec un docteur en informatique, et cela a été assez éclairant.
      Il ne comprenait rien au logiciel, au point de faire remarquer que je ne vérifiais pas que la taille d’une structure de données de la bibliothèque standard n’était pas négative.
      Cela dit, il y a parfois des raisons à ce genre de structure. Parfois elles sont réellement rationnelles, et parfois elles servent à gérer une base de code délirante laissée par les personnes passées avant.
    • À une extrémité du spectre des codeurs, il y a les scientifiques ; à l’autre, les ingénieurs logiciel. Seul l’équilibre peut nous sauver.
      Il m’est arrivé de lire du code utilisé dans des articles de recherche : les mathématiques théoriques sont souvent déjà au-delà de la compréhension, et quand on descend dans le code pour en voir la logique, c’est souvent encore bien pire et illisible.
      Au fond, nous sommes habitués à notre propre façon de faire, et les autres nous paraissent étrangères.
    • La cause fréquente est le sur-engineering. La solution simple est souvent cachée et difficile à trouver.
      Cela dit, les couches d’indirection supplémentaires se justifient généralement dans l’architecture globale, et dans les cas raisonnables il peut être difficile d’en percevoir la valeur en ne regardant que localement.
      « La légèreté et le calme des premiers langages de programmation font toujours plaisir. Il n’y a pas beaucoup de texte, et pourtant beaucoup de choses se produisent. Les vieux programmes ne se lisent pas comme une dispute avec le compilateur, mais comme une conversation calme entre un chercheur éloquent et un collègue machine bien entraîné. Qui aurait cru que la sophistication achèterait tout ce bruit ? » — Dick Gabriel
    • À l’inverse, je suis d’accord pour dire que les ingénieurs logiciel poussent parfois l’abstraction trop loin quand ils ne savent pas bien ce qu’ils font, mais je n’ai pas non plus une opinion particulièrement élevée du code écrit par des gens dont le métier n’est pas ingénieur logiciel.
      On voit les deux extrêmes : des bases de code trop dispersées à force d’abstractions, et des bases de code sans aucune abstraction, proches de scripts écrits uniquement pour atteindre un objectif. Les deux sont difficiles à travailler.
      Beaucoup de scripts Python, JS ou PHP ont été écrits dans l’esprit « peu importe, donne-moi juste le résultat que je veux », alors que les personnes qui travaillent dans le code au quotidien ont besoin d’abstractions qui facilitent la collaboration et la résilience.
  • Le mème du début m’a vraiment fait rire, en tant que programmeur C en voie de rétablissement.
    C’est amusant de voir des exemples où l’on tord un langage de cette façon ; C offre énormément d’occasions de le faire, et c’est intéressant d’en voir aussi en Go.

  • On parle d’« une vieille blague de programmation datant de l’époque où C et ses dérivés dominaient », mais nous vivons encore à cette époque.

  • Il est ironique de voir ce fil critiquer cela comme un exemple typique d’excès d’abstraction.
    Si l’on voit rarement des variables à trois étoiles en C, ce n’est pas parce que les chaînes de pointeurs sont rares, mais parce qu’il est peu fréquent de devoir manipuler plus de deux niveaux à la fois.
    Dans des langages comme Python, Java ou JavaScript, où presque tout se comporte par défaut comme un pointeur, il existe certainement des chaînes de pointeurs de longueur bien supérieure à 4.
    Les parties profondes sont généralement cachées dans des structures dont on n’a pas besoin de connaître les internes, c’est-à-dire abstraites.
    Les channels jouent aussi, dans le code concurrent, à peu près le rôle que les pointeurs jouent dans le code séquentiel ; donc s’il y a ici un défaut fatal, ce n’est peut-être pas un excès d’abstraction, mais au contraire un manque d’abstraction.
    Cela dit, je ne connais pas la base de code ; peut-être que chan chan était exactement l’abstraction adaptée à ce qu’ils voulaient écrire. Dans des langages fortement concurrents comme Erlang, il est très courant d’envoyer un PID à un autre PID pour identifier le processus qui recevra la réponse.

    • Je me souviens encore m’être fait engueuler au début de ma carrière pour un commit avec trois étoiles. Quelqu’un demandait qui pouvait bien avoir besoin d’un pointeur vers un pointeur vers un pointeur.
      Mais ce n’était pas ça : c’était l’adresse d’un tableau de chaînes. C’était du C, et même aujourd’hui, environ 20 ans plus tard, je pense que c’était la solution la plus intuitive.
      Pour défendre mon collègue, il n’y avait probablement pas de commentaire. Cette partie-là était de ma faute.
  • Cela me rappelle un classique intemporel du Buena Vista Social Club : https://www.youtube.com/watch?v=o5cELP06Mik

  • chan chan Value ou chan struct{resp chan Value} sont des patterns que j’ai réellement utilisés dans des situations très spécifiques
    On aurait pu utiliser un bus de messages, mais il aurait alors fallu gérer ce bus de messages en plus

    • chan chan s’utilise assez souvent. C’est le cas où l’on envoie un message à un serveur interne ou à un acteur, en incluant dans ce message le canal sur lequel recevoir la réponse
      En pratique, cela prend plutôt la forme de chan struct { ... quelque chose contenant un canal ... } que du littéral chan chan trouvable avec grep, mais le principe est le même
      C’est un pattern très utile, et je le considère comme l’une des bases de Go
      En revanche, à ma connaissance, je n’ai jamais utilisé chan chan chan
      Remplacer tout cela par un bus de messages serait excessif. Les caractéristiques de performance sont aussi très différentes, et les canaux de Go étant proches de composants internes à un processus du système d’exploitation, il n’y a pas d’intérêt à les « mettre à niveau » vers un bus de messages pour de la communication intra-processus
    • C’est probablement un pattern similaire au serveur de chat concurrent de gopl. C’est un peu surprenant au début, mais ça reste assez lisible
      [0] : https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
    • Je l’ai déjà utilisé aussi. C’est utile pour implémenter en Go une sémantique proche des Promise
  • Un canal de canaux est un pattern normal, mais on le voit généralement plus souvent sous la forme d’un canal de valeurs de structure, cette structure contenant elle-même un champ canal
    Cela peut servir à envoyer des requêtes à traiter, puis à attendre leurs valeurs sans dépendre de leur ordre, afin d’éviter le blocage en tête de ligne
    Par exemple, on envoie des requêtes sur un canal avec quelque chose comme type request struct { params, reply chan response }, puis un worker traite le travail et place le résultat dans le canal reply
    Mais, dans les cas où cela m’a été utile, deux niveaux suffisaient, et je n’ai jamais vu de cas d’usage nécessitant chan chan chan

  • Il existe un contre-exemple de blog qui implémente un dispatch dynamique avec un canal envoyant des canaux. Ce n’est pas en Go, mais en Limbo ; le concept est le même. Peut-être que la complexité finit justement par prouver le point soulevé https://ipn.caerwyn.com/2007/07/lab-78-dynamic-dispatch.html...

    • Pour celles et ceux que cela intéresse, Limbo est un langage qui a précédé Go et qui était utilisé dans le système d’exploitation Inferno, descendant de Plan 9
  • Cela me rappelle “My favorite Erlang Program” de Joe Armstrong
    https://joearms.github.io/published/2013-11-21-My-favorite-e...