Über unsMediaKontaktImpressum
Jan Stamer 23. Mai 2017

Go Parallel

Der Autor Jan Stamer als Gopher. © Jan Stamer
© Jan Stamer

Die Programmiersprache Go ist durch und durch auf Nebenläufigkeit und Parallelisierung ausgelegt. Und das man mit Go auch ernsthafte Programme schreiben kann, bestreitet auch keiner mehr seit es Docker gibt. Das macht Go für Projekte mit hohen Anforderungen an Parallelisierung interessant. Schauen wir uns also die Konzepte von Go für Nebenläufigkeit und Parallelisierung an und wie man sie praktisch anwendet.

Abb.1: Der Gopher. © Jan Stamer
Abb.1: Der Gopher. © Jan Stamer

Bevor wir einsteigen ein paar Fakten zu Go: Go hat ein statisches Typsystem und einen Garbage Collector. Anders als Ruby oder Python läuft Go nicht in einer virtuellen Maschine ab, sondern wird als Binärdatei direkt auf der Maschine ausgeführt. Und Go ist weder objektorientiert noch funktional, sondern zählt zu den prozeduralen Programmiersprachen. Aber jetzt wollen wir Code sehen!

Begrüßen wir also das Maskottchen der Sprache Go, den Gopher (Abb.1). Dazu erstellen wir die Datei hellogopher.go mit dem Code aus Listing 1. Auf dem "Go Playground" kann man den Code direkt ausführen.

Listing 1: (Go Playground)

package main

import "fmt"

func main() {
    fmt.Println("Hello Gopher!")
}

Die Datei hellogopher.go beginnt mit der Package-Deklaration in der ersten Zeile. In diesem Fall ist das Package main und stellt den Einstiegspunkt des Programms dar. Dann kommen die importierten Packages, hier nur das Package fmt aus der Standardbibliothek. Anschließend beginnt die main-Funktion mit dem Schlüsselwort func. Im Body der Funktion main wird die Funktion Println aus dem Package fmt mit dem String "Hello Gopher!" als Argument aufgerufen. Jetzt führen wir unser Programm aus. Dazu kompilieren wir den Go-Code mit go build hellogopher.go in die Binärdatei hellogopher. Die können wir nun mit ./hellogopher ausführen. Alternativ können wir auch in einem Schritt kompilieren und ausführen mit go run hellogopher.go.

Go-Code entwickeln

Um Go-Code zu entwickeln, braucht es nicht viel. Es reicht ein einfacher Editor. Der wird dann kombiniert mit einigen Tools zur Go-Entwicklung, wie gocode zur Autovervollständigung, gorename um Variablen oder Packages umzubennen oder goimport um Package-Imports aufzuräumen. Dies sind eigene Kommandozeilen-Tools ganz nach der Unix-Philosophie. Die Kombination dieser Tools mit einem Editor wie Vim, Emacs, Atom oder auch Visual Studio Code ergibt eine Art integrierte Entwicklungsumgebung. Wer eine IDE bevorzugt, findet auch Go-Plugins für die großen IDEs wie Eclipse oder Intellij. Jetbrains arbeitet sogar an einer eigenen IDE speziell für Go [1].

Go-Quellcode ist immer im Zeichensatz UTF-8 kodiert und mit dem Tool gofmt formatiert, welches mit Go ausgeliefert wird. Eine Diskussion über Tabs oder Spaces gibt es bei der Go-Entwicklung nicht. Der Go-Compiler duldet weder ungenutzte Variablen noch überflüssige Package-Imports. Beides verbessert die Wartbarkeit.

Goroutinen

In unserem Hello Gopher-Beispiel läuft der Code sequentiell ab. Das ändern wir nun. Als Vorbereitung verlagern wir die Ausgabe in eine eigene Funktion HelloGopher und rufen diese aus der main-Funktion auf. Jetzt sorgen wir dafür, dass die Funktion HelloGopher nicht mehr sequentiell, sondern parallel ausgeführt wird. Dazu rufen wir HelloGopher in einer eigenen Goroutine auf. Eine Goroutine ist so etwas wie ein leichtgewichtiger Thread und wird mit dem Schlüsselwort go gestartet. Mit dem Aufruf go HelloGopher() starten wir eine neue Goroutine in der die Funktion HelloGopher aufgerufen wird. Den Code zeigt Listing 2.

Listing 2: (Go Playground)

func HelloGopher() {
    fmt.Println("Hello Gopher!")
}

func main() {
    go HelloGopher()
}

Führen wir das Programm nun aus, sehen wir aber keine Ausgabe mehr. Warum nicht? In Go wird auch die main-Funktion in einer eigenen Goroutine ausgeführt, nämlich der main-Goroutine. Ein Go-Programm endet, sobald die main-Goroutine endet. Da nach dem Start der Goroutine, die HelloGopher aufruft, nichts mehr kommt, endet die main-Goroutine und damit auch das Go-Programm. Das Programm endet also schon bevor wir die Ausgabe sehen. Damit wir die Ausgabe sehen muss die main-Goroutine so lange warten, bis die Goroutine mit der Ausgabe endet. Dafür gibt es die WaitGroup aus dem Package sync. Wir holen uns mit var wg sync.WaitGroup eine WaitGroup und speichern sie in der Variablen wg. Der WaitGroup teilen wir mit wg.Add(1) mit, dass wir eine Goroutine ausführen wollen. Innerhalb der Goroutine müssen wir die WaitGroup mit wg.Done() über das Ende der Goroutine informieren. Dazu schreiben wir den Code so um, dass die Goroutine eine anonyme Funktion startet, in deren Body zuerst defer wg.Done() aufgerufen wird und dann die Funktion HelloGopher. Aber Moment, warum zuerst wg.Done()? Achtung, den Unterschied macht das Schlüsselwort defer vor dem Aufruf von wg.Done(). Das defer sorgt dafür, dass wg.Done() nach dem Body der Funktion ausgeführt wird und zwar auch dann, wenn im Body der Funktion ein Fehler auftritt. Jetzt können wir in der main-Funktion mit wg.Wait() auf das Ende aller Goroutinen warten. Den Code dazu zeigt Listing 3.

Listing 3: (Go Playground)

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        HelloGopher()
    }()
    wg.Wait()
}

War das jetzt parallel? Noch nicht so ganz. Damit etwas parallel ausgeführt wird müssen wir noch mindestens eine weitere Goroutine starten, wie in Listing 4 gezeigt.

Listing 4: (Go Playground)

func main() {
    var wg sync.WaitGroup

    // 1. Goroutine
    wg.Add(1)
    go func() {
        defer wg.Done()
        HelloGopher()
    }()

    // 2. Goroutine
    wg.Add(1)
    go func() {
        defer wg.Done()
        HelloGopher()
    }()

    wg.Wait()
}

Jetzt werden die beiden Goroutinen zur Laufzeit parallel ausgeführt, wenn unsere Hardware das hergibt. Wenn beide Goroutinen enden, endet die Methode Wait der WaitGroup und damit auch unser Go-Programm. Wir haben mit Goroutinen ein wichtiges Konzept von Go zur nebenläufigen Programmierung kennengelernt. Es gibt noch ein weiteres wichtiges Konzept: die Channels.

Channels

Channels sind Nachrichtenkanäle, über die sich mehrere Goroutinen Nachrichten schicken können. Dahinter steckt das Konzept CSP [2]. Jetzt schreiben wir unser letztes Beispiel so um, dass es Channels verwendet.

In der main-Funktion erstellen mit names := make(chan string) einen Channel vom Typ string und weisen ihn der Variable names zu. Jetzt benennen wir die Funktion HelloGopher in Hello um und übergeben ihr den Channel als Parameter. Die Signatur der Funktion Hello lautet dann func Hello(names chan string). In der Funktion Hello warten wir mit name := <-names auf eine Nachricht des Channel names, speichern sie in der Variablen name und geben sie aus. Dann senden wir in der main-Funktion mit names <- "Jim" den String "Jim" an den Channel. Der gesamte Code steht in Listing 5. Unser Programm begrüßt Jim auf der Konsole, funktioniert also wie gewünscht. Aber wo ist die WaitGroup hin? Die brauchen wir nicht mehr weil wir mit einem Channel arbeiten.

Listing 5: (Go Playground)

func Hello(names chan string) {
    name := <-names
    fmt.Println("Hello " + name + "!")
}

func main() {
    names := make(chan string)
    go Hello(names)
    names <- "Jim"
}

Channels dienen nicht nur der Kommunikation, sondern auch der Synchronisation. Empfängt eine Goroutine eine Nachricht auf einem Channel, ist sie so lange blockiert, bis eine Nachricht kommt. Auch das Senden einer Nachricht an einen Channel blockiert die Goroutine so lange, bis der Channel bereit ist, die Nachricht zu empfangen. In unserem Channel-Beispiel läuft die Goroutine los und die Funktion Hello wird ausgeführt. In der Funktion Hello wird auf eine Nachricht auf dem Channel names gewartet. Die Goroutine wartet so lange, bis die main-Goroutine eine Nachricht an den Channel sendet. Dann laufen beide Goroutinen weiter! Es wäre also durchaus möglich, dass die main-Goroutine schneller endet als die Goroutine mit der Funktion Hello die Ausgabe schreiben kann. Dann würden wir gar nichts sehen (auch wenn das in der Praxis nie der Fall ist). Das zeigt also, dass unser Code nicht ganz sauber ist, weil wir nicht sichergestellt haben, das auch wirklich alle Goroutinen beendet sind, wenn das Programm endet.

Non-Blocking Select

Was aber wenn wir vermeiden wollen, dass eine Goroutine blockiert ist? Betrachten wir zunächst das Senden. Damit das Senden nicht blockiert, hat jeder Channel einen Buffer, also eine Queue von Nachrichten mit fester Größe. Nachrichten können nur an den Channel gesendet werden, wenn noch Platz in der Queue ist. Ist kein Platz mehr, blockiert das Senden so lange, bis wieder Platz in der Queue ist, also bis eine andere Goroutine wieder eine Nachricht aus dem Channel empfangen hat. Nachrichten zu empfangen ohne zu blockieren, ist auch möglich. Aber dafür brauchen wir das select-Statement. Das lernen wir jetzt kennen.

In Go gibt es eine eigene Kontrollstruktur speziell für Channels: das select-Statement. Ein select-Statement besteht aus mehreren case-Statements. In jedem case-Statement wird eine Nachricht eines Channels empfangen. Wir schreiben nun unsere Funktion Hello mit Channel um, so dass sie ein select-Statement verwendet (s. Listing 6).

Listing 6:

func Hello(names chan string) {
    select {
    case name := <-names:
        fmt.Println("Hello " + name + "!")
    }
}

Die Funktion Hello enthält nun ein select-Statement mit einem case, in dem wir auf eine Nachricht aus dem Channel names warten. Auch mit select ist die Goroutine blockiert, bis eine Nachricht auf dem Channel names kommt. Um das zu vermeiden, können wir beim select-Statement noch einen default case angeben (s. Listing 7).

Listing 7:

func Hello(names chan string) {
    select {
    case name := <-names:
        fmt.Println("Hello " + name + "!")
    default: // auf keinem Channels kommt eine Nachricht
        fmt.Println("Keiner da...")
    }
}

Kommt auf keinem der Channels des select-Statements eine Nachricht, so greift der default case und in unserem Beispiel kommt die Ausgabe "Keiner da…" statt der Begrüßung. Das select-Statement macht es möglich, dass eine Goroutine nicht blockiert ist, wenn keiner der Channels eine Nachricht enthält. Dann geht es einfach mit dem default case weiter.

Channel mit Timeout

Jetzt können wir beim Warten auf eine Nachricht aus dem Channel entweder blockieren, bis eine Nachricht kommt oder direkt weitermachen, wenn keine Nachricht im Channel drin ist. Aber ist es auch möglich, eine gewisse Zeit zu warten, ob eine Nachricht kommt und sonst weiterzumachen? Auch das geht mit dem select-Statement. Wir erweitern das select-Statement mit case <-time.After(time.Second) um einen weiteren case, mehr nicht.

Der Aufruf der Funktion After aus dem Package time liefert als Ergebnis einen Channel auf dem nach der gewünschten Zeit eine Nachricht kommt. Die Goroutine ist nun so lange blockiert, bis auf einem der Channels des select-Statements eine Nachricht kommt. Kommt innerhalb einer Sekunde keine Nachricht auf dem Channel names, dann wurde das Zeitlimit überschritten und es kommt eine Nachricht auf dem mit time.After(time.Second) erzeugten Channel und es wird "Immer noch keiner da…" ausgegeben. Den Code zeigt Listing 8.

Listing 8:

func Hello(names chan string) {
    select {
    case name := <-names:
        fmt.Println("Hello " + name + "!")
    case <-time.After(time.Second):
        fmt.Println("Keiner da ...!")
    }
}
IT-Tage 2017 - Softwareentwicklung

Pingpong mit Goroutinen

Jetzt haben wir das nötige Vorwissen für ein komplexeres Beispiel. Spielen wir Pingpong mit Goroutinen. Dazu brauchen wir eine Tischtennisplatte, einen Ball und zwei Spieler. Die Tischtennisplatte ist ein Channel vom Typ int, der Ball ein int und für jeden Spieler starten wir eine eigene Goroutine.

Wie die Spieler beim Tischtennis den Ball hin und her spielen, machen das auch die Goroutinen. Die Goroutinen der Spieler warten, bis der Ball kommt, indem sie auf eine Nachricht auf dem Channel table, der Tischtennisplatte, warten. Dann schlagen sie ihn wieder zurück, indem sie eine Nachricht an den Channel table senden. Das macht jeder Spieler in einer Endlosschleife, damit das Spiel immer hin und her geht. Damit wir das Spiel verfolgen können, erhält jeder Spieler einen Namen und den Ball nutzen wir als Zähler, der bei jedem Schlag hochgezählt wird. Bei jedem Schlag geben wir den Namen des Spielers aus, der schlägt und den Wert des Balls (also der wievielte Schlag es ist). Den Code für einen Spieler zeigt die Methode player aus Listing 9.

Listing 9: (Go Playground)

package main

import (
    "fmt"
    "time"
)


func player(name string, table chan int) {
    for {
        ball := <-table // Ball kommt
        ball++
        fmt.Println(name, "schlägt", ball, "ten Ball")
        time.Sleep(400 * time.Millisecond)
        table <- ball // Ball zurückspielen
    }
}

func main() {
    ball := 0
    table := make(chan int)

    go player("Jack", table) // Spieler 1
    go player("Tom", table)  // Spieler 2

    table <- ball
    time.Sleep(2 * time.Second) // Spielzeit
    <-table
}

In der main-Funktion initialisieren wir Ball und Tisch, starten die Goroutinen der Spieler und bringen den Ball ins Spiel. Den Ball bringen wir ins Spiel, indem wir ihn als Nachricht an den Channel table schicken. Das Spiel lassen wir 2 Sekunden laufen und brechen es ab, indem wir den Ball aus dem Spiel nehmen. Dazu empfangen wir nach Ende der Spielzeit eine Nachricht auf dem Channel table, also den Ball. Listing 9 zeigt den gesamten Code des Pingpong-Beispiels.

Führen wir das Programm aus, sehen wir, dass die Goroutinen der beiden Spieler den Ball immer hin und her spielen. Dabei ist die Goroutine des ersten Spielers so lange blockiert bis der Ball kommt, also bis eine Nachricht auf dem Channel tables kommt. Das ist genau dann der Fall, wenn die Goroutine des zweiten Spielers den Ball zurückgespielt hat, also den Ball als Nachricht an den Channel tables geschickt hat. So geht es immer hin und her bis der Ball aus dem Spiel genommen wird. Bei unserem Pingpongspiel wird nie etwas parallel ausgeführt. Das Beispiel zeigt aber, wie mehrere Goroutinen einen Channel nutzen, um miteinander zu kommunizieren und sich an gewissen Punkten der Ausführung zu synchronisieren. Im nächsten Beispiel führen wir dann aber endlich mehrere Goroutinen parallel aus.

Worker Pattern

Lernen wir zum Schluß ein einfaches Concurrency-Pattern kennen, das Worker Pattern, in Go auch Fan Out genannt. Wir haben eine Vielzahl von Jobs, die abgearbeitet werden sollen und zwar parallel von mehreren Workern. Anschließend sammeln wir die Ergebnisse ein.

Dazu erzeugen wir einen Channel für Jobs und einen für die Ergebnisse. Beide erzeugen wir mit make(chan int, 10), das heißt, die Channels sind vom Typ int und der Buffer der Channels hat die Größe 10. Dann starten wir die gewünschte Anzahl von Workern. Jeden Worker starten wir mit go worker("John", jobs, results) in einer eigenen Goroutine und übergeben ihm als Parameter einen Namen sowie die Channels mit Jobs und Ergebnissen.

Jetzt können wir die Jobs an die Worker übergeben. Dazu senden wir für jeden Job eine Nachricht an den Job-Channel, in unserem Fall ein einfacher Zähler. Die Worker beginnen jetzt, parallel die Jobs des Job Channels abzuarbeiten. Jeder Worker wartet, bis eine Nachricht auf dem Job-Channel kommt. Dann legt er sich eine Sekunde schlafen, um eine Verarbeitung zu simulieren, gibt aus, dass er den Job abgearbeitet hat und schickt das Ergebnis an den Channel mit den Ergebnissen. Jetzt ist der Worker wieder frei und wartet auf den nächsten Job, dazu die for-Schleife.

Wann hört das auf? Nachdem alle Jobs an den Job-Channel gesendet wurden wird der Job Channel mit close(jobs) geschlossen. Im Gegensatz zu unserem vorigen Beispiel horchen die Worker Goroutinen nicht in einer Endlosschleife auf Nachrichten des Channels, sondern in einer for range-Schleife – und das macht den Unterschied aus. Wird der Channel geschlossen, endet die Schleife und damit auch die Goroutine des Workers. Damit sind alle Jobs abgearbeitet und alle Worker Goroutinen beendet. Listing 10 zeigt den gesamten Code des Worker Beispiels.

Listing 10: (Go Playground)

package main

import "fmt"
import "time"

// START OMIT
func worker(name string, jobs <-chan int, results chan<- int) {
    for j := range jobs {
        time.Sleep(time.Second)
        fmt.Println(name, "ist fertig mit Job", j)
        results <- j
    }
}

func main() {
    jobs := make(chan int, 10)    // Channel für Jobs mit Puffer Größe 10
    results := make(chan int, 10) // Channel für Ergebnisse mit Puffer Größe 10

    go worker("John", jobs, results)  // Worker 1
    go worker("Maria", jobs, results) // Worker 2

    for j := 1; j <= 5; j++ { // 5 Jobs einstellen
        jobs <- j
    }
    close(jobs) // Channel schließen

    for a := 1; a <= 5; a++ { // Ergebnisse abholen
        fmt.Println("Ergebnis ist", <-results)
    }
}

Bei der Programmierung mit Goroutinen ist darauf zu achten, dass jede Goroutine, die gestartet wird, auch wieder endet. Tut man das nicht, gibt es ein Leak von Gouroutinen zur Laufzeit.

Es gibt noch viele interessante Concurrency Patterns für Go, die hier leider den Rahmen sprengen würden. Rob Pike, einer der Väter der Programmiersprache Go, hat dazu einen tollen Vortrag gehalten. [3].

Fazit

Wir haben mit Goroutinen und Channels die wichtigsten Konzepte von Go zur nebenläufigen Programmierung kennengelernt und nebenbei einiges über die Programmiersprache Go gelernt. Leider mussten viele spannende Themen zur Progammierung in Go wie Interfaces, Structs oder Fehlerhandling außen vor bleiben. Wessen Lust auf Go geweckt wurde, dem empfehle ich diese Grundlagen nachzuholen [4]. Es lohnt sich!

Go-Code ist einfach lesbar, langweilig und uncool.

Auch wer noch nie mit Go in Berührung gekommen ist, findet sich in der Regel in Go-Code schnell zurecht. Go-Code ist einfach lesbar, ja sogar langweilig und uncool. Das darf er auch – ja wir Gopher sind geradezu stolz darauf. Denn wichtig ist nicht der Code an sich, sondern die Ideen und Algorithmen, die er ausdrückt.

Moderne Hardware kann viele Dinge parallel ausführen. Das klappt aber nur, wenn wir Anwendungen so entwickeln, dass sie auf Nebenläufigkeit ausgelegt sind. Das allein dem Compiler zu überlassen, genügt nicht. Wir müssen ein Programmierparadigma wählen, das die Nebenläufigkeit der Fachdomäne abbilden kann. Tun wir das nicht, ist das aus meiner Sicht nicht mehr zeitgemäß. Vielleicht ist Go für Euch das richtige Programmierparadigma und hilft Euch, zeitgemäße Anwendungen zu bauen.

nach Oben
Autor

Jan Stamer

Jan Stamer ist Softwareentwickler, Architekt und begeisterter Gopher bei red6 in Hamburg. Die red6 ist ein Spin-off der Hanse-Merkur-Versicherung und entwickelt Insurance-Lösungen mit zeitgemäßen Technologien.
>> Weiterlesen
botMessage_toctoc_comments_929