Softwareentwicklung: Concurrency in Go
Während die Grundlagen zur Nebenläufigkeit einfach erscheinen, gibt es doch einige Hürden, die es zu überwinden gilt. Go weiß lästige Data Races zu vermeiden.
- Andreas Schröpfer
Eines der spannendsten Features von Go ist die Eleganz, mit der die Sprache Concurrency (Nebenläufigkeit) umsetzt. In nebenläufigen Programmen sind die Funktionen so definiert, dass sie sich unabhängig voneinander ausführen oder auch pausieren lassen. Somit kann die Go-Runtime Anweisungen auf mehrere Threads beziehungsweise CPU-Kerne verteilen. Rechner mit mehreren Kernen sind auf diese Weise besser ausgelastet – das ist auch einer der Gründe für die hohe Geschwindigkeit von Go-Programmen.
Goroutinen als Basis fĂĽr Concurrency in Go
Die Basis für Concurrency in Go sind Goroutinen, die über Channels miteinander kommunizieren können. Die theoretische Grundlage bildet das CSP-Model (Communicating Sequential Processes) von Tony Hoare, das bereits über 40 Jahre alt ist. Rob Pike, einer der Erfinder von Go, hatte bereits Anfang der 80er-Jahre mit Squeak und Newsqueak Erfahrungen mit diesem Model gesammelt, die in die Umsetzung von Go eingeflossen sind.
Die Definition von Goroutinen ist denkbar einfach – ein go
vor dem Funktionsaufruf reicht aus. Channels können Nachrichten eines beliebigen Typs übermitteln und bilden mit diesem zusammen jeweils einen eigenen Typ. Für die Kennzeichnung eines Channels wird chan
gefolgt vom Namen des Typs verwendet. chan string
bezeichnet den Typ Channel eines Strings beziehungsweise Channel of string. Da Channels im Arbeitsspeicher nach einer eigenen Struktur angelegt sind, gilt es, die Instanz immer mit make()
zu erzeugen. FĂĽr den String-Channel ist das make(chan string)
.
Nebenläufig meint nicht zwangsläufig parallel
Um die Koordination der jeweiligen Goroutinen – wann was auf welchem Thread ausgeführt wird – kümmert sich der Scheduler. Er ist Teil der Go-Runtime und dient als Schnittstelle zwischen den Threads des Systems und dem Go-Programm. Eine Goroutine benötigt somit kaum Systemressourcen, da der Scheduler die Threads sinnvoll verwaltet und im Anschluss die Goroutinen möglichst optimal verteilt. Nebenläufige Programme sind deshalb nicht zwingend auch parallel. Außerdem kann die Verteilung der Goroutinen auf die Threads mit jedem Programmlauf unterschiedlich ausfallen, weshalb das Thema der Synchronisierung von Goroutinen wichtig ist.
Goroutinen sind in Go sehr tief verankert. Jedes Go-Programm startet immer in mindestens einer Goroutine, in der die Main-Funktion läuft. Das lässt sich bei einem Programmabbruch sehr gut nachvollziehen. In Listing 1 bricht das Kommando panic
das Programm hart ab. Bei der Ausführung wird zusätzlich der Stacktrace ausgegeben. Dabei zeigt sich, dass die Funktion main
in gouroutine 1
ausgefĂĽhrt wird bzw. wurde. Go ist somit von Grunde auf fĂĽr Goroutinen ausgelegt.
Listing 1: Programmabbruch in Go
func main() {
panic("Hello panic!")
}
// Output:
// panic: Hello panic!
//
// goroutine 1 [running]:
// main.main()
// /tmp/sandbox1748122117/prog.go:8 +0x27
//
// Program exited.
In Listing 2 wird die Funktion routine
so definiert, dass sie sich als Goroutine ausführen lässt. Der Output erfolgt über einen Channel. Die Funktion schreibt einen einfachen Text "Hallo Goroutine!" in den Channel. Die Anweisung ist durch den Pfeil <-
definiert. Zusätzlich zur Übermittlung von Daten synchronisiert der Channel die Ausführung des Codes der Goroutinen. Der Programmfluss wird beim Sender so lange angehalten, bis die Nachricht auf der anderen Seite ankommt.
make()
erzeugt unter main
einen Channel. AnschlieĂźend startet go
die routine
als Goroutine. Ab diesem Zeitpunkt läuft routine
unabhängig zu main
. Unter <-msg
wird nun die Nachricht aus dem Channel ausgelesen und der Variable m
zugewiesen. Der Programmfluss blockiert hier so lange, bis die Nachricht erfolgreich ĂĽber den Channel verschickt wurde. Die Ausgabe erfolgt am Ende mit fmt.Println(m)
.
Listing 2: Einfache Definition einer Goroutine
func routine(out chan string) {
out <- "Hallo Goroutine!"
}
func main() {
msg := make(chan string)
go routine(msg)
m := <-msg
fmt.Println(m)
}
Augen auf bei der Nebenläufigkeit
Neben der einfachen Syntax gibt es ein paar Punkte bei der nebenläufigen Programmierung zu beachten. Da Channels so lange auf Sender- und Empfängerseite blockieren, bis die Nachricht übermittelt ist, kann der Programmfluss komplett zum Erliegen kommen. Das passiert, wenn eine Seite keine Nachricht schickt oder abholt. Listing 3 erweitert das letzte Beispiel (Listing 2) einfach um ein weiteres Auslesen aus msg
. Das hat zur Folge, dass es hier zu einem sogenannten Deadlock
kommt, da nur eine Nachricht geschickt wird. Ein fatal error
bricht das Programm an diesem Punkt ab.
Listing 3: Deadlock
func main() {
msg := make(chan string)
go routine(msg)
m := <-msg
fmt.Println(m)
m = <-msg
}
// Hallo Goroutine!
// fatal error: all goroutines are asleep - deadlock!
Ein weiteres Problem entsteht, wenn Goroutinen ohne Channels nicht aufeinander warten. Da sie nicht blockieren und parallel laufen können, sollte dieser Vorgang Entwicklerinnen und Entwicklern bewusst sein. In Listing 4 erfolgt keine Ausgabe, da main
nicht wartet, bis routine
mit der Ausgabe anfangen kann. Genau fĂĽr diese notwendige Synchronisierung verwendet das erste Beispiel aus Listing 1 einen Channel.
Listing 4: Keine Ausgabe
func routine() {
fmt.Println("Hallo zusammen")
}
func main() {
go routine()
}
Wenn für das Problem aus Listing 4 kein Channel verwendet werden soll, können Entwickler auch auf das sync
-Paket aus der Standardbibliothek zurückgreifen. Als elegante Lösung gibt es dafür die sync.WaitGroup
. Sie besitzt die Methoden Add
, Done
und Wait
, welche die Steuerung ermöglichen. Im Listing 5 wird die WaitGroup eingesetzt, deren Anwendung relativ selbsterklärend ist.