Sprachmechanik entgeht der Analyse

Auftakt


Dies ist der zweite von vier Artikeln in einer Reihe, die Einblicke in die Mechanik und das Design von Zeigern, Stapeln, Haufen, Escape-Analysen und der Go / Value-Semantik bieten. In diesem Beitrag geht es um Haufen- und Fluchtanalyse.

Inhaltsverzeichnis:

  1. Sprachmechanik auf Stapeln und Zeigern ( Übersetzung )
  2. Sprachmechanik zur Fluchtanalyse
  3. Sprachmechanik zur Speicherprofilerstellung
  4. Designphilosophie zu Daten und Semantik

Einführung


Im ersten Beitrag dieser Reihe habe ich anhand eines Beispiels, in dem der Wert über den Stapel zwischen Goroutinen verteilt ist, über die Grundlagen der Zeigermechanik gesprochen. Ich habe Ihnen nicht gezeigt, was passiert, wenn Sie den Wert auf dem Stapel teilen. Um dies zu verstehen, müssen Sie sich über einen anderen Speicherbereich informieren, in dem sich die Werte befinden können: über den „Heap“. Mit diesem Wissen können Sie beginnen, „Fluchtanalyse“ zu studieren.
Die Escape-Analyse ist ein Prozess, mit dem der Compiler die Platzierung der von Ihrem Programm erstellten Werte ermittelt. Insbesondere führt der Compiler eine statische Code-Analyse durch, um festzustellen, ob der Wert für die Funktion, die ihn erstellt, auf dem Stapelrahmen platziert werden kann oder ob der Wert in den Heap "maskiert" werden soll. In Go gibt es kein einziges Schlüsselwort oder keine einzige Funktion, mit der Sie dem Compiler mitteilen können, welche Entscheidung er treffen soll. Nur wenn Sie Ihren Code bedingt schreiben, können Sie diese Entscheidung beeinflussen.

Haufen


Ein Heap ist neben dem Stapel ein zweiter Speicherbereich, in dem Werte gespeichert werden. Der Heap ist nicht wie Stapel selbstreinigend, daher ist die Verwendung dieses Speichers teurer. Zuallererst sind die Kosten mit dem Garbage Collector (GC) verbunden, der diesen Bereich sauber halten soll. Wenn der GC gestartet wird, verbraucht er 25% der verfügbaren Leistung Ihres Prozessors. Darüber hinaus kann es möglicherweise zu Mikrosekunden Verzögerungen kommen, die die Welt stoppen. Der Vorteil eines GC besteht darin, dass Sie sich nicht um die Verwaltung des Heap-Speichers kümmern müssen, der in der Vergangenheit komplex und fehleranfällig war.

Werte im Heap provozieren Speicherzuordnungen in Go. Diese Zuordnungen üben Druck auf den GC aus, da jeder Wert im Heap, auf den sich der Zeiger nicht mehr bezieht, gelöscht werden muss. Je mehr Werte Sie überprüfen und löschen müssen, desto mehr Arbeit muss der GC bei jedem Start leisten. Daher arbeitet der Stimulationsalgorithmus ständig daran, die Größe des Heapspeichers und die Ausführungsgeschwindigkeit in Einklang zu bringen.

Stack-Sharing


In Go dürfen keine Goroutinen einen Zeiger haben, der auf einen Speicher auf dem Stapel einer anderen Goroutine zeigt. Dies liegt an der Tatsache, dass der Stapelspeicher für Goroutinen durch einen neuen Speicherblock ersetzt werden kann, wenn der Stapel vergrößert oder verkleinert werden soll. Wenn Sie zur Laufzeit die Stapelzeiger in einer anderen Goroutine verfolgen müssten, müssten Sie zu viel verwalten, und die Verzögerung beim Stoppen der Zeiger auf diese Stapel wäre erschütternd.

Hier ist ein Beispiel für einen Stapel, der aufgrund von Wachstum mehrmals ersetzt wird. Sehen Sie sich die Ausgabe in den Zeilen 2 und 6 an. Sie sehen zweimal die Adressänderungen des Zeichenfolgenwerts im Hauptstapelrahmen.

play.golang.org/p/pxn5u4EBSI

Fluchtmechanik


Jedes Mal, wenn ein Wert außerhalb des Bereichs des Stapelrahmens einer Funktion geteilt wird, wird er in einem Heap platziert (oder zugewiesen). Die Aufgabe von Escape-Analyse-Algorithmen besteht darin, solche Situationen zu finden und das Integritätsniveau im Programm aufrechtzuerhalten. Integrität soll sicherstellen, dass der Zugriff auf jeden Wert immer genau, konsistent und effizient ist.

Schauen Sie sich dieses Beispiel an, um die grundlegenden Mechanismen der Fluchtanalyse zu lernen.

play.golang.org/p/Y_VZxYteKO

Listing 1

01 package main
02
03 type user struct {
04     name  string
05     email string
06 }
07
08 func main() {
09     u1 := createUserV1()
10     u2 := createUserV2()
11
12     println("u1", &u1, "u2", &u2)
13 }
14
15 //go:noinline
16 func createUserV1() user {
17     u := user{
18         name:  "Bill",
19         email: "bill@ardanlabs.com",
20     }
21
22     println("V1", &u)
23     return u
24 }
25
26 //go:noinline
27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

Ich verwende die Anweisung go: noinline, damit der Compiler keinen Code für diese Funktionen direkt in main einbettet. Durch das Einbetten werden Funktionsaufrufe entfernt und dieses Beispiel kompliziert. Ich werde im nächsten Beitrag über die Nebenwirkungen der Einbettung sprechen.

Listing 1 zeigt ein Programm mit zwei verschiedenen Funktionen, die einen Wert vom Typ user erstellen und an den Aufrufer zurückgeben. Die erste Version der Funktion verwendet bei der Rückgabe die Semantik des Werts.

Listing 2:

16 func createUserV1() user {
17     u := user{
18         name:  "Bill",
19         email: "bill@ardanlabs.com",
20     }
21
22     println("V1", &u)
23     return u
24 }

Ich sagte, dass die Funktion bei der Rückgabe die Semantik von Werten verwendet, da ein von dieser Funktion erstellter Wert vom Typ Benutzer kopiert und an den Aufrufstapel übergeben wird. Dies bedeutet, dass die aufrufende Funktion eine Kopie des Werts selbst erhält.

Sie können die Erstellung eines Werts vom Typ Benutzer sehen, der in den Zeilen 17 bis 20 ausgeführt wird. In Zeile 23 wird dann eine Kopie des Werts an den Aufrufstapel übergeben und an den Aufrufer zurückgegeben. Nach dem Zurückgeben der Funktion sieht der Stapel wie folgt aus.

Bild 1



In Abbildung 1 sehen Sie, dass nach dem Aufruf von createUserV1 in beiden Frames ein Wert vom Typ user vorhanden ist. In der zweiten Version der Funktion wird die Zeigersemantik verwendet, um zurückzukehren.

Listing 3:

27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

Ich sagte, dass eine Funktion bei der Rückgabe eine Zeigersemantik verwendet, da ein Wert vom Typ Benutzer, der von dieser Funktion erstellt wurde, vom Aufrufstapel gemeinsam genutzt wird. Dies bedeutet, dass die aufrufende Funktion eine Kopie der Adresse erhält, an der sich die Werte befinden.

Sie können dasselbe Strukturliteral sehen, das in den Zeilen 28 bis 31 verwendet wird, um einen Wert vom Typ user zu erstellen, aber in Zeile 34 ist die Rückgabe von der Funktion unterschiedlich. Anstatt eine Kopie des Werts an den Aufrufstapel zurückzugeben, wird eine Kopie der Adresse für den Wert übergeben. Auf dieser Grundlage könnten Sie denken, dass der Stapel nach dem Aufruf so aussieht.

Bild 2



Wenn das, was Sie in Abbildung 2 sehen, wirklich passiert, liegt ein Integritätsproblem vor. Ein Zeiger zeigt nach unten auf einen Stapel von Speicheraufrufen, der nicht mehr gültig ist. Beim nächsten Aufruf der Funktion wird der angegebene Speicher neu formatiert und neu initialisiert.

Hier beginnt die Escape-Analyse, die Integrität aufrechtzuerhalten. In diesem Fall stellt der Compiler fest, dass es nicht sicher ist, einen Wert vom Typ user innerhalb des Stapelrahmens createUserV2 zu erstellen. Stattdessen wird ein Wert auf dem Heap erstellt. Dies geschieht sofort während des Baus der Linie 28.

Lesbarkeit


Wie Sie aus einem früheren Beitrag erfahren haben, hat eine Funktion über den Frame-Zeiger direkten Zugriff auf den Speicher innerhalb ihres Frames. Für den Zugriff auf den Speicher außerhalb des Frames ist jedoch ein indirekter Zugriff erforderlich. Dies bedeutet, dass der Zugriff auf Werte, die in den Heap fallen, auch indirekt über einen Zeiger erfolgen muss.

Denken Sie daran, wie der createUserV2-Code aussieht.

Listing 4:

27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

Die Syntax verbirgt, was in diesem Code wirklich passiert. Die in Zeile 28 deklarierte Variable u repräsentiert einen Wert vom Typ user. Die Konstruktion in Go sagt Ihnen nicht genau, wo der Wert im Speicher gespeichert ist. Vor der return-Anweisung in Zeile 34 wissen Sie also nicht, dass der Wert gehäuft wird. Dies bedeutet, dass u zwar einen Wert vom Typ user darstellt, der Zugriff auf diesen Wert jedoch über einen Zeiger erfolgen muss.

Sie können einen Stapel visualisieren, der nach einem Funktionsaufruf so aussieht.

Bild 3



Die Variable u im Stapelrahmen für createUserV2 repräsentiert den Wert auf dem Heap und nicht auf dem Stapel. Dies bedeutet, dass für die Verwendung von u für den Zugriff auf einen Wert der Zugriff auf einen Zeiger erforderlich ist, nicht auf den von der Syntax vorgeschlagenen direkten Zugriff. Sie könnten denken, warum nicht sofort einen Zeiger erstellen, da für den Zugriff auf den Wert, den er darstellt, immer noch ein Zeiger verwendet werden muss?

Listing 5:

27 func createUserV2() *user {
28     u := &user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", u)
34     return u
35 }

Wenn Sie dies tun, verlieren Sie die Lesbarkeit, die Sie in Ihrem Code nicht verlieren könnten. Bewegen Sie sich für eine Sekunde vom Funktionskörper weg und konzentrieren Sie sich nur auf die Rückkehr.

Listing 6:

34     return u
35 }

Worüber spricht diese Rückkehr? Er sagt nur, dass eine Kopie von u auf den Aufrufstapel geschoben wird. Was sagt Ihnen die Rückkehr in der Zwischenzeit, wenn Sie den Operator & verwenden?

Listing 7:

34     return &u
35 }

Dank des Operators & return erfahren Sie jetzt, dass Sie den Aufrufstapel gemeinsam nutzen und daher in den Heap gehen. Denken Sie daran, dass Zeiger zusammen verwendet werden sollen und beim Lesen des Codes den Operator & durch den Ausdruck "Freigabe" ersetzen. Es ist sehr leistungsfähig in Bezug auf die Lesbarkeit. Das möchte ich nicht verlieren.

Hier ist ein weiteres Beispiel, bei dem das Erstellen von Werten mithilfe der Zeigersemantik die Lesbarkeit beeinträchtigt.

Listing 8:

01 var u *user
02 err := json.Unmarshal([]byte(r), &u)
03 return u, err

Damit dieser Code funktioniert, müssen Sie beim Aufrufen von json.Unmarshal in Zeile 02 einen Zeiger auf eine Zeigervariable übergeben. Ein Aufruf von json.Unmarshal erstellt einen Wert vom Typ user und weist seine Adresse einer Zeigervariablen zu. play.golang.org/p/koI8EjpeIx

Was dieser Code sagt:
01: Erstellen Sie einen Zeiger vom Typ Benutzer mit einem Nullwert.
02: Teilen Sie die Variable u mit der Funktion json.Unmarshal.
03: Eine Kopie der Variablen u an den Aufrufer zurückgeben.

Es ist nicht ganz offensichtlich, dass ein Wert vom Typ user, der von der Funktion json.Unmarshal erstellt wurde, an den Aufrufer übergeben wird.

Wie ändert sich die Lesbarkeit, wenn die Semantik von Werten während der Variablendeklaration verwendet wird?

Listing 9:

01 var u user
02 err := json.Unmarshal([]byte(r), &u)
03 return &u, err

Was dieser Code sagt:
01: Erstellen Sie einen Wert vom Typ Benutzer mit einem Nullwert.
02: Teilen Sie die Variable u mit der Funktion json.Unmarshal.
03: Teilen Sie die Variable u mit dem Aufrufer.

Alles ist sehr klar. Zeile 02 teilt den Wert des Typbenutzers im Aufrufstapel in json.Unmarshal auf, und Zeile 03 teilt den Wert des Aufrufstapels an den Anrufer zurück. Diese Freigabe bewirkt, dass der Wert auf den Heap verschoben wird.

Verwenden Sie beim Erstellen von Werten die Semantik von Werten und nutzen Sie die Lesbarkeit des Operators &, um zu verdeutlichen, wie Werte getrennt werden.

Compiler-Berichterstellung


Um die vom Compiler getroffenen Entscheidungen anzuzeigen, können Sie den Compiler bitten, einen Bericht bereitzustellen. Alles, was Sie tun müssen, ist, den Schalter -gcflags mit der Option -m zu verwenden, wenn Sie go build aufrufen.

Tatsächlich können Sie 4 Ebenen von -m verwenden, aber nach 2 Informationsebenen wird es zu viel. Ich werde 2 Ebenen -m verwenden.

Listing 10:

$ go build -gcflags "-m -m"
./main.go:16: cannot inline createUserV1: marked go:noinline
./main.go:27: cannot inline createUserV2: marked go:noinline
./main.go:8: cannot inline main: non-leaf function
./main.go:22: createUserV1 &u does not escape
./main.go:34: &u escapes to heap
./main.go:34:     from ~r0 (return) at ./main.go:34
./main.go:31: moved to heap: u
./main.go:33: createUserV2 &u does not escape
./main.go:12: main &u1 does not escape
./main.go:12: main &u2 does not escape

Sie können sehen, dass der Compiler Entscheidungen meldet, um den Wert in den Heap zu sichern. Was sagt der Compiler? Schauen Sie sich zunächst die Funktionen createUserV1 und createUserV2 noch einmal an, um sie im Speicher zu aktualisieren.

Listing 13:

16 func createUserV1() user {
17     u := user{
18         name:  "Bill",
19         email: "bill@ardanlabs.com",
20     }
21
22     println("V1", &u)
23     return u
24 }

27 func createUserV2() *user {
28     u := user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", &u)
34     return &u
35 }

Beginnen wir mit dieser Zeile im Bericht.

Listing 14:

./main.go:22: createUserV1 &u does not escape

Dies deutet darauf hin, dass der Aufruf der Funktion println innerhalb der Funktion createUserV1 nicht dazu führt, dass der Benutzertyp in den Heap kopiert wird. Dieser Fall musste überprüft werden, da er in Verbindung mit der Funktion println verwendet wird.

Schauen Sie sich als Nächstes diese Zeilen im Bericht an.

Listing 15:

./main.go:34: &u escapes to heap
./main.go:34:     from ~r0 (return) at ./main.go:34
./main.go:31: moved to heap: u
./main.go:33: createUserV2 &u does not escape

Diese Zeilen besagen, dass der Wert des Benutzertyps, der der Variablen u zugeordnet ist, die den benannten Benutzertyp hat und in Zeile 31 erstellt wird, aufgrund der Rückgabe in Zeile 34 in den Heap ausgegeben wird. Die letzte Zeile sagt dasselbe wie zuvor, println call in der Zeile 33 setzt den Benutzertyp nicht zurück.

Das Lesen dieser Berichte kann verwirrend sein und leicht variieren, je nachdem, ob der Typ der betreffenden Variablen auf einem benannten oder einem wörtlichen Typ basiert.

Ändern Sie die Variable u so, dass sie wie zuvor der Literal-Typ * user anstelle des benannten Typbenutzers ist.

Listing 16:

27 func createUserV2() *user {
28     u := &user{
29         name:  "Bill",
30         email: "bill@ardanlabs.com",
31     }
32
33     println("V2", u)
34     return u
35 }

Führen Sie den Bericht erneut aus.

Listing 17:

./main.go:30: &user literal escapes to heap
./main.go:30:     from u (assigned) at ./main.go:28
./main.go:30:     from ~r0 (return) at ./main.go:34

Der Bericht besagt nun, dass der Wert des Benutzertyps, auf den die Variable u verweist, die den Literaltyp * user hat und in Zeile 28 erstellt wurde, aufgrund der Rückgabe in Zeile 34 auf dem Heap gespeichert wird.

Fazit


Das Erstellen eines Werts bestimmt nicht, wo er sich befindet. Nur wie der Wert aufgeteilt wird, bestimmt, was der Compiler mit diesem Wert macht. Jedes Mal, wenn Sie einen Wert im Aufrufstapel freigeben, wird er auf dem Heap gespeichert. Es gibt andere Gründe, warum ein Wert aus dem Stapel entkommen kann. Ich werde im nächsten Beitrag darüber sprechen.

Der Zweck dieser Beiträge besteht darin, Anleitungen zur Auswahl der Wertesemantik oder Zeigersemantik für einen bestimmten Typ bereitzustellen. Jede Semantik ist mit Gewinn und Wert verbunden. Die Semantik der Werte speichert die Werte auf dem Stapel, wodurch die Belastung des GC verringert wird. Es gibt jedoch verschiedene Kopien desselben Werts, die gespeichert, verfolgt und verwaltet werden müssen. Die Zeigersemantik legt Werte auf einen Haufen, was Druck auf den GC ausüben kann. Sie sind jedoch effektiv, da nur ein Wert gespeichert, verfolgt und verwaltet werden muss. Der entscheidende Punkt ist die korrekte, konsistente und ausgewogene Verwendung jeder Semantik.

All Articles