1 points par GN⁺ 2023-09-20 | 1 commentaires | Partager sur WhatsApp
  • Go 1.22 modifie les variables de boucle for pour qu’elles aient une portée par itération plutôt qu’une portée sur l’ensemble de la boucle, afin de réduire l’une des erreurs les plus classiques en Go, où une closure capture par erreur la même variable
  • Avec l’ancienne sémantique, même sans goroutine, une fonction exécutée après les itérations peut référencer le même v ou i, ne voir que la dernière valeur, ou faire en sorte qu’un test réussisse à tort
  • Les analyseurs loopclosure de go vet et gopls ne détectent que les cas certains, ce qui entraîne des faux négatifs, tandis que des vérifications plus agressives peuvent multiplier inutilement le code x := x à cause des faux positifs
  • La nouvelle sémantique ne s’applique qu’aux modules déclarant go 1.22 ou plus dans go.mod, et dans Go 1.21 il est possible de l’essayer en avant-première avec GOEXPERIMENT=loopvar
  • Google a imposé ce mode sur toutes les builds de son toolchain Go interne à partir de début mai 2023 et n’a reçu aucun signalement de problème en production pendant quatre mois, mais des tests mal écrits ont été mis en évidence

Piège de capture de variables dans les anciennes boucles for

  • Dans l’ancienne sémantique de Go, les variables de boucle for ont une portée sur l’ensemble de la boucle, si bien que du code qui y fait référence après la fin des itérations peut voir une valeur différente de celle attendue
  • Si l’on parcourt values := []string{"a", "b", "c"} en créant trois goroutines, chacune affichera non pas le v propre à son itération, mais la même variable v
  • Le même problème peut survenir même sans concurrence
    • Si l’on stocke func() { fmt.Println(i) } dans un slice à l’intérieur de la boucle pour l’exécuter plus tard, chaque fonction référencera le même i au lieu de la valeur propre à l’itération

Incidents en production et limites des analyseurs

  • Ce type d’erreur a provoqué des problèmes en production dans plusieurs entreprises, et l’incident public de Let’s Encrypt en est un exemple
  • Dans le cas de Let’s Encrypt, lors d’un parcours de map, k avait bien été copié avec kCopy := k, mais comme modelToAuthzPB(&v) utilisait des pointeurs vers des champs de v pendant la construction du résultat, il fallait aussi copier v séparément
    • La capture de variable s’étendait sur plusieurs fonctions, ce qui rendait le problème difficile à repérer
  • Les outils d’analyse statique ont du mal à déterminer si une variable survit au-delà de l’itération, et doivent donc arbitrer entre faux positifs et faux négatifs
    • Les analyseurs loopclosure de go vet et gopls ne signalent que les problèmes certains, au prix de faux négatifs
    • Des vérifications plus agressives peuvent signaler à tort du code correct comme étant erroné
  • En examinant des commits de code Go open source ajoutant des lignes x := x, on constate qu’ils mêlent de vraies corrections de bugs à de nombreuses modifications inutiles
    • Certains développeurs ajoutaient du code superflu uniquement pour satisfaire l’analyseur
    • Parmi deux diffs comme informer := informer et a := a, l’un pouvait corriger un bug et l’autre être inutile, mais il est difficile de les distinguer sans informations de type et de fonction

Nouvelle sémantique des boucles dans Go 1.22

  • Dans Go 1.22, les variables des boucles for auront une portée distincte à chaque itération
  • Les exemples précédents ne seront alors plus des programmes Go bogués, ce qui réduira à la fois les incidents en production dus à cette erreur et le besoin d’outils d’analyse imprécis
  • Pour préserver la compatibilité descendante, la nouvelle sémantique ne s’applique qu’aux packages de modules déclarant go 1.22 ou plus dans go.mod
    • Il est ainsi possible de migrer progressivement sans modifier toute une base de code d’un seul coup
    • Un contrôle au niveau fichier est aussi possible via les lignes //go:build
  • Le code existant conserve exactement la sémantique actuelle
    • La modification ne concerne que le nouveau code ou le code mis à jour
    • Les développeurs gardent la main sur le moment où la sémantique change dans un package donné

Garde-fous dans les versions antérieures de Go

  • Grâce au travail de compatibilité ascendante de Go, Go 1.21 ne compile pas le code déclarant go 1.22 ou plus
  • Les versions correctives Go 1.20.8 et Go 1.19.13 incluent aussi un traitement spécial produisant le même effet
  • Une fois Go 1.22 publié, le code écrit en s’appuyant sur la nouvelle sémantique ne pourra pas être compilé avec l’ancienne sémantique, sauf en utilisant une très ancienne version de Go hors support

Essayer l’aperçu dans Go 1.21

  • Go 1.21 inclut un aperçu du changement de portée des boucles
  • En compilant avec GOEXPERIMENT=loopvar, la ligne go de go.mod est ignorée et la nouvelle sémantique s’applique à toutes les boucles
  • Pour vérifier qu’un package et toutes ses dépendances passent bien les tests avec la nouvelle sémantique de boucle, on peut lancer :
GOEXPERIMENT=loopvar go test
  • Dans le Go Playground, il est possible de tester la nouvelle sémantique en ajoutant le commentaire // GOEXPERIMENT=loopvar en haut du programme
    • Programme d’exemple : Exemple Go Playground
    • Ce commentaire n’est pris en compte que dans le Go Playground
  • Le toolchain Go interne de Google a été modifié pour imposer ce mode sur toutes les builds dès début mai 2023, et aucun problème de code de production n’a été signalé pendant les quatre mois qui ont suivi

Bugs de tests révélés par la nouvelle sémantique

  • La nouvelle sémantique des boucles n’a pas causé de problème dans le code de production, mais elle a mis en lumière des tests qui réussissaient à tort
  • Dans l’exemple de sous-tests utilisant t.Parallel, Go 1.21 bloque chaque sous-test jusqu’à la fin de toute la boucle, puis les exécute en parallèle
    • Une fois la boucle terminée, v vaut toujours 6, donc tous les sous-tests vérifient que 6 est pair et réussissent
    • Pourtant, les cas de test incluent aussi 1, le test devrait donc échouer
  • Dans Go 1.21, la précision de l’analyseur loopclosure a été améliorée afin d’identifier et de signaler ce problème
    • Exemple de rapport dans Go Playground : Exemple de programme
    • Si go vet signale ce type de problème dans des tests, le corriger aide à préparer le passage à Go 1.22
  • Des outils et exemples pour localiser les boucles à l’origine d’échecs de tests avec la nouvelle sémantique sont rassemblés dans la FAQ

Pour aller plus loin

1 commentaires

 
GN⁺ 2023-09-20
Avis sur Hacker News
  • Il existe sans doute des exemples bien plus anciens, mais le plus ancien avertissement sur ce comportement que j’ai trouvé en 60 secondes de recherche date de 1992, il y a plus de 30 ans, dans la FAQ comp.lang.lisp.
    Elle expliquait que DOTIMES, DOLIST et DO utilisent une affectation, et non un binding, lorsqu’ils mettent à jour la variable d’itération ; donc, si un lambda capture n comme dans l’exemple, les 10 closures sont toutes créées au-dessus de la valeur de la même variable N.

    • D a le même problème : https://issues.dlang.org/show_bug.cgi?id=2043
      Si l’on capture par référence, c’est en fait le comportement attendu.
    • La norme ne précise pas si ce type de boucle modifie la valeur ou refait un binding ; il faut donc supposer qu’il n’y a pas de rebinding si l’on capture la variable.
      Cela dit, une fois qu’on a appris comment ça fonctionne, ce n’est plus un problème, et si nécessaire on peut choisir la forme et regarder l’expansion de macro pour vérifier l’implémentation.
  • L’équipe du langage C# a rencontré le même problème après avoir introduit des closures légères dans C# 4.0, et il est vite apparu que c’était un piège.
    Les utilisateurs se trompaient presque toujours dans l’utilisation de la variable de boucle, et un changement cassant la compatibilité a été introduit dans C# 5.0.
    Eric Lippert a écrit un billet qui explique bien le « pourquoi » de ce point de vue : https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
    Le billet d’annonce original de C# 5 était difficile à retrouver ; espérons qu’il n’a pas disparu lors des multiples migrations de blogs sur les domaines Microsoft après 2012.

    • Python a lui aussi reçu plusieurs fois la même demande de fonctionnalité au fil des années, mais la réponse a toujours été : « peu de bénéfices importants, et cela casserait du code existant » : https://discuss.python.org/t/make-lambdas-proper-closures/10...
      Quand on pense au bazar provoqué par le seul changement de type des chaînes lors du passage de Python 2 à 3, je doute que ce changement arrive avant Python 4.0.
      Et quelqu’un dira probablement que Python est nul parce qu’il ne corrige pas ce genre de choses, puis critiquera encore Python parce qu’un script écrit en 2003 ne fonctionne plus.
    • jaredpar, de l’équipe C#, a posté le premier commentaire dans la discussion GitHub de cette proposition pour Go : https://github.com/golang/go/discussions/56010
      À mon avis, cela a beaucoup compté pour franchir la barrière du « rejet par défaut » qu’une proposition de changement de langage doit normalement surmonter.
      Un autre élément très convaincant a été le scan de bases de code open source, pour comparer l’équilibre entre les bugs corrigés et les nouveaux bugs introduits.
    • Java a aussi eu ce problème avec les classes anonymes, et on le résout généralement en introduisant un objet fonction.
      Comme le passage se fait par valeur, cela capture l’état de la variable au moment de l’appel et réduit l’ambiguïté du code.
      Si l’on essaie de capturer des variables de façon étrange, par exemple une collection accumulée pour transformer un tableau en map et des variables déclarées peuvent se comporter différemment.
      Go semble vouloir trouver un équilibre en appliquant ce comportement seulement aux compteurs de boucle, mais certaines variables continuent malgré tout à se comporter bizarrement.
      Je suis notamment curieux de voir ce qui se passe lorsqu’on définit plusieurs variables de boucle pour scanner directement une entrée.
    • JavaScript avait le même problème et a introduit les boucles for(let).
    • Très typique de Go : ne pas apprendre des langages précédents, ignorer ce comportement, puis essayer de le corriger plus tard.
  • https://eli.thegreenplace.net/2019/go-internals-capturing-lo... semble expliquer ce problème plus en détail.

    • Il est intéressant de constater que l’ancienne astuce i := i fonctionne pour une raison totalement différente de ce que je pensais.
      Au départ, je pensais que le nouveau i était passé à la goroutine, que l’analyse d’échappement le marquait donc comme s’échappant de sa portée lexicale, qu’il était alors alloué sur le tas, et qu’une allocation sur le tas était créée à chaque itération, donnant à chaque goroutine son propre emplacement mémoire.
      En réalité, le compilateur Go dispose d’heuristiques pour choisir entre capture par référence et capture par valeur, et l’une des conditions pour capturer par valeur est que la valeur ne soit pas modifiée après son initialisation.
      Le nouveau i se trouve dans la portée du corps du for, et comme la boucle elle-même ne le met pas à jour, il est considéré comme une valeur non modifiée après initialisation ; le compilateur génère donc du code qui le capture par valeur, sans allocation sur le tas.
      Je comprends que cette seconde approche est meilleure, mais j’aimerais qu’une personne connaissant Go en profondeur m’explique pourquoi la première ne se produit pas en même temps.
  • Ce changement ne risque-t-il pas de casser les programmes qui dépendent du comportement actuel ?

    • Pour garantir la rétrocompatibilité avec le code existant, la nouvelle sémantique ne s’applique qu’aux packages des modules qui déclarent go 1.22 ou une version ultérieure dans go.mod
      Au niveau d’un fichier, cela peut aussi être déterminé avec une ligne //go:build
    • Je ne sais pas pourquoi c’est downvoté, mais en pratique, c’est bien un changement qui rompt la promesse de compatibilité de Go 1
      Cette promesse dit qu’un programme écrit selon la spécification Go 1 doit continuer à compiler et à s’exécuter correctement sans modification pendant toute la durée de vie de la spécification ; une spécification Go 2 pourra peut-être arriver un jour, mais d’ici là, même dans des versions mineures comme Go 1.1 ou Go 1.2, les programmes Go qui fonctionnent aujourd’hui doivent continuer à fonctionner
    • Lors de la préparation de Go 1.21, ils ont analysé un très vaste corpus de code Go pour voir ce qui serait affecté, et ont dit que le nombre de cas était extrêmement faible
      Ils ont estimé que cette conception avait probablement causé beaucoup plus de bugs involontaires qu’il n’y aurait de personnes affectées par la correction
    • La proposition initiale détaillait assez largement l’étude des usages existants de cette syntaxe
      De mémoire, elle indiquait que, dans la base de code de Google ou dans le code sur GitHub, ce changement ne cassait presque jamais le comportement attendu
      Ce n’est qu’après avoir vérifié à quel point les bases de code affectées étaient rares, puis mis en place un mécanisme obligeant à modifier activement le code via la version indiquée dans go.mod pour obtenir le nouveau comportement, qu’ils ont décidé de rompre la rétrocompatibilité
    • Il y en a pas mal
      https://twitter.com/go100and1/status/1690412229135601664
      https://twitter.com/go100and1/status/1690587305806057472
      https://twitter.com/go100and1/status/1690589791686119424
      https://twitter.com/go100and1/status/1690591234715492352
      https://twitter.com/go100and1/status/1690593184857145344
      https://twitter.com/go100and1/status/1691456732151889920
      La plupart n’étaient pas du tout mentionnés dans le document de proposition
  • J’ai déjà rencontré ce problème en Python, mais pas récemment
    Je ne sais pas si Python a changé, ou si c’est moi qui ai appris à repérer le problème
    Le simple code suivant suffit à montrer que cela peut toujours poser problème en Python : funcs = [(lambda: x) for x in range(3)] ; funcs[0]() affiche 2

    • C’est le bon comportement
      Python était pire auparavant, et partageait même la portée en dehors des compréhensions de liste
    • Ce comportement vient de la liaison tardive des closures en Python
      Quand on utilise une lambda dans une compréhension de liste ou une boucle, elle ne capture pas la valeur courante de x, mais une référence à la variable x
      Au moment où funcs[0]() est appelé, x a déjà été fixé à la dernière valeur de range, c’est-à-dire 2
      Pour obtenir le comportement souhaité, il faut passer x comme argument par défaut de la lambda : funcs = [(lambda x=x: x) for x in range(3)]
  • Je n’ai utilisé Go que très peu et je comprends le problème général que ce changement résout, mais je comprends moins bien les exemples plus subtils, comme le cas letsencrypt ou "range c.informerMap" contre "range alarms"
    Dans for k, v := range someMap, v est du type de la valeur de la map, et il y a une seule liaison pour toute la boucle, recopiée à chaque itération ? Si c’est le cas, cela explique le problème, mais je m’attendais à ce que v soit une référence vers l’intérieur de la map
    En parcourant rapidement la section « For statements with range clause » de la spécification, je n’ai pas trouvé la réponse, mais comme je touche très peu à Go, j’ai sans doute regardé au mauvais endroit : https://go.dev/ref/spec#For_statements
    Édit : la réponse était dans le tableau sous forme de bloc de code. J’ai dû le survoler comme une bannière. Je suis surpris que v soit une valeur copiée, et non une référence

    • Go ne prend pas en charge les pointeurs vers les clés ou les valeurs d’une map
      Il prend en charge les pointeurs vers les cases d’un tableau, mais for range copie chaque case au lieu de fournir un pointeur vers elle
    • Si vous avez une map de chaînes vers des entiers, le type de v est int
      C’est une valeur, pas un pointeur vers un int
    • J’ai retrouvé la source de ces extraits de code
      Vous pouvez la consulter si cela vous intéresse : https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      En gros, à cause du déréférencement automatique, le compilateur transforme go a.Monitor(b) en (&a).Monitor(b)
  • Je me demande comment fonctionne le passage disant : « À la suite du travail sur la compatibilité ascendante, Go 1.21 ne tente pas de compiler du code déclarant go 1.22 ou plus. Nous avons aussi ajouté un traitement spécial ayant le même effet dans les releases ponctuelles Go 1.20.8 et Go 1.19.13 ; ainsi, quand Go 1.22 sortira, le code écrit en s’appuyant sur la nouvelle sémantique ne sera jamais compilé avec l’ancienne sémantique, sauf si l’on utilise une très vieille version de Go non prise en charge. »
    Si un package est fixé à 1.22 et que je compile avec 1.18, est-ce que ça compile, ou est-ce que j’obtiens une erreur disant qu’il faut le compilateur 1.22 ?

    • Ils ont utilisé une méthode un peu rusée.
      Comme Go 1.21 a changé le format du numéro de version dans le fichier go.mod, si l’on essaie de builder avec Go 1.18, on obtient une erreur du genre go.mod:3: invalid go version '1.21.0': must match format 1.23.
      Cela dit, ce n’est le cas que lorsqu’on crée le module avec go mod init ; si l’on écrit manuellement go 1.21 dans go.mod, ça build sans protester.
    • Fait intéressant, avec Go 1.21, si un module déclare une version de Go plus élevée, le comportement par défaut consiste à récupérer une toolchain plus récente et à l’utiliser à la place : https://go.dev/blog/toolchain
      C’est une fonctionnalité assez sympa, mais c’est un comportement surprenant, et le fait de se connecter à un serveur contrôlé par Google pour télécharger un binaire me fait un peu hésiter.
      Avec le proxy de modules, c’est l’une des fonctionnalités de Go envers lesquelles je suis le plus partagé ; je serais beaucoup plus à l’aise si Go était géré par une fondation dans laquelle Google n’aurait qu’une participation.
      Édit : à la réflexion, cela concerne le cas où c’est le module courant qui déclare cette version, pas une dépendance qui déclare une autre version, donc ce n’est pas exactement la question d’origine.
    • D’après ce que je comprends, avec Go 1.18, même si un module 1.22 arrive comme dépendance, il sera compilé, et si ce module s’appuie sur cette fonctionnalité, il peut produire une logique incorrecte.
      L’utilisation de Go 1.18 devient donc activement dangereuse.
      Avec Go 1.19, il devrait y avoir une erreur de compilation.
      De toute façon, Go ne corrige pas les failles de sécurité dans les anciennes releases ni dans leur bibliothèque standard, donc j’estime que l’utilisation de ces versions est en soi risquée.
    • Il devrait y avoir une erreur de compilation.
      Mais même si tu compiles avec Go 1.22, ton code conserve la sémantique de Go 1.18.
  • Go est, à certains égards, un langage vraiment étrange.
    C’est un langage très opinionated, tout en donnant l’impression d’être un langage qui n’a presque pas d’opinions.

  • Je ne suis pas sûr de la différence entre le code qui parcourt c.informerMap et celui qui parcourt alarms, mais à vue de nez, la variable de boucle de l’un pourrait être un pointeur et celle de l’autre une valeur.
    Comme l’appel de méthode utilise un receiver pointeur, dans le cas d’une valeur, le compilateur n’ajouterait-il pas automatiquement une référence au receiver ?

    • J’ai retrouvé les sources contenant ces extraits via la recherche de code GitHub.
      https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      La différence est que, dans le premier cas, informer est une interface, donc l’appel de méthode est immédiatement résolu en informer.Run et il n’y a pas de problème.
      Dans l’autre cas, a est une struct Alarm et elle est copiée par valeur, tandis que la méthode Monitor prend un receiver pointeur.
      Le compilateur transforme donc en pratique go a.Monitor(b) en go (&a).Monitor(b), ce qui crée une référence à la variable de boucle et provoque le problème.
    • Quand on parcourt une map en Go, les valeurs sont toujours copiées, donc le premier code semble fonctionner comme attendu.
      Pour le second, je suppose que a finit par ne contenir que la valeur du dernier élément de alarms, ce qui déclenche le problème décrit dans l’article.
    • Rien qu’aux noms, celui du haut est une map et celui du bas une slice.
      Mes connaissances internes s’arrêtent à peu près là, mais les slices ont un tableau sous-jacent sur le tas, donc des pointeurs ou références y sont impliqués dans une certaine mesure.
    • Il doit clairement y avoir quelque chose du genre où le compilateur sait qu’il capture la valeur.
  • Lire ça me soulage énormément.
    Cela corrige l’un des plus gros défauts de Go.

    • Non, le plus gros défaut, c’est la gestion des erreurs.
      Si l’on écrit foo, err := getFoo(); if err != nil ... puis bar, err := getBar(); fmt.Println(bar), on peut rater la vérification de l’erreur de getBar.
      À cause des règles de portée, le pattern if foo, err := getFoo(); err != nil devient ingérable dès que l’imbrication s’approfondit un tant soit peu.
      Il introduit aussi des états invalides. Que doit retourner getFoo quand il renvoie une erreur ? On se retrouve à se demander s’il faut changer l’API pour retourner un pointeur et pouvoir renvoyer nil, ou laisser un objet partiellement construit dans un état invalide.
    • La prochaine étape, ce serait de corriger le test de nil sur les interfaces