Ein Einstieg in die Programmiersprache Go, Teil 1

Seite 2: Nebenläufigkeit in Go

Inhaltsverzeichnis

Go hat Nebenläufigkeit beziehungsweise Concurrency auf Sprachebene umgesetzt und sich an Hoares Communicating Sequential Processes orientiert. Die beiden Sprachkonstrukte zur Nutzung von Nebenläufigkeit sind Go-Routinen und Channels.

Go-Routinen sind Funktionen oder Methoden, die nebenläufig ausgeführt werden – sie blockieren also nicht die Ausführung des aktuellen Codes so lange, bis die Funktion ihre Berechnungen beendet hat. In Go können Entwickler jede Funktion oder Methode als Go-Routine ausführen, indem sie sie mit dem Schlüsselwort go aufrufen:

// Funktionen werden mit dem Schlüsselwort func deklariert
func print(s string) {
fmt.Println(s)
}

// Die Funktion main ist der Einstiegspunkt in jedes Go-Programm
func main() {
print("one")
// print("two") wird in einer Go-Routine ausgeführt
go print("two")
print("three")
time.Sleep(1 * time.Second)
}

Der Code erzeugt folgende Ausgabe:

one
three
two

da der zweite Aufruf als Go-Routine nicht blockiert. Der Aufruf von time.Sleep am Ende ist notwendig, weil sich das Programm sonst vorm Ausführen beenden würde – ein Go-Programm wartet vor dem Beenden nicht automatisch, bis alle Go-Routinen beendet sind.

Ruft man Funktionen oder Methoden mit Rückgabewerten als Go-Routinen auf, gibt es keine Möglichkeit, an die Rückgabewerte zu gelangen. Aus anderen Sprachen bekannten Konzepte wie Promises oder Futures gibt es in Go nicht.

Die Strategie in Go lautet: "Don't communicate by sharing memory; share memory by communicating". Es sollen nicht mehrere nebenläufige Prozesse mit derselben globalen Variablen arbeiten, deren Zugriff zum Beispiel Mutexe oder Semaphoren steuern, sondern Prozesse sollen untereinander durch sogenannte Channels kommunizieren. Channels dienen zum Austausch der Referenzen auf Daten.

Entwickler können Channels wie normale Variablen nutzen und als Funktionsparameter übergeben oder als Rückgabewert zurückliefern. Die einfachste Form eines Channels, der ungepufferte Channel, ist eine Warteschlange nach dem FIFO-Prinzip (First In, First Out). Dabei ist zu beachten, dass das Schreiben in einem ungepufferten Channel so lange blockiert, bis jemand von ihm liest. Umgekehrt blockiert das Lesen eines Channels so lange, bis jemand in den Channel schreibt.

func greeting(channel chan string) {
// schreibe "hello world" in den channel
channel <- "hello world"
}

func main() {
// erzeuge einen channel vom Typ string
channel := make(chan string)
go greeting(channel)

var text string = <-channel
fmt.Println(text)
}

Wie im Beispiel sichtbar, müssen Anwender Channels mit dem Schlüsselwort make(...) erstellen. Sie haben außerdem einen Typ, der hier string ist. Die Funktion greeting erhält einen Channel, in den sie einen String schreibt. Die Channel-Syntax kann man sich bildlich vorstellen, wenn man einen Channel wie eine Röhre darstellt:

Der Channel als Röhre (Abb. 1)

Möchte man in einen Channel schreiben, zeigt der Pfeil vom Wert auf die Channel-Variable. Möchte man hingegen aus dem Channel lesen, zeigt der Pfeil von der Channel-Variable zu der Variable, die den Wert speichern soll.

Da Lese- und Schreiboperationen blockieren, muss das Programm greeting(channel) zwingend als Go-Routine aufrufen, damit es nicht in eine Deadlock-Situation läuft. channel <- "hello world" würde bei einem synchronen Aufruf so lange blockieren, bis jemand vom Channel liest: Der Lesebefehl wäre nie zu erreichen. Channels verwenden Entwickler deshalb häufig in Kombination mit Go-Routinen.

Ein Channel kann mehrere Go-Routinen miteinander verbinden:

func producer(text chan string) {
text <- "one"
text <- "two"
text <- "three"
close(text)
}

func print(text chan string, done chan bool) {
var s string
// eine for-Schleife in Go braucht keine Klammern um den
// Schleifenkopf mit Initialisierung, Test und Fortsetzung
for ok := true; ok; s, ok = <-text {
fmt.Println(s)
}

done <- true
}

func main() {
channel := make(chan string)
done := make(chan bool)

go producer(channel)
go print(channel, done)

<-done
}

Das Beispiel übergibt beiden Go-Routinen denselben Channel, über den sie kommunizieren können. Außerdem schließt der Producer den Channel am Ende, was die Go-Routine print durch s, ok = <- text abfragen kann: Wenn der zweite Wert (ok) false zurückliefert, ist der Channel geschlossen. Die main-Methode blockiert so lange, bis man in den done-Channel schreibt – er dient nur zur Synchronisation. Der Wert im Channel interessiert nicht und ist keiner Variable zugeordnet.

Channels sind sicher für die parallele Verwendung. Es dürfen mehrere Go-Routinen in denselben Channel schreiben, ohne dass Daten verloren gehen.

func producer(text chan string, s string) {
for i := 0; i < 10; i++ {
text <- s
}
}

func main() {
channel := make(chan string)

go producer(channel, "-")
go producer(channel, "|")

for i := 0; i < 20; i++ {
fmt.Print(<-channel)
}
}

Das gezeigte Programm erzeugt Ausgaben wie "||-----|||-----|||||" oder "||-||||||||---------". Es gehen keine Zeichen verloren, aber die Reihenfolge ist zufällig, je nachdem welche Go-Routine zuerst einen Wert in den Channel schreiben kann.

Mit dem select-Statement ist es möglich, auf die erste Antwort von beliebig vielen Channels zu warten:

func print(c chan string, msg string) {
time.Sleep(time.Duration(rand.Intn(50)))
c <- "message: " + msg
}

func main() {
c1 := make(chan string)
c2 := make(chan string)

go print(c1, "one")
go print(c2, "two")

select {
case s1 := <-c1: fmt.Println(s1)
case s2 := <-c2: fmt.Println(s2)
}
}

Das Beispiel gibt entweder message: one oder message: two aus, je nachdem welcher print-Aufruf zuerst seinen Channel beschreibt.