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:
- R geschieht nicht vor W.
-
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:
- W geschieht vor R.
-
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!