1 Punkte von GN⁺ 2023-09-20 | 1 Kommentare | Auf WhatsApp teilen
  • Go 1.22 ändert for-Schleifenvariablen von einem Gültigkeitsbereich über die gesamte Schleife hin zu einem Gültigkeitsbereich pro Iteration, um einen der typischen Go-Fehler zu verringern, bei dem Closures versehentlich dieselbe Variable capturen
  • In der bisherigen Semantik können selbst ohne Goroutinen Funktionen, die nach einer Iteration ausgeführt werden, auf dasselbe v oder i verweisen, sodass sie nur den letzten Wert sehen oder Tests fälschlich bestehen
  • Die loopclosure-Analyzer in go vet und gopls erkennen nur eindeutige Fälle, wodurch False Negatives entstehen; aggressivere Checker können wegen False Positives unnötigen x:= x-Code vermehren
  • Die neue Semantik gilt nur für Module, die in go.mod go 1.22 oder höher deklarieren; in Go 1.21 lässt sich mit GOEXPERIMENT=loopvar eine Vorschau ausführen
  • Google hat diesen Modus seit Anfang Mai 2023 im internen Go-Toolchain für alle Builds erzwungen; in vier Monaten wurden keine Produktionsprobleme gemeldet, aber fehlerhaft geschriebene Tests wurden sichtbar

Fallstricke bei der Variablen-Capture in bisherigen for-Schleifen

  • In der bisherigen Go-Semantik haben for-Schleifenvariablen einen Gültigkeitsbereich über die gesamte Schleife, sodass Code, der nach Ende einer Iteration auf diese Variable zugreift, möglicherweise einen anderen Wert sieht als beabsichtigt
  • Wenn man über values := []string{"a", "b", "c"} iteriert und drei Goroutinen erzeugt, gibt jede Goroutine nicht das iterationsspezifische v, sondern dieselbe Variable v aus
  • Dasselbe Problem tritt auch ohne Nebenläufigkeit auf
    • Wenn man innerhalb der Schleife func() { fmt.Println(i) } in einem Slice speichert und später ausführt, verweist jede Funktion nicht auf den Wert der jeweiligen Iteration, sondern auf dasselbe i

Produktionsausfälle und Grenzen von Analyzern

  • Solche Fehler haben bei mehreren Unternehmen zu Produktionsproblemen geführt; das öffentliche Issue von Let’s Encrypt ist eines davon
  • Im Fall von Let’s Encrypt wurde beim Iterieren über eine Map zwar k mit kCopy := k kopiert, aber da modelToAuthzPB(&v) bei der Erzeugung des Ergebnisses Feld-Pointer von v verwendete, musste auch v separat kopiert werden
    • Da sich das Variablen-Capturing über mehrere Funktionen erstreckte, war das Problem schwer zu erkennen
  • Statische Analysewerkzeuge können nur schwer entscheiden, ob eine Variable über eine Iteration hinaus weiterlebt, und müssen daher zwischen False Positives und False Negatives abwägen
    • Die loopclosure-Analyzer in go vet und gopls melden nur sichere Probleme und nehmen dadurch False Negatives in Kauf
    • Aggressivere Checker können auch korrekten Code fälschlich als fehlerhaft markieren
  • Betrachtet man Commits in Open-Source-Go-Code, die Zeilen wie x := x hinzufügen, findet man neben echten Bugfixes auch viele unnötige Änderungen
    • Es gab Fälle, in denen Entwickler unnötigen Code hinzufügten, nur um den Checker zufriedenzustellen
    • Von zwei Diffs wie informer := informer und a := a war nur einer ein Bugfix und der andere eine unnötige Änderung, aber ohne Typ- und Funktionsinformationen ist die Unterscheidung schwer

Die neue Schleifensemantik in Go 1.22

  • In Go 1.22 sollen for-Schleifenvariablen für jede Iteration einen eigenen Gültigkeitsbereich erhalten
  • Die vorherigen Beispiele sind dann keine fehlerhaften Go-Programme mehr, und sowohl Produktionsprobleme durch diese Art von Fehler als auch der Bedarf an ungenauen Prüfwerkzeugen nehmen ab
  • Aus Gründen der Abwärtskompatibilität gilt die neue Semantik nur für Pakete in Modulen, deren go.mod go 1.22 oder höher deklariert
    • Dadurch ist eine schrittweise Migration möglich, statt die gesamte Codebasis auf einmal umzustellen
    • Mit //go:build-Zeilen ist auch eine Steuerung auf Dateiebene möglich
  • Bestehender Code behält unverändert die bisherige Bedeutung
    • Die Änderung gilt nur für neuen oder aktualisierten Code
    • Entwickler können steuern, ab welchem Zeitpunkt sich die Semantik in einem bestimmten Paket ändert

Schutzmechanismen in älteren Go-Versionen

  • Entsprechend den Arbeiten zur forward compatibility von Go kompiliert Go 1.21 keinen Code, der go 1.22 oder höher deklariert
  • Auch in den Point-Releases Go 1.20.8 und Go 1.19.13 wurde eine Sonderbehandlung aufgenommen, die denselben Effekt erzeugt
  • Nach der Veröffentlichung von Go 1.22 wird Code, der auf die neue Semantik angewiesen ist, nicht mehr mit der alten Semantik kompiliert, solange nicht sehr alte nicht mehr unterstützte Go-Versionen verwendet werden

Vorschau in Go 1.21 ausführen

  • Go 1.21 enthält eine Vorschau auf die Änderung des Schleifen-Gültigkeitsbereichs
  • Wenn mit gesetztem GOEXPERIMENT=loopvar kompiliert wird, wird die go-Zeile in go.mod ignoriert und die neue Semantik auf alle Schleifen angewendet
  • Um zu prüfen, ob ein Paket und alle seine Abhängigkeiten auch mit der neuen Schleifensemantik ihre Tests bestehen, führt man Folgendes aus
GOEXPERIMENT=loopvar go test
  • Im Go Playground kann man die neue Semantik testen, indem man am Anfang des Programms den Kommentar // GOEXPERIMENT=loopvar einfügt
  • Googles interne Go-Toolchain wurde seit Anfang Mai 2023 so gepatcht, dass dieser Modus in allen Builds erzwungen wird; in den vier Monaten danach wurden keine Probleme im Produktionscode gemeldet

Testfehler, die durch die neue Semantik sichtbar werden

  • Die neue Schleifensemantik verursachte keine Probleme im Produktionscode, machte aber Tests sichtbar, die zuvor fälschlich bestanden hatten
  • Im Beispiel mit Subtests, die t.Parallel verwenden, blockiert Go 1.21 jeden Subtest bis zum Ende der gesamten Schleife und führt sie erst dann parallel aus
    • Wenn die Schleife endet, ist v immer 6, daher prüfen alle Subtests nur, ob 6 gerade ist, und bestehen
    • Da im eigentlichen Testfall auch 1 enthalten ist, müsste der Test in Wirklichkeit fehlschlagen
  • In Go 1.21 wurde die Präzision des loopclosure-Analyzers verbessert, sodass dieses Problem erkannt und gemeldet werden kann
    • Beispiel für eine Meldung im Go Playground: Programmbeispiel
    • Wenn go vet solche Probleme in Tests meldet, hilft ihre Behebung bei der Vorbereitung auf Go 1.22
  • Werkzeuge und Beispiele, um bei aktivierter neuer Semantik die Schleifen zu finden, die bestimmte Testfehler verursachen, sind in der FAQ zusammengefasst

Weiterführende Informationen

1 Kommentare

 
GN⁺ 2023-09-20
Meinungen auf Hacker News
  • Es gibt sicher noch deutlich frühere Beispiele, aber die älteste Warnung zu diesem Verhalten, die ich in 60 Sekunden Suche gefunden habe, stammt aus der comp.lang.lisp FAQ von 1992, also von vor über 30 Jahren.
    Dort wird erklärt, dass DOTIMES, DOLIST und DO beim Aktualisieren der Iterationsvariable Zuweisung statt Binding verwenden; wenn also wie im Beispiel ein lambda n capturt, werden alle 10 Closures über dem Wert derselben Variable N erstellt.

    • D hat dasselbe Problem: https://issues.dlang.org/show_bug.cgi?id=2043
      Wenn per Referenz gecapturt wird, ist das eigentlich das erwartete Verhalten.
    • Im Standard ist nicht festgelegt, ob solche Loops Werte ändern oder neu binden; wenn man also Variablen capturt, sollte man davon ausgehen, dass nicht neu gebunden wird.
      Hat man die Funktionsweise einmal gelernt, ist es aber kein Problem mehr, und bei Bedarf kann man das Form auswählen und per Makro-Expansion prüfen, wie es implementiert ist.
  • Auch das C#-Sprachteam hatte dasselbe Problem, nachdem es in C# 4.0 leichtgewichtige Closures eingeführt hatte, und es stellte sich schnell als Fallstrick heraus.
    Nutzer verwendeten die Loop-Variable fast immer falsch, und in C# 5.0 wurde eine Änderung eingeführt, die die Kompatibilität brach.
    Eric Lippert hat aus dieser Perspektive einen guten Beitrag zum „Warum“ geschrieben: https://ericlippert.com/2009/11/12/closing-over-the-loop-var...
    Den ursprünglichen Ankündigungsbeitrag zu C# 5 konnte ich schwer finden; ich hoffe, er ist bei den diversen Blog-Migrationen auf Microsoft-Domains seit 2012 nicht verschwunden.

    • Auch Python hat über Jahre hinweg mehrfach denselben Feature-Request erhalten, aber die Antwort war stets: „wenig großer Nutzen, bricht bestehenden Code“: https://discuss.python.org/t/make-lambdas-proper-closures/10...
      Wenn man bedenkt, welches Chaos schon die Änderung der String-Typen beim Übergang von Python 2 zu 3 ausgelöst hat, glaube ich nicht, dass diese Änderung vor Python 4.0 kommen wird.
      Und irgendjemand wird wohl erst Python dafür schlechtreden, dass es so etwas nicht fixt, und dann wieder über Python schimpfen, weil ein Skript von 2003 nicht mehr läuft.
    • jaredpar aus dem C#-Team hat in der GitHub-Diskussion zu diesem Go-Vorschlag den ersten Kommentar geschrieben: https://github.com/golang/go/discussions/56010
      Ich denke, das hat eine große Rolle dabei gespielt, die Hürde des „erst einmal ablehnen“ zu überwinden, die Vorschläge für Sprachänderungen grundsätzlich haben sollten.
      Ein weiterer stark überzeugender Punkt war das Ergebnis eines Scans öffentlicher Codebasen, bei dem das Verhältnis zwischen behobenen Bugs und neu entstehenden Bugs betrachtet wurde.
    • Auch Java hatte dieses Problem bei anonymen Klassen, und üblicherweise löst man es, indem man Funktionsobjekte einführt.
      Weil Werte übergeben werden, wird der Variablenzustand zum Aufrufzeitpunkt gecapturt, was die Mehrdeutigkeit im Code reduziert.
      Wenn man versucht, Variablen auf merkwürdige Weise zu capturen, verhalten sich etwa eine Collection, die man akkumuliert, um ein Array in eine Map umzuwandeln, und deklarierte Variablen unterschiedlich.
      Go scheint einen Ausgleich schaffen zu wollen, indem es dieses Verhalten nur auf Loop-Counter anwendet, aber einige Variablen verhalten sich trotzdem weiterhin merkwürdig.
      Besonders interessiert mich, was passiert, wenn man mehrere Loop-Variablen definiert, um Eingaben direkt zu scannen.
    • Auch JavaScript hatte dasselbe Problem und führte for(let)-Loops ein.
    • Ganz Go-typisch: Man lernt nicht von früheren Sprachen, ignoriert dieses Verhalten und versucht später, es wieder zu reparieren.
  • https://eli.thegreenplace.net/2019/go-internals-capturing-lo... scheint dieses Problem ausführlicher zu erklären.

    • Interessant ist, dass der alte i := i-Trick aus einem völlig anderen Grund funktioniert, als ich gedacht hatte.
      Zuerst dachte ich, das neue i werde an die Goroutine übergeben, die Escape-Analyse markiere es deshalb als außerhalb des lexikalischen Scopes entweichend, es werde daher auf dem Heap alloziert, und pro Iteration entstehe eine Heap-Allokation, sodass jede Goroutine auf eine eigene Speicherstelle verweise.
      Tatsächlich hat der Go-Compiler eine Heuristik, die zwischen Capture by Reference und Capture by Value wählt, und es gibt eine Bedingung, nach der Werte, die nach der Initialisierung nicht aktualisiert werden, by Value gecapturt werden.
      Das neue i liegt im Scope des for-Bodys und wird vom Loop selbst nicht aktualisiert; daher wird es als Wert betrachtet, der nach der Initialisierung nicht aktualisiert wird, und es wird Code erzeugt, der ohne Heap-Allokation by Value capturt.
      Mir ist klar, dass Letzteres besser ist, aber ich würde gern von jemandem mit tiefem Go-Wissen hören, warum Ersteres nicht ebenfalls passiert.
  • Wird diese Änderung nicht Programme kaputtmachen, die vom aktuellen Verhalten abhängen?

    • Um Abwärtskompatibilität mit bestehendem Code zu gewährleisten, gilt die neue Semantik nur für Pakete in Modulen, die in go.mod go 1.22 oder höher deklarieren.
      Auf Dateiebene kann dies auch über eine //go:build-Zeile entschieden werden.
    • Ich weiß nicht, warum das Downvotes bekommt, aber tatsächlich ist es eine Änderung, die das Kompatibilitätsversprechen von Go 1 bricht.
      Dieses Versprechen besagt, dass Programme, die gemäß der Go-1-Spezifikation geschrieben wurden, während der Lebensdauer der Spezifikation unverändert weiter kompilieren und korrekt laufen müssen; irgendwann könnte es zwar eine Go-2-Spezifikation geben, aber bis dahin müssen Go-Programme, die heute funktionieren, auch in Point-Releases wie Go 1.1 oder Go 1.2 weiterhin funktionieren.
    • Im Zuge der Vorbereitung auf Go 1.21 wurde ein sehr großes Korpus an Go-Code analysiert, um zu sehen, was betroffen wäre, und die Zahl sei sehr, sehr klein gewesen.
      Man ging davon aus, dass wegen dieses Designs viel mehr Menschen unbeabsichtigte Bugs erzeugt haben, als Menschen von der Korrektur betroffen sein würden.
    • Der ursprüngliche Vorschlag behandelte die Untersuchung bestehender Nutzungsfälle dieser Syntax recht ausführlich.
      Soweit ich mich erinnere, hieß es, dass diese Änderung im Google-Codebestand oder in GitHub-Code nur sehr selten erwartetes Verhalten brechen würde.
      Erst nachdem man festgestellt hatte, wie wenige Codebasen betroffen wären, und einen Mechanismus geschaffen hatte, bei dem Code aktiv angepasst werden muss, um über die Versionsangabe in go.mod das neue Verhalten zu nutzen, entschied man sich dafür, die Abwärtskompatibilität zu brechen.
    • Ziemlich viele.
      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
      Die meisten davon wurden im Vorschlagsdokument überhaupt nicht erwähnt.
  • Auch in Python hatte ich dieses Problem schon, wenn auch nicht in letzter Zeit.
    Ich bin mir nicht sicher, ob Python sich geändert hat oder ob ich das Problem einfach zu erkennen gelernt habe.
    Dass es in Python immer noch ein Problem sein kann, zeigt schon dieser Code: funcs = [(lambda: x) for x in range(3)]; funcs[0]() gibt 2 aus.

    • Das ist das korrekte Verhalten.
      Früher war Python noch schlimmer und teilte sich sogar den Scope außerhalb der List Comprehension.
    • Dieses Verhalten liegt am Late Binding von Python-Closures.
      Wenn man in einer List Comprehension oder Schleife ein Lambda verwendet, wird nicht der aktuelle Wert von x erfasst, sondern eine Referenz auf die Variable x.
      Wenn funcs[0]() aufgerufen wird, ist x bereits auf den letzten Wert von range, also 2, gesetzt.
      Um das gewünschte Verhalten zu erhalten, kann man x als Default-Argument des Lambdas übergeben: funcs = [(lambda x=x: x) for x in range(3)]
  • Ich habe Go nur ein wenig benutzt und verstehe das allgemeine Problem, das diese Änderung löst, aber die subtileren Beispiele wie der letsencrypt-Fall oder "range c.informerMap" versus "range alarms" verstehe ich nicht richtig.
    Bei for k, v := range someMap ist v vom Werttyp der Map, und es gibt eine einzige Bindung für die gesamte Schleife, in die bei jeder Iteration kopiert wird? Dann wäre das Problem erklärt, aber ich hätte erwartet, dass v eine Referenz auf das Innere der Map ist.
    Ein kurzer Blick in den Abschnitt „For statements with range clause“ der Spezifikation hat mir die Antwort nicht geliefert, aber ich arbeite kaum mit Go und habe vermutlich an der falschen Stelle gesucht: https://go.dev/ref/spec#For_statements
    Edit: Die Antwort stand in einer Tabelle im Codeblock-Format. Ich habe sie wohl wie einen Banner übersehen. Überraschend, dass v keine Referenz ist, sondern ein kopierter Wert.

    • Go unterstützt keine Pointer auf Map-Schlüssel oder -Werte.
      Pointer auf Array-Slots werden unterstützt, aber for range kopiert, statt einen Pointer auf den jeweiligen Slot zu liefern.
    • Wenn du eine Map von Strings auf Integer hast, ist der Typ von v int.
      Ein Wert, kein Pointer auf ein int.
    • Ich habe die Originale dieser Codeausschnitte gefunden.
      Falls du neugierig bist, kannst du sie dir ansehen: https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      Im Grunde wandelt der Compiler wegen automatischer Dereferenzierung go a.Monitor(b) in (&a).Monitor(b) um.
  • Mich interessiert, wie der Teil funktioniert: „Als Ergebnis der Arbeiten an Vorwärtskompatibilität versucht Go 1.21 nicht, Code zu kompilieren, der go 1.22 oder höher deklariert. In die Point-Releases Go 1.20.8 und Go 1.19.13 wurde ebenfalls eine Sonderbehandlung mit demselben Effekt eingebaut; wenn Go 1.22 erscheint, wird Code, der in Abhängigkeit von der neuen Semantik geschrieben wurde, daher niemals mit der alten Semantik kompiliert, außer man verwendet eine sehr alte, nicht mehr unterstützte Go-Version.“
    Wenn irgendein Paket auf 1.22 festgelegt ist und ich mit 1.18 kompiliere: Wird es kompiliert, oder kommt ein Fehler, dass ein 1.22-Compiler nötig ist?

    • Es wurde ein etwas trickreicher Ansatz gewählt.
      Weil Go 1.21 das Format der Versionsnummer in der Datei go.mod geändert hat, bekommt man beim Versuch, mit Go 1.18 zu bauen, einen Fehler wie go.mod:3: invalid go version '1.21.0': must match format 1.23.
      Allerdings gilt das nur, wenn man das Modul mit go mod init erstellt hat; wenn man manuell go 1.21 in go.mod schreibt, wird ohne Beanstandung gebaut.
    • Interessanterweise ist das Standardverhalten in Go 1.21, wenn ein Modul eine höhere Go-Version deklariert, dass eine neuere Toolchain geholt und stattdessen verwendet wird: https://go.dev/blog/toolchain
      Ein ziemlich cooles Feature, aber es ist überraschendes Verhalten, und dass dabei ein von Google kontrollierter Server kontaktiert wird, um Binaries herunterzuladen, macht mich etwas zögerlich.
      Zusammen mit dem Modul-Proxy ist das eines der Features in Go, bei denen ich am zwiespältigsten bin; ich würde mich deutlich wohler fühlen, wenn Go von einer Stiftung verwaltet würde, an der Google nur beteiligt ist.
      Edit: Wenn ich darüber nachdenke, geht es hier darum, dass das aktuelle Modul die Version deklariert, nicht eine Dependency; das unterscheidet sich also von der ursprünglichen Frage.
    • Nach meinem Verständnis wird unter Go 1.18 ein 1.22-Modul auch dann kompiliert, wenn es als Dependency hereinkommt, und wenn es sich auf dieses Feature verlässt, kann dadurch falsche Logik entstehen.
      Deshalb wird die Verwendung von Go 1.18 aktiv gefährlich.
      Unter Go 1.19 sollte es einen Compiler-Fehler geben.
      Da Go ohnehin keine Sicherheitsfixes für alte Releases und deren Standardbibliotheken liefert, halte ich die Nutzung solcher Versionen an sich für gefährlich.
    • Es sollte einen Kompilierfehler geben.
      Aber selbst wenn du mit Go 1.22 kompilierst, hat dein Code weiterhin die Go-1.18-Semantik.
  • Go ist in mancher Hinsicht eine wirklich seltsame Sprache.
    Sie ist eine Sprache mit sehr starken Meinungen und wirkt gleichzeitig wie eine Sprache, die viel zu wenig Meinung hat.

  • Ich bin mir nicht sicher, worin der Unterschied zwischen dem Code liegt, der über c.informerMap iteriert, und dem Code, der über alarms iteriert, aber ich würde vermuten: In einem Fall ist die Loop-Variable ein Pointer, im anderen ein Wert.
    Da der Methodenaufruf einen Pointer-Receiver verwendet, fügt der Compiler im Wert-Fall vielleicht automatisch eine Referenz auf den Receiver ein?

    • Über die GitHub-Code-Suche habe ich die Originale gefunden, die diese Codeausschnitte enthalten.
      https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
      https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
      Der Unterschied ist, dass informer im einen Fall ein Interface ist, sodass der Methodenaufruf sofort zu informer.Run aufgelöst wird und es kein Problem gibt.
      Im anderen Fall ist a ein Alarm-Struct und wird als Wert kopiert; die Methode Monitor nimmt einen Pointer-Receiver.
      Daher wandelt der Compiler go a.Monitor(b) im Grunde in go (&a).Monitor(b) um, und das erzeugt eine Referenz auf die Loop-Variable, was das Problem verursacht.
    • Wenn man in Go über eine Map iteriert, werden Werte immer kopiert, daher scheint der erste Code wie erwartet zu funktionieren.
      Beim zweiten vermute ich, dass a am Ende nur den Wert des letzten Elements von alarms enthält, wodurch das im Artikel beschriebene ursprüngliche Problem auftritt.
    • Dem Namen nach ist das obere eine Map und das untere ein Slice.
      Mein internes Wissen reicht nur bis dahin, aber ein Slice hat ein Backing Array auf dem Heap, sodass Pointer oder Referenzen bis zu einem gewissen Grad damit verwoben sind.
    • Es gibt offenbar definitiv irgendeinen Mechanismus, bei dem der Compiler weiß, dass er sich den Wert holt.
  • Das zu lesen, erleichtert mich sehr.
    Einer der größten Makel in Go wird behoben.

    • Nein, der größte Makel ist das Error Handling.
      Wenn man foo, err := getFoo(); if err != nil ... und danach etwas wie bar, err := getBar(); fmt.Println(bar) schreibt, übersieht man die Fehlerprüfung für getBar.
      Wegen der Scope-Regeln wird das Muster if foo, err := getFoo(); err != nil schon bei etwas tieferer Verschachtelung kaum noch beherrschbar.
      Außerdem führt es ungültige Zustände ein. Was soll getFoo zurückgeben, wenn es einen Fehler zurückgibt? Man überlegt dann, ob man die API auf Pointer-Rückgabe umstellt und nil zurückgibt oder ein teilweise erzeugtes Objekt in einem ungültigen Zustand belässt.
    • Als Nächstes sollte man die nil-Prüfung bei Interfaces beheben.