Gleichzeitig (Linux-Magazin, November 2018)

Oft wundere ich mich darüber, warum engagierte Entwickler überhaupt noch neue Programmiersprachen entwerfen. Gut, ein bisserl bessere Syntax schnappen die jungen Füchslein heutzutage begierig auf, ein paar neue clevere Ideen für kompakten Code verstärken den Hipsterdrang, aber der Aufwand, ein ganzes Ökosystem drumherum zu bauen und einer Community einzutrichtern ist doch immens!

Doch seit Prozessoren vor geraumer Zeit aufgehört haben, jährlich schneller zu laufen, und nurmehr 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 einigen Monaten dem Whatsapp-Team bei Facebook in Menlo Park mal nach der Arbeit einen Besuch abstattete, erfuhr ich, was das Geheimnis des Erfolges des kleinen Teams damals war, die mit einer Handvoll Maschinen Abermillionen User texten ließen: 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 ganz neue Primitiven 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 Web-Zugriffe. 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 einen 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. Eigentlich rufen Go-Jünger Webclients aus dem Core-Paket net/http einfach mit http.Get() auf, aber für später gezeigte Parallelzugriffe sollte der Client auch noch die Reißleine bei hängenden Webseiten oder schleppend eintrudelnden Daten ziehen können. Laut Berichten ([4]) taugt der Default-Client allerdings 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 gleich https kann, als wär's die natürlichste Sache der Welt, recht so! Der Rest von Listing 1 widmet sich der Fehlerbehandlung, der Prüfung des Statuscodes, der auf 200 stehen sollte, sowie dem Anfordern und Einlesen des über den Socket eintrudelnden Webseitentextes. Dabei gibt die Funktion bei vorzeitigem Abbruch jeweils den Leerstring als Ergebnis, sowie einen Fehlercode an den Aufrufer zurück. In den Zeilen 19 und 31 reicht sie lediglich die Fehlerwerte der verwendeten 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.

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 }

Damit ein Client das Helferlein später auch findet, muss der Go-Code aus Listing 1 in ein neues Verzeichnis ~/go/src/httpsimple kopiert, und dort dann mit go install compiliert und als Library installiert werden.

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. 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 compilierten Binaries (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 nun beschleunigen? Der Web-Client ist ja keineswegs ausgelastet, sondern wartet nur geduldig, bis der Webserver die Daten rüberwachsen lässt, was auf CPU-Niveau eine kleine Ewigkeit ist. Viel effektiver wäre es, wenn der Web-Client die Anfragen an alle vier Webserver auf einmal rausfeuern würde, 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 NodeJS.

Abbildung 1: Mit hintereinander abgefeuerten Requests holt der Go-Client alle vier URLs in guten zwei Sekunden vom Netz.

Eigenes Süppchen

Go bietet als nebenläufiges Primitiv sogenannte Goroutines an, deren Ablauf die Go-Runtime plant und ausführt. Sie sind noch leichtgewichtiger 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 allerdings ein Hauptprogramm ausführt, indem 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 abgespalteten Programmflüsse auch nur bis zum Println-Kommando kommt. 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 3).

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 }

Abbildung 2: Drei verschiedene Ergebnisse bei drei nacheinander folgenden Aufrufen wegen unsyncronisierter Goroutinen.

Auf Nachzügler warten

Die Länge des Schlafkommandos im Hauptprogramm auszuweiten und auf gut Glück zu hoffen, dass in der Zwischenzeit alle Zöglinge ihre Arbeit beendet haben, ist offensichtlich keine gute Lösung. Falls der Rechner zwischenzeitlich mit kostspieligen Operationen beschäftigt ist, kann es durchaus sein, dass die Zeitspanne sich auf einige Sekunden ausdehnt, und der Wettlauf wieder zu Ungunsten der Go-Routinen ausgeht.

Abbildung 3: Die etwas gewöhnungsbedürftige Go-Syntax zum schreibenden und lesenden Channel-Zugriff.

Damit jede der abgefeuerten Go-Routinen garantiert zum Zug kommt, müssen sich Hauptprogramm und Go-Routinen untereinander verständigen. Das Hauptprogramm muss am Ende des Programmflusses warten, bis jede der Go-Routinen signalisiert, dass sie sich 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, aber die eleganteste und von Go-Programmierern bevorzugte Methode bedient sich der sogenannten "Channels". Diese an Unix-Pipes erinnernden Kommunikationskanäle transportieren Informationen von einem Programmteil zum anderen. Außerdem blocken sie den Programmfluss in einer Go-Routine falls sich mal zwischenzeitlich nichts in den Channel hineinstopfen oder herauslesen lässt und eignen sich deswegen hervorragend zur Synchronisation, da so einzelne Programmteile aufeinander warten können.

Kanalisierte Synchronisation

Listing 4 feuert auch wieder drei verschiedene Go-Routinen ab, gibt aber in diesen nichts direkt aus. Statt dessen 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 (z.B. "a") zum Channel zeigt, schickt die Daten in den Channel hinein. Je nach offener Kapazität des Channels passiert dies entweder sofort oder nachdem die Go-Runtime den Lauf der jeweiligen Go-Routine solange geblockt hat, bis der Kanal die Daten aufnehmen kann. Dabei, und das ist ganz wichtig, blockt immer nur eine Go-Routine, andere Go-Routinen im System 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 Go-Routinen fertig sind, in welcher Reihenfolge das passiert, hängt vom Zufall ab. Allerdings ist garantiert, dass in der Ausgabe immer drei Buchstaben stehen.

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 }

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 Leseoperator <- auf der linken (!) Seite der Channelvariable 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 aber nichts, denn wenn der Channel leer ist, blockt das Hauptprogramm einfach 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 Leseanweisung im Hauptprogramm, dass es weiter geht, und die For-Schleife geht in die nächste Runde.

Genau abgezählt

Jetzt wird auch klar, warum bei dieser einfachen Implementierung die For-Schleife in Zeile 13 genau wissen muss, wieviele Datenpakete insgesamt 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 mitsamt 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 gilt es allerdings einen typischen Anfängerfehler zu vermeiden ([5]): Der Aufruf go func(){}() einer anonym definierten Funktion als Go-Routine funktioniert als sogenannte Closure, hält also etwaige lokal definierte 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 perplexen Situation wieder, dass jede der Go-Routinen den gleichen Wert für url erhält, meist das letzte Element des Arrays 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 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 Arrays 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: Wenn Goroutinen die Requests gleichzeitig abfeuern, ist das Programm etwa dreimal so schnell.

Abbildung 3 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 effektiver, den Rechner während der Wartepausen beim Einholen der Web-Daten andersweitig zu beschäftigen, als herumsitzen und Daumen drehn zu lassen.

Zum Thema Nebenläufigkeit mit Go kann ich das Buch von Katherine Cox-Buday wärmstens empfehlen ([2]). Es hangelt sich mit akribischer Sorgfalt durch gutes und schlechtes Design mit Go-Channels, und zeigt nicht nur die gängigen Design-Patterns 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 Panikattacken des Programms.

Infos

[1]

Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2018/11/snapshot/

[2]

Katherine Cox-Buday, "Concurrency in Go", O'Reilly 2017

[3]

Michael Schilli, "Turm von Babylon": Linux-Magazin 06/2017, S.XX, <U>http://www.linux-magazin.de/Ausgaben/2017/06/Snapshot<U>

[4]

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

[5]

"Closure mistake with for loops", https://github.com/golang/go/wiki/CommonMistakes

Michael Schilli

arbeitet als Software-Engineer in der San Francisco Bay Area in Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen verschiedener Programmiersprachen. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.

POD ERRORS

Hey! The above document had some coding errors, which are explained below:

Around line 5:

Unknown directive: =desc