- 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
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,DOLISTundDObeim Aktualisieren der Iterationsvariable Zuweisung statt Binding verwenden; wenn also wie im Beispiel einlambdancapturt, werden alle 10 Closures über dem Wert derselben VariableNerstellt.Wenn per Referenz gecapturt wird, ist das eigentlich das erwartete Verhalten.
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.
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.
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.
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.
for(let)-Loops ein.https://eli.thegreenplace.net/2019/go-internals-capturing-lo... scheint dieses Problem ausführlicher zu erklären.
i := i-Trick aus einem völlig anderen Grund funktioniert, als ich gedacht hatte.Zuerst dachte ich, das neue
iwerde 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
iliegt im Scope desfor-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?
go.modgo 1.22oder höher deklarieren.Auf Dateiebene kann dies auch über eine
//go:build-Zeile entschieden werden.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.
Man ging davon aus, dass wegen dieses Designs viel mehr Menschen unbeabsichtigte Bugs erzeugt haben, als Menschen von der Korrektur betroffen sein würden.
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.moddas neue Verhalten zu nutzen, entschied man sich dafür, die Abwärtskompatibilität zu brechen.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]()gibt2aus.Früher war Python noch schlimmer und teilte sich sogar den Scope außerhalb der List Comprehension.
Wenn man in einer List Comprehension oder Schleife ein Lambda verwendet, wird nicht der aktuelle Wert von
xerfasst, sondern eine Referenz auf die Variablex.Wenn
funcs[0]()aufgerufen wird, istxbereits auf den letzten Wert vonrange, also2, gesetzt.Um das gewünschte Verhalten zu erhalten, kann man
xals 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 someMapistvvom 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, dassveine 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
vkeine Referenz ist, sondern ein kopierter Wert.Pointer auf Array-Slots werden unterstützt, aber
for rangekopiert, statt einen Pointer auf den jeweiligen Slot zu liefern.vint.Ein Wert, kein Pointer auf ein
int.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.22oder 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?
Weil Go 1.21 das Format der Versionsnummer in der Datei
go.modgeändert hat, bekommt man beim Versuch, mit Go 1.18 zu bauen, einen Fehler wiego.mod:3: invalid go version '1.21.0': must match format 1.23.Allerdings gilt das nur, wenn man das Modul mit
go mod initerstellt hat; wenn man manuellgo 1.21ingo.modschreibt, wird ohne Beanstandung gebaut.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.
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.
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.informerMapiteriert, und dem Code, der überalarmsiteriert, 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?
https://github.com/adobe/kratos/blob/93246f92d53feba73743dbf...
https://github.com/StalkR/goircbot/blob/6081ed5d1d74f01767d7...
Der Unterschied ist, dass
informerim einen Fall ein Interface ist, sodass der Methodenaufruf sofort zuinformer.Runaufgelöst wird und es kein Problem gibt.Im anderen Fall ist
aeinAlarm-Struct und wird als Wert kopiert; die MethodeMonitornimmt einen Pointer-Receiver.Daher wandelt der Compiler
go a.Monitor(b)im Grunde ingo (&a).Monitor(b)um, und das erzeugt eine Referenz auf die Loop-Variable, was das Problem verursacht.Beim zweiten vermute ich, dass
aam Ende nur den Wert des letzten Elements vonalarmsenthält, wodurch das im Artikel beschriebene ursprüngliche Problem auftritt.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.
Das zu lesen, erleichtert mich sehr.
Einer der größten Makel in Go wird behoben.
Wenn man
foo, err := getFoo(); if err != nil ...und danach etwas wiebar, err := getBar(); fmt.Println(bar)schreibt, übersieht man die Fehlerprüfung fürgetBar.Wegen der Scope-Regeln wird das Muster
if foo, err := getFoo(); err != nilschon bei etwas tieferer Verschachtelung kaum noch beherrschbar.Außerdem führt es ungültige Zustände ein. Was soll
getFoozurückgeben, wenn es einen Fehler zurückgibt? Man überlegt dann, ob man die API auf Pointer-Rückgabe umstellt undnilzurückgibt oder ein teilweise erzeugtes Objekt in einem ungültigen Zustand belässt.