Go Home Page
Die Programmiersprache Go

The Go Memory Model — Deutsche Übersetzung

Das Original:
https://golang.org/ref/mem
Version of December 21, 2018 (go1.12)
Diese Übersetzung:
https://bitloeffel.de/DOC/golang/go_mem_20190226_de.html
Stand: 12.01.2019
© 2016-19 Hans-Werner Heinzen @ Bitloeffel.de
Die Nutzung dieses Werks ist unter den Bedingungen der "Creative Commons Attribution 3.0"-Lizenz erlaubt.
Für Fachbegriffe und ähnliches gibt es hier noch eine Wörterliste.

Das Go-Speicherkonzept

Einleitung

Das Go-Speicherkonzept legt die Bedingungen fest, unter denen garantiert wird, dass das Lesen einer Variable durch eine Goroutine den Wert, der von einer anderen Goroutine in dieselbe Variable geschrieben wurde, auch sehen kann.

Ein guter Rat

Programme, welche Daten ändern, auf die gleichzeitig von mehreren Goroutinen zugegriffen wird, müssen diese Zugriffe serialisieren.

Um das zu erreichen, schützen Sie die Daten mithilfe von Kanaloperationen oder anderen Synchronisitionsprimitiven der Art, wie sie in den Paketen sync und sync/atomic enthalten sind.

Wenn Sie den Rest dieses Dokuments lesen müssen, um das Verhalten Ihres Programms zu verstehen, dann programmieren Sie zu schlau.

Seien Sie nicht schlau.

Geschieht vor

Innerhalb einer Goroutine müssen sich Lese- und Schreibvorgänge so verhalten, als ob sie in der Reihenfolge ausgeführt würden, die durch den Programmkode vorgegeben ist. Das heißt: Compiler oder Prozessoren dürfen Lese- und Schreibvorgänge innerhalb einer Goroutine nur dann umordnen, wenn dadurch ihr durch die Sprachbeschreibung definiertes Verhalten innerhalb dieser Goroutine nicht verändert wird. Als Folge einer solchen Umodnung kann die von einer Goroutine beobachtete Reihenfolge sich von der beobachteten Reihenfolge durch eine andere Goroutine unterscheiden. Wenn beispielsweise eine Goroutine a = 1; b = 2; ausführt, so "sieht" vielleicht eine andere Goroutine den aktualisierten Wert von b vor dem aktualisierten Wert von a.

Um die Anforderungen an Lese- und Schreibvorgänge spezifizieren zu können, definieren wir geschieht vor als eine Teilordnung für die Ausführung von Speicheroperationen in einem Go-Programm. Wenn ein Ereignis E1 vor einem Ereignis E2 geschieht, dann sagen wir, dass E2 nach E1 geschieht. Außerdem sagen wir, wenn E1 nicht vor E2 und auch nicht nach E2 geschieht, dass E1 und E2 nebenläufig geschehen.

Innerhalb einer Goroutine ist die Geschieht-vor-Ordnung die Ordnung, die vom Programm beschrieben wird.

Das Lesen R einer Variablen v darf das Schreiben W von v dann sehen, wenn gilt:

  1. R geschieht nicht vor W.
  2. Es existiert kein anderes Schreiben W' nach v, welches nach W aber vor R geschieht.

Um zu garantieren, dass das Lesen R einer Variablen v ein ganz bestimmtes Schreiben W nach v auch wirklich sieht, stellen Sie sicher, dass W das einzige Schreiben nach v ist, das R sehen darf. Das heißt: R kann dann W garantiert sehen, wenn gilt:

  1. W geschieht vor R.
  2. Jedes andere Schreiben zur gemeinsamen Variablen v geschieht entweder vor W oder nach R.

Diese beiden Bedingungen sind strenger als die beiden oberen; sie verlangen, dass kein weiteres Schreiben nebenläufig zu W oder R geschieht.

Innerhalb einer Goroutine gibt es keine Nebenläufigkeit; also sind dort die beiden Definitionen äquivalent zu: ein Lesen R sieht den Wert, der vom letzten Schreiben W nach v stammt. Wenn mehrere Goroutinen auf eine gemeinsame Variable v zugreifen, so müssen sie Synchronisationsereignisse benutzen, um diejenigen Geschieht-vor-Bedingungen zu erfüllen, die sicherstellen, dass Leseoperationen die gewünschten Schreiboperationen sehen.

Das Initialisieren der Variablen v mit dem Nullwert des Typs von v verhält sich wie eine Schreiboperation.

Lesen und Schreiben von Werten größer als ein Maschinenwort verhalten sich wie mehrere maschinenwortlange Operationen in undefinierter Reihenfolge.

Synchronisation

Initialisierung

Die Programminitialisierung läuft in einer einzelnen Goroutine, aber diese Goroutine kann andere Goroutinen erzeugen, die dann nebenläufig sind.

Wenn ein Paket p ein Paket q importiert, so geschieht das Abarbeiten aller init-Funktionen von q vor dem Start der init-Funktionen von p.

Der Start der Funktion main.main geschieht, nachdem alle init-Funktionen geendet haben.

Erzeugen von Goroutinen

Die go-Anweisung, welche eine neue Goroutine erzeugt, geschieht, bevor die Ausführung dieser Goroutine beginnt.

Zum Beispiel wird in diesem Programm:

var a string

func f() {
    print(a)
}

func hello() {
    a = "Hallo Welt"
    go f()
}
		

ein Aufruf von hello irgendwann in Zukunft "Hallo Welt" drucken — vielleicht erst nachdem hello geendet hat.

Vernichten von Goroutinen

Es wird nicht garantiert, dass das Ende einer Goroutine vor irgendeinem anderen Ereignis im Programm geschieht. In diesem Programm zum Beispiel:

var a string

func hello() {
    go func() { a = "Hallo" }()
    print(a)
}
		

folgt auf die Zuweisung zu a kein Synchronisationsereignis; also ist nicht garantiert, dass irgendeine andere Goroutine sie sieht. Es ist sogar möglich, dass ein aggressiver Compiler die gesamte go-Anweisung verwirft.

Wenn die Wirkungen einer Goroutine für eine andere sichtbar sein müssen, dann nutzen Sie einen Synchronisationsmechanismus, etwa eine Sperre oder den Nachrichtenaustausch über eine Kanal, um eine relative Ordnung zu etablieren.

Nachrichtenaustausch über Kanäle

Der Nachrichtenaustausch über Kanäle ist die wichtigste Methode, um Goroutinen zu synchronisieren. Zu jedem Senden in einen bestimmten Kanal gehört ein Empfangen aus diesem Kanal; gewöhnlich geschieht das in einer anderen Goroutine.

Ein Senden in einen Kanal geschieht, bevor das zugehörige Empfangen aus diesem Kanal endet.

Es wird garantiert, dass dieses Programm:

var c = make(chan int, 10)
var a string

func f() {
    a = "Hallo Welt"
    c <- 0
}

func main() {
    go f()
    <-c
    print(a)
}
		

"Hallo Welt" druckt. Das Schreiben nach a geschieht vor dem Senden nach c, was geschieht, bevor das zugehörige Empfangen aus c endet, was wiederum vor dem print geschieht.

Das Schließen eines Kanals geschieht vor einem Empfangen, das einen Nullwert zurückgibt, weil der Kanal geschlossen ist.

Wenn man im vorigen Beispiel c <- 0 durch close(c) ersetzt, erhält man ein Programm mit dem gleichen garantierten Verhalten.

Das Empfangen aus einem ungepufferten Kanal geschieht, bevor das Senden in diesen Kanal endet.

Auch für das folgende Programm — ähnlich dem obigen, nur dass Senden und Empfangen vertauscht sind und der benutzte Kanal ein ungepufferter ist:

var c = make(chan int)
var a string

func f() {
    a = "Hallo Welt"
    <-c
}

func main() {
    go f()
    c <- 0
    print(a)
}
		

ist garantiert, dass es "Hallo Welt" druckt. Das Schreiben geschieht vor dem Empfangen aus c, welches geschieht, bevor das zugehörige Senden nach c endet, welches wiederum vor dem print geschieht.

Wäre der Kanal ein gepufferter (z.B. c = make(chan int, 1)), dann wäre nicht garantiert, dass das Programm "Hallo Welt" druckte. (Es könnte einen leeren String drucken, es könnte abstürzen, es könnte irgendetwas tun.)

Das k-te Empfangen aus einem Kanal mit der Kapazität C geschieht bevor das k+C-te Senden in diesen Kanal endet.

Diese Regel verallgemeinert die vorige Regel zu ungepufferten Kanälen. Sie erlaubt, einen gepufferten Kanal als "mitzählende Ampel" zu nutzen: Die Anzahl der Objekte in einem Kanal ist dann die Anzahl der aktiven Nutzer, die Kapazität dieses Kanals ist die maximale Anzahl der gleichzeitigen Nutzer, das Senden eines Objekts belastet die Ampel und das Empfangen eines Objekts entlastet sie wieder. Dies ist eine übliche Methode, Nebenläufigkeit zu begrenzen.

Das folgende Programm startet eine Goroutine für jeden Eintrag in einer work-Liste, aber die Goroutinen stimmen sich über einen limit-Kanal ab, um sicherzustellen, dass maximal drei work-Funktionen gleichzeitig laufen.

var limit = make(chan int, 3)

func main() {
    for _, w := range work {
        go func(w func()) {
            limit <- 1
            w()
            <-limit
        }(w)
    }
    select{}
}
		

Sperren

Das Paket sync implementiert zwei Datentypen, die Sperren sind: sync.Mutex und sync.RWMutex.

Für jede Variable l vom Typ sync.Mutex oder sync.RWMutex, und n < m gilt: Der n-te Aufruf von l.Unlock() geschieht, bevor der m-te Aufruf von l.Lock() endet.

Für das Programm:

var l sync.Mutex
var a string

func f() {
    a = "Hallo Welt"
    l.Unlock()
}

func main() {
    l.Lock()
    go f()
    l.Lock()
    print(a)
}
		

ist garantiert, dass es "Hallo Welt" druckt. Der erste Aufruf von l.Unlock() (in f) geschieht, bevor der zweite Aufruf von l.Lock() (in main) endet, welcher vor dem print geschieht.

Für jeden Aufruf von l.RLock (zu einer Variable l vom Typ sync.RWMutex) gibt es ein n, so dass das l.RLock nach dem n-ten Aufruf l.Unlock und das zugehörige l.Unlock vor dem n+1-ten Aufruf von l.Lock geschieht.

Once

Das Paket sync bietet einen sicheren Mechanismus zum einmaligen Initialisieren im Beisein mehrerer Goroutinen, nämlich den Typ Once. Für ein gegebenes f können mehrere Verarbeitungsstränge (threads) once.Do(f) aufrufen, doch nur einer davon wird f ausführen während die anderen Aufrufe warten bis f geendet hat.

Ein einzelner Aufruf von f durch once.Do(f) geschieht, bevor ein beliebiger Aufruf von once.Do(f) endet.

In diesem Programm:

var a string
var once sync.Once

func setup() {
    a = "Hallo Welt"
}

func doprint() {
    once.Do(setup)
    print(a)
}

func twoprint() {
    go doprint()
    go doprint()
}
		

wird ein Aufruf von twoprint genau einmal setup aufrufen. Die Funktion setup wird vor den beiden Aufrufen von print abgeschlossen sein. Ergebnis wird sein, dass "Hallo Welt" zweimal gedruckt wird.

Falsche Synchronisation

Beachten Sie, dass ein Lesen R den durch ein konkurrierendes Schreiben W geschriebenen Wert sehen kann. Selbst wenn das vorkommt, bedeutet es nicht, dass ein Lesen, welches nach R geschieht, ein Schreiben sehen muss, welches vor W geschah.

In diesem Programm:

var a, b int

func f() {
    a = 1
    b = 2
}

func g() {
    print(b)
    print(a)
}

func main() {
    go f()
    g()
}
		

kann es vorkommen, dass g zuerst 2 und dann 0 druckt.

Diese Tatsache macht eine Reihe von üblichen Programmiermustern ungültig.

Sperren mit doppelter Prüfung ist ein Versuch, den Verwaltungsaufwand von Synchronisation zu vermeiden. Beispielsweise könnte man das twoprint-Programm fehlerhaft so schreiben:

var a string
var done bool

func setup() {
    a = "Hallo Welt"
    done = true
}

func doprint() {
    if !done {
        once.Do(setup)
    }
    print(a)
}

func twoprint() {
    go doprint()
    go doprint()
}
		

doch gibt es keine Garantie, dass, wenn doprint das Schreiben nach done sieht, es auch das Schreiben nach a sieht. Diese Programmversion kann (fehlerhaft) einen leeren String anstelle von "Hallo Welt" drucken.

Ein ebenfalls fehlerhaftes Programmiermuster wartet fleißig auf einen Wert:

var a string
var done bool

func setup() {
    a = "Hallo Welt"
    done = true
}

func main() {
    go setup()
    for !done {
    }
    print(a)
}
		

Wie schon vorher gibt es auch hier keine Garantie, dass, wenn main das Schreiben nach done sieht, es auch das Schreiben nach a sieht. Also kann auch dieses Programm einen leeren String drucken. Schlimmer noch: es gibt keine Garantie, dass main jemals das Schreiben nach done sieht, weil kein Synchronisationsereignis zwischen den beiden Verarbeitungssträngen liegt; Es gibt keine Garantie, dass die Schleife in main jemals endet.

Es gibt noch subtilere Variationen dieses Themas, wie zum Beispiel dieses Programm:

type T struct {
    msg string
}

var g *T

func setup() {
    t := new(T)
    t.msg = "Hallo Welt"
    g = t
}

func main() {
    go setup()
    for g == nil {
    }
    print(g.msg)
}

Selbst wenn main sieht, dass g != nil ist und die Schleife verlässt, gibt es keine Garantie, dass es auch den initialisierten Wert von g.msg sieht.

Für alle diese Beispiele ist die Lösung die gleiche: Synchronisieren Sie explizit!