
Heute habe ich beschlossen, für Sie einen kurzen Artikel über die Innenseiten der Implementierung der sogenannten Verschlüsse oder Verschlüsse zu übersetzen. Außerdem erfahren Sie, wie Go in verschiedenen Fällen automatisch ermittelt, ob ein Zeiger / Link oder ein Wert verwendet werden soll. Durch das Verstehen dieser Dinge werden Fehler vermieden. Und es ist nur so, dass all diese Innereien verdammt interessant sind, denke ich!
Und ich möchte Sie auch zum Golang Conf 2019 einladen, der am 7. Oktober in Moskau stattfinden wird. Ich bin Mitglied des Ausschusses für Konferenzprogramme, und meine Kollegen und ich haben viele gleichermaßen harte und sehr, sehr interessante Berichte ausgewählt. Was ich liebe!
Unter dem Schnitt gebe ich das Wort an den Autor weiter.
Im Go-Wiki gibt es eine Seite mit dem Titel " Häufige Fehler" . Seltsamerweise gibt es nur ein Beispiel: Missbrauch von Schleifenvariablen mit Goroutinen:
for _, val := range values { go func() { fmt.Println(val) }() }
Dieser Code gibt den letzten Wert aus dem Array von Werten len (Werte) mal aus. Das Korrigieren des Codes ist sehr einfach:
Dieses Beispiel reicht aus, um das Problem zu verstehen und nie wieder einen Fehler zu machen. Wenn Sie jedoch die Implementierungsdetails kennen möchten, erhalten Sie in diesem Artikel ein tiefes Verständnis sowohl des Problems als auch der Lösung.
Grundlegende Dinge: Wertübergabe und Referenzübergabe
In Go gibt es einen Unterschied bei der Übergabe von Objekten nach Wert und Referenz [1]. Beginnen wir mit Beispiel 1 [2]:
func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go foobyval(i) } time.Sleep(100 * time.Millisecond) }
Höchstwahrscheinlich hat niemand Zweifel daran, dass das Ergebnis Werte von 0 bis 4 anzeigt. Wahrscheinlich in einer zufälligen Reihenfolge.
Schauen wir uns Beispiel 2 an .
func foobyref(n *int) { fmt.Println(*n) } func main() { for i := 0; i < 5; i++ { go foobyref(&i) } time.Sleep(100 * time.Millisecond) }
Als Ergebnis wird Folgendes angezeigt:
5
5
5
5
5
Wenn wir verstehen, warum das Ergebnis genau das ist, erhalten wir bereits 80% des Verständnisses der Essenz des Problems. Nehmen wir uns daher etwas Zeit, um die Gründe zu finden.
Und die Antwort ist genau dort in der Go-Sprachspezifikation . Die Spezifikation lautet:
In der Initialisierungsanweisung deklarierte Variablen werden in jeder Schleife wiederverwendet.
Dies bedeutet, dass beim Ausführen des Programms nur ein Objekt oder Speicher für die Variable i vorhanden ist und nicht für jeden Zyklus ein neues erstellt wird. Dieses Objekt nimmt bei jeder Iteration einen neuen Wert an.
Schauen wir uns den Unterschied im generierten Maschinencode [3] für die Schleife in den Beispielen 1 und 2 an. Beginnen wir mit Beispiel 1.
0x0026 00038 (go-func-byval.go:14) MOVL $8, (SP) 0x002d 00045 (go-func-byval.go:14) LEAQ "".foobyval·f(SB), CX 0x0034 00052 (go-func-byval.go:14) MOVQ CX, 8(SP) 0x0039 00057 (go-func-byval.go:14) MOVQ AX, 16(SP) 0x003e 00062 (go-func-byval.go:14) CALL runtime.newproc(SB) 0x0043 00067 (go-func-byval.go:13) MOVQ "".i+24(SP), AX 0x0048 00072 (go-func-byval.go:13) INCQ AX 0x004b 00075 (go-func-byval.go:13) CMPQ AX, $5 0x004f 00079 (go-func-byval.go:13) JLT 33
Die Go-Anweisung wird zum Aufruf der Funktion runtime.newproc. Die Mechanik dieses Prozesses ist sehr interessant, aber lassen wir dies für den nächsten Artikel. Jetzt interessiert uns mehr, was mit der Variablen i passiert. Es wird im AX-Register gespeichert, das dann als Wert durch den Stapel an die foobyval-Funktion [4] als Argument übergeben wird. "Nach Wert" sieht in diesem Fall so aus, als würde der Wert des AX-Registers auf den Stapel kopiert. Und das Ändern der AX in der Zukunft hat keinen Einfluss darauf, was an die Foobyval-Funktion übergeben wird.
Und so sieht Beispiel 2 aus:
0x0040 00064 (go-func-byref.go:14) LEAQ "".foobyref·f(SB), CX 0x0047 00071 (go-func-byref.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-func-byref.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-func-byref.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-func-byref.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-func-byref.go:13) INCQ (AX) 0x005e 00094 (go-func-byref.go:13) CMPQ (AX), $5 0x0062 00098 (go-func-byref.go:13) JLT 57
Der Code ist sehr ähnlich - mit nur einem, aber sehr wichtigen Unterschied. Jetzt ist in AX die Adresse i und nicht ihr Wert. Beachten Sie auch, dass Inkrement und Vergleich für die Schleife auf (AX) und nicht auf AX erfolgen. Und wenn wir dann AX auf den Stapel legen, übergeben wir, wie sich herausstellt, die Adresse i an die Funktion. Die Änderung (AX) wird auf diese Weise auch in Goroutine gesehen.
Keine Überraschungen. Am Ende übergeben wir einen Zeiger auf eine Zahl in der Funktion foobyref.
Während des Betriebs endet der Zyklus schneller, als eine der erstellten Goroutinen zu arbeiten beginnt. Wenn sie anfangen zu arbeiten, haben sie einen Zeiger auf dieselbe Variable i und nicht auf eine Kopie. Und was ist der Wert von i in diesem Moment? Der Wert ist 5. Der Wert, bei dem der Zyklus gestoppt wurde. Und deshalb leiten alle Goroutinen 5 ab.
Methoden mit einem Wert VS Methoden mit einem Zeiger
Ein ähnliches Verhalten kann beim Erstellen von Goroutinen beobachtet werden, die Methoden aufrufen. Dies wird durch dieselbe Wiki-Seite angezeigt. Schauen Sie sich Beispiel 3 an :
type MyInt int func (mi MyInt) Show() { fmt.Println(mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) }
In diesem Beispiel werden die Elemente des ms-Arrays angezeigt. In zufälliger Reihenfolge, wie wir erwartet hatten. Ein sehr ähnliches Beispiel 4 verwendet eine Zeigermethode für die Show-Methode:
type MyInt int func (mi *MyInt) Show() { fmt.Println(*mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) }
Versuchen Sie zu erraten, wie die Schlussfolgerung aussehen wird: 90, fünfmal gedruckt. Der Grund ist der gleiche wie im einfacheren Beispiel 2. Hier ist das Problem aufgrund des syntaktischen Zuckers in Go bei Verwendung von Zeigermethoden weniger auffällig. Wenn wir in den Beispielen beim Wechsel von Beispiel 1 zu Beispiel 2 i in & i geändert haben, sieht der Aufruf hier gleich aus! m.Show () in beiden Beispielen, und das Verhalten ist unterschiedlich.
Es scheint mir keine sehr glückliche Kombination aus zwei Go-Funktionen zu sein. Nichts an der Stelle des Anrufs weist auf eine Übertragung als Referenz hin. Und Sie müssen sich die Implementierung der Show-Methode ansehen, um genau zu sehen, wie der Aufruf abläuft (und die Methode kann sich natürlich in einer völlig anderen Datei oder einem anderen Paket befinden).
In den meisten Fällen ist diese Funktion nützlich. Wir schreiben saubereren Code. Das Übergeben von Referenzen führt hier jedoch zu unerwarteten Effekten.
Kurzschlüsse
Endlich kommen wir zu den Schließungen. Schauen wir uns Beispiel 5 an :
func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go func() { foobyval(i) }() } time.Sleep(100 * time.Millisecond) }
Er wird folgendes drucken:
5
5
5
5
5
Und das trotz der Tatsache, dass ich im Abschluss als Wert an foobyval übergeben werde. Ähnlich wie in Beispiel 1. Aber warum? Schauen wir uns die Assembler-Loop-Ansicht an:
0x0040 00064 (go-closure.go:14) LEAQ "".main.func1·f(SB), CX 0x0047 00071 (go-closure.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-closure.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-closure.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-closure.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-closure.go:13) INCQ (AX) 0x005e 00094 (go-closure.go:13) CMPQ (AX), $5 0x0062 00098 (go-closure.go:13) JLT 57
Der Code ist Beispiel 2 sehr ähnlich: Beachten Sie, dass i durch eine Adresse im AX-Register dargestellt wird. Das heißt, wir übergeben i als Referenz. Und das trotz der Tatsache, dass Foobyval genannt wird. Der Hauptteil der Schleife ruft die Funktion mit runtime.newproc auf, aber woher kommt diese Funktion?
Func1 wird vom Compiler erstellt und ist ein Abschluss. Der Compiler hat den Abschlusscode als separate Funktion zugewiesen und ruft ihn von main auf. Das Hauptproblem bei dieser Zuordnung ist der Umgang mit Variablen, die von Closures verwendet werden, aber eindeutig keine Argumente sind.
So sieht der Körper von func1 aus:
0x0000 00000 (go-closure.go:14) MOVQ (TLS), CX 0x0009 00009 (go-closure.go:14) CMPQ SP, 16(CX) 0x000d 00013 (go-closure.go:14) JLS 56 0x000f 00015 (go-closure.go:14) SUBQ $16, SP 0x0013 00019 (go-closure.go:14) MOVQ BP, 8(SP) 0x0018 00024 (go-closure.go:14) LEAQ 8(SP), BP 0x001d 00029 (go-closure.go:15) MOVQ "".&i+24(SP), AX 0x0022 00034 (go-closure.go:15) MOVQ (AX), AX 0x0025 00037 (go-closure.go:15) MOVQ AX, (SP) 0x0029 00041 (go-closure.go:15) CALL "".foobyval(SB) 0x002e 00046 (go-closure.go:16) MOVQ 8(SP), BP 0x0033 00051 (go-closure.go:16) ADDQ $16, SP 0x0037 00055 (go-closure.go:16) RET
Es ist hier interessant, dass die Funktion ein Argument in 24 (SP) hat, das ein Zeiger auf int ist: Schauen Sie sich die Zeile MOVQ (AX), AX an, die einen Wert annimmt, bevor Sie ihn an foobyval übergeben. Tatsächlich sieht func1 ungefähr so aus:
func func1(i *int) { foobyval(*i) } main - : for i := 0; i < 5; i++ { go func1(&i) }
Erhielt das Äquivalent von Beispiel 2, und dies erklärt die Schlussfolgerung. In der Fachsprache würden wir sagen, dass i eine freie Variable innerhalb eines Abschlusses ist und solche Variablen durch Referenz in Go erfasst werden.
Aber ist das immer so? Überraschenderweise lautet die Antwort nein. In einigen Fällen werden freie Variablen nach Wert erfasst. Hier ist eine Variation unseres Beispiels:
for i := 0; i < 5; i++ { ii := i go func() { foobyval(ii) }() }
In diesem Beispiel werden 0, 1, 2, 3, 4 in zufälliger Reihenfolge ausgegeben. Aber warum unterscheidet sich das Verhalten hier von Beispiel 5?
Es stellt sich heraus, dass dieses Verhalten ein Artefakt der Heuristik ist, die der Go-Compiler verwendet, wenn er mit Closures arbeitet.
Wir schauen unter die Haube
Wenn Sie mit der Architektur des Go-Compilers nicht vertraut sind, empfehlen wir Ihnen, meine frühen Artikel zu diesem Thema zu lesen: Teil 1 , Teil 2 .
Der spezifische (im Gegensatz zum abstrakten) Syntaxbaum, der durch Parsen des Codes erhalten wird, sieht folgendermaßen aus:
0: *syntax.CallStmt { . Tok: go . Call: *syntax.CallExpr { . . Fun: *syntax.FuncLit { . . . Type: *syntax.FuncType { . . . . ParamList: nil . . . . ResultList: nil . . . } . . . Body: *syntax.BlockStmt { . . . . List: []syntax.Stmt (1 entries) { . . . . . 0: *syntax.ExprStmt { . . . . . . X: *syntax.CallExpr { . . . . . . . Fun: foobyval @ go-closure.go:15:4 . . . . . . . ArgList: []syntax.Expr (1 entries) { . . . . . . . . 0: i @ go-closure.go:15:13 . . . . . . . } . . . . . . . HasDots: false . . . . . . } . . . . . } . . . . } . . . . Rbrace: syntax.Pos {} . . . } . . } . . ArgList: nil . . HasDots: false . } }
Die aufgerufene Funktion wird durch den FuncLit-Knoten dargestellt, eine konstante Funktion. Wenn dieser Baum in AST (abstrakter Syntaxbaum) konvertiert wird, wird diese Konstantenfunktion als separate hervorgehoben. Dies geschieht in der Methode noder.funcLit, die sich in gc / Closure.go befindet.
Dann schließt der Tipe Checker die Transformation ab und wir erhalten die folgende Darstellung für die Funktion im AST:
main.func1: . DCLFUNC l(14) tc(1) FUNC-func() . DCLFUNC-body . . CALLFUNC l(15) tc(1) . . . NAME-main.foobyval a(true) l(8) x(0) class(PFUNC) tc(1) used FUNC-func(int) . . CALLFUNC-list . . . NAME-main.il(15) x(0) class(PAUTOHEAP) tc(1) used int
Beachten Sie, dass der an foobyval übergebene Wert NAME-main.i ist, dh wir verweisen explizit auf die Variable aus der Funktion, die den Abschluss umschließt.
In dieser Phase wird die Compiler-Phase, Capturevars genannt, dh "Capturing-Variablen", in Betrieb genommen. Der Zweck besteht darin, zu entscheiden, wie "geschlossene Variablen" (dh freie Variablen, die in Schließungen verwendet werden) erfasst werden sollen. Hier ist ein Kommentar der entsprechenden Compilerfunktion, der auch die Heuristik beschreibt:
// capturevars wird nach allen Typprüfungen in einer separaten Phase aufgerufen.
// Es wird entschieden, ob die Variable nach Wert oder nach Referenz erfasst werden soll.
// Wir verwenden die Erfassung nach Wert für Werte <= 128 Bytes, deren Wert nach der Erfassung nicht mehr geändert wird (im Wesentlichen Konstanten).
Wenn in Beispiel 5 capturevars aufgerufen wird, entscheidet es, dass die Schleifenvariable i als Referenz erfasst werden soll, und fügt ihr das entsprechende addrtaken-Flag hinzu. Dies ist in der AST-Ausgabe zu sehen:
FOR l(13) tc(1) . LT l(13) tc(1) bool . . NAME-main.ia(true) g(1) l(13) x(0) class(PAUTOHEAP) esc(h) tc(1) addrtaken assigned used int
Für die Schleifenvariable funktioniert die Auswahlheuristik "Nach Wert" nicht, da die Variable ihren Wert nach dem Aufruf ändert (beachten Sie das Zitat aus der Spezifikation, dass die Schleifenvariable bei jeder Iteration wiederverwendet wird). Daher wird die Variable i als Referenz erfasst.
In dieser Variation unseres Beispiels, in der wir ii: = i haben, wird ii nicht mehr verwendet und wird daher durch den Wert erfasst [5].
Wir sehen also ein beeindruckendes Beispiel dafür, wie zwei verschiedene Merkmale einer Sprache auf unerwartete Weise überlappt werden. Anstatt bei jeder Iteration der Schleife eine neue Variable zu verwenden, verwendet Go dieselbe wieder. Dies führt wiederum zur Auslösung von Heuristiken und zur Auswahl der Erfassung als Referenz, und dies führt zu einem unerwarteten Ergebnis. In den Go-FAQ heißt es, dass dieses Verhalten möglicherweise ein Entwurfsfehler ist.
Dieses Verhalten (keine neue Variable verwenden) ist wahrscheinlich ein Fehler beim Entwerfen einer Sprache. Vielleicht werden wir es in zukünftigen Versionen beheben, aber aufgrund der Abwärtskompatibilität können wir in Go Version 1 nichts tun.
Wenn Sie sich des Problems bewusst sind, werden Sie höchstwahrscheinlich nicht auf diesen Rechen treten. Beachten Sie jedoch, dass freie Variablen immer als Referenz erfasst werden können. Um Fehler zu vermeiden, stellen Sie sicher, dass bei Verwendung von goroutin nur schreibgeschützte Variablen erfasst werden. Dies ist auch aufgrund möglicher Probleme mit Datenflügen wichtig.
[1] Einige Leser haben festgestellt, dass es in Go streng genommen kein Konzept für das „Übergeben als Referenz“ gibt, da alles als Wert übergeben wird, einschließlich Zeigern. Wenn Sie in diesem Artikel "Referenzübergabe" sehen, meine ich "Adresse übergeben" und es ist in einigen Fällen explizit (z. B. Übergabe von & n an eine Funktion, die * int erwartet) und in einigen Fällen implizit, wie in späteren Fällen Teile des Artikels.
[2] Im Folgenden verwende ich die Zeit. Schlafen ist eine schnelle und schmutzige Methode, um auf den Abschluss aller Goroutinen zu warten. Ohne dies endet main, bevor die Goroutinen zu arbeiten beginnen. Der richtige Weg, dies zu tun, wäre, etwas wie WaitGroup oder done channel zu verwenden.
[3] Die Assembler-Darstellung für alle Beispiele in diesem Artikel wurde mit dem Befehl go tool compile -l -S erhalten. Das Flag -l deaktiviert das Inlining von Funktionen und macht Assembler-Code besser lesbar.
[4] Foobyval wird nicht direkt aufgerufen, da der Anruf durch go geht. Stattdessen wird die Adresse als zweites Argument (16 (SP)) an die Funktion runtime.newproc übergeben, und das Argument an foobyval (in diesem Fall i) geht den Stapel hoch.
[5] Addiere als Übung ii = 10 als letzte Zeile der for-Schleife (nach dem Aufruf von go). Was war Ihre Schlussfolgerung? Warum?