In der Sprache Go kommunizieren gleichzeitig laufende Programmteile beinahe spielerisch über so genannte Channels. Mike Schilli demonstriert das am Beispiel einer flinken Parallelisierung von Webzugriffen.
Online PLUS
Im Screencast demonstriert Michael Schilli das Beispiel: https://www.linux-magazin.de/videos/
Oft wundere ich mich, warum engagierte Entwickler neue Programmiersprachen entwerfen. Gut, eine etwas bessere Syntax schnappen die jungen Füchslein heutzutage gierig auf. Einige clevere Ideen für kompakten Code verstärken den Hipster-Drang. Aber der Aufwand, ein Ökosystem zu bauen und einer Community einzutrichtern ist doch immens!
Doch seit Prozessoren vor geraumer Zeit aufgehört haben jährlich schneller zu laufen und nur noch mit parallel laufenden Cores Geschwindigkeit simulieren, ist eines ganz wichtig: Die Sprache der Wahl muss parallel laufende Programmteile einfach koordinieren können. Als ich vor Monaten dem Whatsapp-Team bei Facebook in Menlo Park nach der Arbeit einen Besuch abstattete, erfuhr ich, was das Erfolgsgeheimnis des kleinen Teams war, das mit einer Handvoll Maschinen Millionen User texten ließ: Die altbackene Sprache Erlang, die Parallelität schon eingebaut hat.
Genauso verhält es sich mit Go. Die Google-Füchse haben nicht nur Prozessmanagement und Threading wie in Oma Meumes Programmiersprache eingebaut, sondern setzen auf neue Primitives wie Go-Routinen und Channels, und damit ist Nebenläufigkeit nicht nur verfügbar, sondern fester Bestandteil der Sprache.
Alles inklusive
Zunächst baut Listing 1 ein kleines Helferlein, eine Library für einfache Webzugriffe. Anwender rufen später einfach »httpsimple.Get()« auf und bekommen einen Fehlercode sowie den Text der eingeholten Webseite zurück. Die Funktion »Get()« ist großgeschrieben, damit externe Clients sie später aus dem Paket nutzen können. Wie die Deklaration in Zeile 10 zeigt, nimmt sie als Argumente eine URL vom Typ »string« entgegen und liefert zwei Werte zurück, den Ergebnisstring mit dem Inhalt der Webseite und einen Fehlerwert, der bei erfolgreichen Zugriffen auf »nil« steht.
Listing 1
httpsimple.go
01 package httpsimple
02 import(
03 "fmt"
04 "net/http"
05 "io/ioutil"
06 "time"
07 "errors"
08 )
09
10 func Get(url string) (string, error) {
11 tr := &http.Transport{
12 IdleConnTimeout: 30 * time.Second,
13 }
14 client := &http.Client{Transport: tr}
15 resp, err := client.Get(url)
16
17 if err != nil {
18 fmt.Printf("%s\n", err)
19 return "", err
20 }
21
22 if resp.StatusCode != 200 {
23 return "", errors.New(fmt.Sprintf(
24 "Status %v", resp.StatusCode))
25 }
26
27 defer resp.Body.Close()
28 body, err := ioutil.ReadAll(resp.Body)
29 if err != nil {
30 fmt.Printf("I/O Error: %s\n", err)
31 return "", err
32 }
33
34 return string(body), nil
35 }
Eigentlich rufen Go-Jünger Webclients aus dem Core-Paket »net/http« einfach mit »http.Get()« auf, aber für spätere Parallelzugriffe sollte der Client auch noch die Reißleine bei hängenden Webseiten oder schleppend eintrudelnden Daten ziehen können. Laut Berichten [2] taugt der Default-Client dafür nicht, deshalb definiert Zeile 11 in Listing 1 einen Transport, der den Timeout auf 30 Sekunden setzt. Und schön, dass der Client auch HTTPS kann, als wäre es die natürlichste Sache der Welt!
Der Rest von Listing 1 dient der Fehlerbehandlung, der Prüfung des Statuscodes, der auf 200 stehen sollte, sowie dem Anfordern und Einlesen des über den Socket eintreffenden Webseitentextes. Dabei gibt die Funktion bei vorzeitigem Abbruch jeweils den Leerstring als Ergebnis sowie einen Fehlercode zurück. In den Zeilen 19 und 31 reicht sie nur die Fehlerwerte der genutzten Core-Libraries »net/http« und »io/ioutil« weiter, während sie in Zeile 23 sogar einen neuen Fehlertyp mit der von 200 abweichenden Statusmeldung des Webservers zusammenbaut.
Geht alles gut, gibt Zeile 34 den in einen String konvertierten Seitentext sowie den Fehlerwert »nil« an den Aufrufer zurück. Damit ein Client das Helferlein später findet, muss ich den Go-Code aus Listing 1 in ein neues Verzeichnis »~/go/src/httpsimple« kopieren und dort dann mit »go install« kompilieren und als Library installieren.
Schön der Reihe nach
Listing 2 ruft nun nacheinander die Webserver einiger großer US-Firmen an und holt mit der neuen Library »httpsimple« deren Homepages ein [3]. Dazu definiert es ab Zeile 9 einen Array von Strings mit deren URLs und iteriert ab Zeile 15 mit einer For-Schleife darüber. Statt den ganzen Datensalat auszuspucken, ermittelt sie mit »len()« aber lediglich dessen Länge und gibt ihn zu Testzwecken aus. Abbildung 1 zeigt den Aufruf des kompilierten Binary (»go build http-serial.go«) mit dem Zeitmesser »time«, der enthüllt, dass die gesamte Aktion etwas über zwei Sekunden dauert.
Listing 2
http-serial.go
01 package main
02
03 import(
04 "fmt"
05 "httpsimple"
06 )
07
08 func main() {
09 urls := []string{
10 "https://google.com",
11 "https://facebook.com",
12 "https://yahoo.com",
13 "https://apple.com"}
14
15 for _, url := range urls {
16 body, err := httpsimple.Get(url)
17 if err == nil {
18 fmt.Printf("%s: %d bytes\n",
19 url, len(body))
20 }
21 }
22 }
Wie ließe sich das Einholen der Daten beschleunigen? Der Webclient ist ja keineswegs ausgelastet, sondern wartet geduldig, bis der Webserver die Daten rüberwachsen lässt, was auf CPU-Niveau eine kleine Ewigkeit ist. Effektiver wäre es, wenn der Webclient die Anfragen an alle vier Webserver auf einmal abschickt, um dann in aller Ruhe die eintrudelnden Daten einzusammeln. Das ginge entweder mit mehreren parallel laufenden Prozessen, mit leichtgewichtigeren Threads oder aber mit einer Eventschleife wie zum Beispiel in Node.js.

Abbildung 1: Mit hintereinander abgefeuerten Requests holt der Go-Client alle vier URLs in gut zwei Sekunden vom Netz.
Eigenes Süppchen
Go bietet als nebenläufiges Primitiv so genannte Go-Routines an, deren Ablauf die Go-Runtime plant und ausführt. Sie sind noch leichter als Threads, da sich mehrere Go-Routinen einen Thread teilen. Das Schlüsselwort »go« – gefolgt von einem Funktionsaufruf – startet eine parallel laufende Go-Routine im Hintergrund und springt in der Ausführung des Hauptprogramms gleich zur nächsten Zeile weiter. Wer jedoch ein Hauptprogramm ausführt, in dem nur einige Aufrufe zu Go-Routinen stehen, die wie in
go fmt.Println("a")
go fmt.Println("b")
go fmt.Println("c")
jeweils einen Buchstaben ausgeben, wird sich wundern, dass überhaupt nichts auf der Standardausgabe erscheint, während das Programm läuft und abrupt darauf endet! Der Grund dafür ist, dass Go die drei Routinen zwar nebenläufig startet, aber das Hauptprogramm so schnell abschließt, dass keiner der abgespaltenen Programmflüsse auch nur bis zum »Println()«-Kommando kommt.

Abbildung 2: Drei verschiedene Ergebnisse bei drei nacheinander folgenden Aufrufen wegen unsynchronisierter Go-Routinen.
Eine interessante Race Condition kommt zustande, falls am Ende wie in Listing 3 eine Sleep-Anweisung aus dem »time«-Paket ein paar Mikrosekunden lang wartet. Die Ausgabe des Programms schwankt dann zwischen gar nichts, einem, zwei, oder drei Buchstaben, je nachdem, wie weit das Programm in der vorgegebenen Zeit kommt, aber deterministisch ist das offensichtlich nicht (Abbildung 2).
Auf Nachzügler warten
Die Länge des Schlaf-Kommandos im Hauptprogramm auszuweiten und auf gut Glück zu hoffen, dass in der Zwischenzeit alle Zöglinge ihre Arbeit beendet haben, ist keine gute Lösung. Falls der Rechner zwischenzeitlich mit kostspieligen Operationen beschäftigt ist, kann es sein, dass die Dauer sich auf einige Sekunden ausdehnt und der Wettlauf wieder zu Ungunsten der Go-Routinen ausgeht.
Listing 3
racecond.go
01 package main
02 import "fmt"
03 import "time"
04
05 func main() {
06 go fmt.Println("a")
07 go fmt.Println("b")
08 go fmt.Println("c")
09 // unreliable!
10 time.Sleep( 50 * time.Microsecond )
11 }
Damit jede Go-Routine garantiert zum Zug kommt, müssen sich Hauptprogramm und Routinen verständigen. Das Hauptprogramm muss am Ende des Programmflusses warten, bis sich jede Routine erfolgreich beendet hat, und kann erst dann den Hauptprozess herunterfahren. Go bietet mit dem Paket »sync« einige auf Semaphoren basierende Tools an, die diesen Job zuverlässig erledigen.
Die eleganteste und von Go-Programmierern bevorzugte Methode bedient sich aber so genannter Channels. Diese an Unix-Pipes erinnernden Kommunikationskanäle transportieren Informationen von einem Programmteil zum anderen. Zudem blocken sie den Programmfluss in einer Routine, falls sich nichts in den Channel hineinstopfen oder herauslesen lässt, und eignen sich so hervorragend zur Synchronisation, da so einzelne Programmteile aufeinander warten können.
Kanalisiert synchronisieren
Listing 4 feuert auch wieder drei verschiedene Go-Routinen ab, gibt aber in diesen nichts direkt aus. Stattdessen stopfen die Go-Routinen ihre Ausgabe in einen in Zeile 5 definierten Channel, der Daten vom Typ »string« aufnimmt. Der umgedrehte Pfeil »<-«, der von den zu schreibenden Daten (zum Beispiel “a”) zum Channel zeigt, schickt die Daten in den Channel hinein (Abbildung 3).
Listing 4
gochannel.go
01 package main
02 import "fmt"
03
04 func main() {
05 done := make(chan string)
06
07 go func() { done <- "a" }()
08 go func() { done <- "b" }()
09 go func() { done <- "c" }()
10
11 defer close(done)
12
13 for i := 0; i <= 2; i++ {
14 msg := <-done
15 fmt.Println(msg)
16 }
17 }
Je nach offener Kapazität des Channels erfolgt dies entweder sofort oder nachdem die Go-Runtime den Lauf der jeweiligen Go-Routine so lange geblockt hat, bis der Kanal die Daten aufnehmen kann. Dabei, das ist ganz wichtig, blockt immer nur eine Go-Routine, andere Go-Routinen laufen ungehindert weiter und verursachen so keinen Hänger im Programm, sondern bilden ein hochperformantes System.
Dabei ist die Ausgabe von Listing 4 ebenfalls nicht deterministisch, ob dort »a b c« oder »c b a« oder »b a c« steht, ist ungewiss, da sich die einzelnen Go-Routinen in dieser einfachen Implementierung nicht untereinander absprechen und das Hauptprogramm nur wartet, bis alle Routinen fertig sind – in welcher Reihenfolge hängt vom Zufall ab. Allerdings ist garantiert, dass in der Ausgabe immer drei Buchstaben stehen.
Nach dem Abfeuern der Go-Routinen ordnet das Hauptprogramm mit der »defer«-Anweisung in Zeile 11 an, den Channel »done« nach Programmende zu schließen, und betritt dann eine For-Schleife, die jeweils in Zeile 14 mit dem Lese-Operator »<-« auf der linken (!) Seite der Channelvariablen den nächsten im Channel anliegenden Wert abholt und der Variablen »msg« zuweist.
Bei diesem Lesevorgang findet auch die Synchronisation mit den Go-Routinen statt: Wenn das Hauptprogramm die For-Schleife und die Lese-Operation erreicht, hatte mit großer Wahrscheinlichkeit noch keine der Go-Routinen Gelegenheit, ihre Schreibanweisung abzuarbeiten. Das macht nichts, wenn der Channel leer ist, blockt das Programm in Zeile 14 bis Daten vorliegen und die Go-Runtime lässt eine der Go-Routinen ran. Sobald diese Daten eingetrichtert hat, merkt auch die geblockte Lese-Anweisung im Hauptprogramm, dass es weitergeht, und die For-Schleife geht in die nächste Runde.
Genau abgezählt
Jetzt wird klar, warum bei dieser einfachen Implementierung die For-Schleife in Zeile 13 genau wissen muss, wie viele Datenpakete von den Go-Routinen in den Channel eingespeist wurden, um genau diese Anzahl wieder herauszuholen. Würde das Hauptprogramm den Channel einfach weiter nach Daten fragen, würde die Go-Runtime den vierten Lesevorgang unendlich lange blocken, denn niemand schreibt ab diesem Zeitpunkt weitere Daten in den Channel. Ein hängendes Hauptprogramm wäre die Folge.
Schneller durch Parallelität
Mit diesem Rüstzeug versucht nun der Webclient in Listing 5 die Requests an die verschiedenen Internetseiten nicht mehr hintereinander, sondern gleichzeitig abzusetzen und damit Zeit zu sparen. Dazu nutzt es auch wieder das in Listing 1 gezeigte Paket »httpsimple« zum Einholen der Daten vom Web, feuert aber in »fetchall()« ab Zeile 32 in einer For-Schleife für jeden Request eine eigene Go-Routine ab, samt dem Hauptprogramm werkeln also insgesamt fünf Go-Routinen gleichzeitig am Einholen und Abarbeiten der Daten herum!
Listing 5
http-parallel.go
01 package main
02
03 import(
04 "fmt"
05 "httpsimple"
06 )
07
08 type Result struct {
09 Error error
10 Body string
11 Url string
12 }
13
14 func main() {
15 urls := []string{
16 "https://google.com",
17 "https://facebook.com",
18 "https://yahoo.com",
19 "https://apple.com"}
20
21 results := fetchall(urls)
22
23 for i := 0; i<len(urls); i++ {
24 result := <-results
25 if result.Error == nil {
26 fmt.Printf("%s: %d bytes\n",
27 result.Url, len(result.Body))
28 }
29 }
30 }
31
32 func fetchall(
33 urls []string) (<-chan Result) {
34
35 results := make(chan Result)
36
37 for _, url := range urls {
38 go func(url string) {
39 body, err := httpsimple.Get(url)
40 results <- Result{
41 Error: err, Body: body, Url: url}
42 }(url)
43 }
44
45 return results
46 }
Den Channel, über den die Arbeitsbienen ihre Ergebnisse ans Hauptprogramm schicken, definiert Zeile 35 und legt als Typ für die eingefütterten Daten die in Zeile 8 definierte Struktur »Result« fest. Nachdem die »Get()«-Funktion des »httpsimple«-Pakets die Textdaten der eingeholten Webseite in der Variablen »body« geliefert hat, stopft sie Zeile 40 samt etwaiger Fehlercodes und der URL in die Datenstruktur und mit dem Schreiboperator »<-« auf der rechten Seite der Channel-Variablen »results« in den Channel.
Vorsicht, Falle!
Beim Abfeuern von Go-Routinen in For-Schleifen ist aber ein typischer Anfängerfehler zu vermeiden [4]: Der Aufruf »go func(){}()« einer anonym definierten Funktion als Go-Routine funktioniert als so genannte Closure, hält also etwaige lokal definierten Variablen des Hauptprogramms innerhalb der Go-Routine parat, auch wenn Erstere beim Verlassen des Codeblocks ihre Gültigkeit verlieren.
Da aber die Schleifenvariable »url« bei jedem neuen Schleifendurchgang ihren Wert verändert und höchstwahrscheinlich keine der Go-Routinen zum Einsatz kommt, bevor die Schleife endet, findet sich der Programmierer in den meisten Fällen in der seltsamen Situation wieder, dass jede der Go-Routinen den gleichen Wert für »url« erhält, meist das letzte Element des Array der Schleife. Damit dies nicht passiert und jede Go-Routine ihren eigenen »url«-Wert mitbekommt, nimmt sie die anonyme Funktion in Zeile 38 in ihre Parameterliste auf und Zeile 42 macht sie zum Teil der Closure.
Das Hauptprogramm iteriert ab Zeile 23 über eine feste Anzahl von Channel-Einträgen, die ja gottlob mit der Länge des Array »urls« in Zeile 15 feststeht. So kann der Channel-Leseoperator in Zeile 24 auch ruhig blocken, bis das Ergebnis im Channel vorliegt, da die parallel arbeitenden Go-Routinen genau die vorgegebene Anzahl von Ergebnissen im Channel ablegen werden.
Abbildung 4 zeigt, dass das parallele Einholen der Daten in der Tat enorm Zeit spart, das Programm schließt den Vorgang etwa dreimal so schnell ab. Es ist fraglos effizienter, den Rechner während der Wartepausen beim Einholen der Webdaten mit anderen Aufgaben zu beschäftigen, als herumsitzen und Daumen drehen zu lassen.
Zum Thema Nebenläufigkeit mit Go kann ich das Buch von Katherine Cox-Buday wärmstens empfehlen [5]. Es hangelt sich mit akribischer Sorgfalt durch gutes und schlechtes Design mit Go-Channels und zeigt nicht nur die gängigen Design-Pattern, sondern schaut auch hinter die Kulissen und erklärt, warum ein Ansatz schnellere und weniger fehleranfällige Programme erzeugt.
Geschenkt kommt der Geschwindigkeitszuwachs bei der Parallelisierung nämlich nicht daher. Wer nicht aufpasst wie ein Haftelmacher, erlebt auf Produktionssystemen unter Last oft sein blaues Wunder mit Race Conditions, Deadlocks oder anderen rätselhaften Panikattacken des Programms.
Infos
-
Listings zu diesem Artikel: https://www.linux-magazin.de/static/listings/magazin/2018/11/snapshot/
-
Nathan Smith, “Don’t use Go’s default HTTP client (in production)”: https://medium.com/@nate510/don-t-use-go-s-default-http-client-4804cb19f779
-
Michael Schilli, “Turm von Babylon”: Linux-Magazin 06/17, S. 88, https://www.linux-magazin.de/Ausgaben/2017/06/Snapshot
-
“Closure mistake with for loops”: https://github.com/golang/go/wiki/CommonMistakes
-
Katherine Cox-Buday, “Concurrency in Go”: O’Reilly, 2017









