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.

In Pocket speichern vorlesen Druckansicht 9 Kommentare lesen
Gleisanlagen in Maschen im Gegenlicht
Lesezeit: 16 Min.
Von
  • Andreas Schröpfer
Inhaltsverzeichnis

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.

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).

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)
}

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.