Massenhaft (Linux-Magazin, März 2021)

Gos Goroutinen sind so billig, dass Programmierer sie auch gerne im Dutzend abfeuern. Nur, wer räumt das Durcheinander am Ende wieder auf? Im Grunde bieten sich Go-Channels zur Kommunikation an, und da ein Hauptprogramm eventuell die Kontrolle über viele gleichzeitig laufende Goroutinen behalten muss, aber eine Nachricht in einem Channel immer nur bei einem Empfänger ankommt, verlassen sich die Kommunikationspartner in diesem Szenario auf einen Sonderfall.

Versuchen nämlich ein oder mehrere Empfänger in Go, aus einem Channel zu lesen, blockieren aber, weil dort nichts vorliegt, kann der Sender auf einen Schlag alle Empfänger benachrichtigen, indem er den Channel schließt. Das weckt alle Empfänger auf, ihre blockierenden Lesefunktionen kehren mit einem Fehlerwert zurück. Genau dieses Verfahren nutzt das Hauptprogramm, um noch laufende Unterprogramme zu stoppen: Es öffnet einen Channel, übergibt diesen an jedes aufgerufene Unterprogramm, worauf diese eine Leseklette daranhängen. Schließt das Hauptprogramm später den Kanal, lösen sich die Kletten und die Unterfunktionen tut das Vereinbarte, im häufigsten Fall Resourcen freigeben und sich beenden.

Reißleine bei Drei

Listing 1 zeigt als illustratives Beispiel ein Hauptprogramm, das dreimal hintereiner die Funktion work() aufruft, die zwar praktisch sofort wieder zurückkehrt, aber jeweils intern eine Goroutine abfeuert, die im Sekundentakt bis 10 zählt und den aktuellen Zählerwert jeweils auf der Standardausgabe druckt. Jede dieser Goroutinen liefe nun, auch nach Abschluss der sie aufrufenden Funktion, zehn Sekunden lang weiter, wäre da nicht das Hauptprogramm, das in Zeile 15 nach drei Sekunden mit close() die Reißleine zieht.

Listing 1: grtest.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "time"
    06 )
    07 
    08 func main() {
    09   done := make(chan interface{})
    10   work(done)
    11   work(done)
    12   work(done)
    13 
    14   time.Sleep(3 * time.Second)
    15   close(done)
    16   time.Sleep(3 * time.Second)
    17 }
    18 
    19 func work(done chan interface{}) {
    20   go func() {
    21     for i := 0; i < 10; i++ {
    22       fmt.Printf("%d\n", i)
    23 
    24       select {
    25       case <-done:
    26         fmt.Printf("Ok. I quit.\n")
    27         return
    28       case <-time.After(time.Second):
    29       }
    30     }
    31   }()
    32 }

Den zur Synchronisation genutzten Channel done erzeugt Zeile 9 in Listing 1. Den in ihm transportierte Datentyp legt interface{} als generisch an, da das Programm später gar keine Daten in den Channel schickt oder aus ihm ausliest, sondern nur die propagierte close-Anweisung auswertet.

Abbildung 1: Im Testprogramm von Listing 1 ertönt für alle drei Arbeiter nach drei Durchgängen die Werkssirene.

Wie genau bekommen nun zu diesem Zeitpunkt die Arbeiter das Ertönen der Werkssirene mit, während sie doch damit beschäftigt sind, ihren Job zu machen und Ergebnisse zusammenzutragen, oder wie im vorliegenden Fall, einfach nur geschäftig Zeit verstreichen lassen? Dieses "busy waiting" implementiert das select-Konstrukt ab Zeile 24 in Listing 1. Mit zwei verschiedenen case-Anweisungen wartet es gleichzeitig auf eines von zwei möglichen Ereignissen: Entweder versucht es mit <-done, Daten aus dem done-Channel zu lesen oder einen Fehler zu kassieren, falls main den Channel schließt. Oder aber der in Zeile 28 mit time.After() gestartete Timer läuft nach einer Sekunde ab, und beschert dem Select-Konstrukt einen Anlass, den entsprechenden case-Fall anzuspringen. In den ersten drei Sekunden des Beispielprogramms läuft jedes Mal nur der Timer ab, aber nach dem dritten Mal schnackelt's, denn das Hauptprogramm hat den Channel done geschlossen und das löst im ersten case in Zeile 25 einen Fehler aus, worauf der Arbeiter die Meldung "Ok. I quit" ausgibt und mit return die Goroutine verlässt. Abbildung 1 zeigt den Ablauf.

Jetzt mal in echt

Statt bis zehn zu zählen und zwischen den einzelnen Schritten immer eine Sekunde zu warten, würde eine work()-Funktion in der realen Welt zum Beispiel zeitverschlingende Aufgaben erledigen, wie eine Webseite übers Netzwerk einzuholen oder mit tailf-ähnlicher Technik Zuwächse in einer oder mehreren lokal überwachten Dateien feststellen. Doch auch in diesen Situationen muss ein Serverprogramm unter Umständen die Notbremse ziehen, sei es, dass der anfragende User die Geduld verloren hat oder die Datenaufbearbeitung im Backend dem Hauptprogramm einfach zu lange dauert und es sich anderen Requests zuwenden möchte.

Am Ende eines eigenständigen main-Programms wie in Listing 1 räumt zwar das Betriebssystem noch laufende Goroutinen ab und gibt die allozierten Resourcen wie Speicher oder File-Handles automatisch frei. Doch ein Serverprogramm darf sich nicht auf diesen Luxus verlassen, denn ein aus was für Gründen auch immer abgebrochener Request beendet nicht das Server-Programm. Und dieses muss unter Umständen noch Wochen weiterlaufen, ohne dass herumlungernde Funktionsleichen den verbrauchten Speicher mehr und mehr zumüllen, bis der Out-of-Memory-Killer einschreitet und den Gnadenschuss abfeuert.

Grenzbereiche

Funktionen, die sich untereinander über Channels Daten zuschicken, müssen übrigens zwei Grenzfälle vermeiden. Senden sie eine Nachricht an einen Kanal, der bereits wieder geschlossen wurde, geht das Go-Programm in den Panikmodus und bricht mit einem Fehler ab. Und liest eine Funktion aus einem Channel, in den niemand mehr etwas senden kann, weil alle Sender den Geist aufgegeben haben, hängt der Programmfluss für immer fest. Im vorliegenden Fall ist das allerdings einerlei, durch den verwendeten Channel fließen keine Daten, da das Programm nur die Tatsache ausnutzt, dass das Lesen aus einem geschlossenen Kanal einen Fehler erzeugt.

Einfacher mit Kontext

Um dem Programmierer solcher doch recht gängiger Funktionen die Arbeit zu erleichtern, bietet die Go-Standard-Bibliothek Objekte vom Typ Context. Sie kommen in den Google-Rechenzentren bei Servern zum Einsatz, die für hereinkommende User-Anfragen oft viele Goroutinen aufrufen müssen, die das Ergebnis zusammentragen. Dauert das zu lange, muss die Hauptfunktion, die den Request bearbeitet, die Möglichkeit haben, alle noch laufenden Goroutinen zu kontaktieren, sie zum sofortigen Aufgeben ihrer angefangenen Arbeiten veranlassen, eventuell belegte Resourcen freizugeben, und den Programmfluss einzustellen.

Das Interface eines Contexts liefert deshalb mit Done() einen offenen Channel zurück, aus dem die Arbeiterbienen zu lesen versuchen. Allerdings kommt im Channel (wie schon im vorherigen Beispiel) niemals eine Nachricht an. Vielmehr schließt das Hauptprogramm zum Abpfiff mittels der dem Context eigenen cancel()-Funktion intern den Channel mit close(), was den lesenden Bienchen plötzlich einen Fehlerwert beschert, den sie abfangen und dies als Signal verstehen, den Laden dicht zu machen.

Listing 2: context.go

    01 package main
    02 
    03 import (
    04   "context"
    05   "fmt"
    06   "time"
    07 )
    08 
    09 func main() {
    10   ctx, cancel := context.WithCancel(context.Background())
    11 
    12   work(ctx)
    13   work(ctx)
    14   work(ctx)
    15 
    16   time.Sleep(3 * time.Second)
    17   cancel()
    18   time.Sleep(3 * time.Second)
    19 }
    20 
    21 func work(ctx context.Context) {
    22   go func() {
    23     for i := 0; i < 10; i++ {
    24       fmt.Printf("%d\n", i)
    25 
    26       select {
    27       case <-ctx.Done():
    28         fmt.Printf("Ok. I quit.\n")
    29         return
    30       case <-time.After(time.Second):
    31       }
    32     }
    33   }()
    34 }

Listing 2 holt in Zeile 4 mit "context" das gleichnamige Paket der Standardbibliothek herein und die Funktion context.WithCancel() setzt auf einem mit context.Background() erzeugten Standard-Context auf und gibt zwei Dinge zurück: Ein Context-Objekt in ctx und eine cancel()-Funktion, die der Programmierer später (im Beispiel in Zeile 17) aufruft, um das Signal zum Ende der Party und dem allgemeinen Aufbruch zu senden. Arbeiterbienen extrahieren aus dem Context mit ctx.Done() den zu überwachenden Kanal und fügen eine case-Anweisung mit einer Leseanweisung darauf in ihre select-Schleifen ein, mit denen sie ihre Kommunikation mit Subsystemen arrangieren. Die Ausgabe des compilierten Listings 2 sieht genauso aus wie in Abbildung 1 gezeigt, es zeigt exakt das gleiche Verhalten, und das ist kein Wunder, denn die Context-Implementierung nutzt die gleiche interne Infrastruktur.

Im Gehirn von Google

In Googles Rechenzentren führen alle Arbeiterfunktionen als ersten Parameter eine Context-Variable, die einen eventuell notwendigen vorzeitigen Abbruch kontrolliert. Sie hilft aber auch dabei, Nutzdaten eingegangener Requests nach unten zu reichen, wie den Namen des authentifzierten Users oder Credentials für Subsysteme. So unterstützen alle Subsysteme, über alle API-Grenzen hinweg, bestimmte Standardfunktionen wie Timeouts, Aufräumsignale wegen unlösbarer Probleme oder auch einfach bequemen Zugriff auf globale Key/Value-Werte.

Wer bremst verliert

Wie so eine Serverfunktion im Prinzip aussehen könnte, zeigt Listing 3. Sie holt vier verschiedene URLs ein, die Startseiten von Google, Facebook, Amazon, sowie die künstlich gebremste Website deelay.me. Zeile 23 zeigt, dass sie die AOL-Website mit einer Verzögerung von 5000 Millisekunden (also fünf Sekunden) abruft, und dem Client so eine lahme Internetverbindung vorgaukelt.

Listing 3: delay.go

    01 package main
    02 
    03 import (
    04   "context"
    05   "fmt"
    06   "net/http"
    07   "time"
    08 )
    09 
    10 type Resp struct {
    11   rcode int
    12   url   string
    13 }
    14 
    15 func main() {
    16   ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    17   defer cancel()
    18 
    19   urls := []string{
    20     "https://www.google.com",
    21     "https://www.facebook.com",
    22     "https://www.amazon.com",
    23     "https://deelay.me/5000/www.aol.com",
    24   }
    25 
    26   results := make(chan Resp)
    27 
    28   for _, url := range urls {
    29     chkurl(ctx, url, results)
    30   }
    31 
    32   for _ = range urls {
    33     resp := <-results
    34     fmt.Printf("Received: %d %s\n", resp.rcode, resp.url)
    35   }
    36 }
    37 
    38 func chkurl(ctx context.Context, url string, results chan Resp) {
    39   fmt.Printf("Fetching %s\n", url)
    40   httpch := make(chan int)
    41 
    42   go func() {
    43     // async url fetch
    44     go func() {
    45       resp, err := http.Get(url)
    46       if err != nil {
    47         httpch <- 500
    48       } else {
    49         httpch <- resp.StatusCode
    50       }
    51     }()
    52 
    53     select {
    54     case result := <-httpch:
    55       results <- Resp{
    56         rcode: result, url: url}
    57     case <-ctx.Done():
    58       fmt.Printf("Timeout!!\n")
    59       results <- Resp{
    60         rcode: 501, url: url}
    61     }
    62   }()
    63 }

Das Hauptprogramm main rangiert nun in der For-Schleife ab Zeile 28 durch diese URLs und übergibt jede der Funktion chkurl(), zusammen mit einer Context-Variablen und einem Kanal results, der Ergebnisse der Arbeiter in Form von Resp-Strukturen zurück zum Hauptprogramm liefert. Dieser ab Zeile 10 definierte Datentyp speichert die eingeholte URL sowie den HTTP-Returncode des Requests. Dabei arbeitet chkurl() die Anfragen asynchron ab, startet ab Zeile 42 eine Goroutine zum zeitaufwendigen Einholen übers Netz, und kehrt deswegen flugs wieder zum Hauptprogramm zurück. Ergebnisse blubbern später über den results-Channel hoch, wo die for-Schleife ab Zeile 32 die Ergebnisse einsammelt und die URLs samt ihren numerischen Ergebnis-Codes ausgibt.

Abbildung 2: Ergebnisse der drei ersten Webseiten kommen schnell, das vierte zu langsam und der Context löst den Timeout aus.

Trödeln wird bestraft

Damit der Worker chkurl() nicht zulange herumtrödelt, setzt Zeile 16 einen Context mit Timeout, den sie in der Variablen ctx an die Arbeitsbiene weiterreicht. Diese holt in einer inneren Goroutine (ab Zeile 44) innerhalb einer äußeren Goroutine mit dem Standardpaket net/http die Webseite vom Netz und schiebt den vom Webserver gelieferten Status-Code in den lokalen Channel httpch. Damit hängt die innere Goroutine fest, bis jemand anders am anderen Ende des Channels httpch zu lesen anfängt. Dies passiert auch im nachfolgenden Select-Konstrukt ab Zeile 53, das bei einem von zwei Ereignissen anspringt: Entweder kommt aus dem Channel httpch eine Antwort des Web-Requests, oder aber das Hauptprogramm hat mittlerweile die Geduld verloren und ctx.Done() kommt mit einem Fehler zurück. Im letzteren Fall gibt die Funktion "Timeout!!" aus und setzt den HTTP-Code auf 501. Im Gutfall schiebt Zeile 56 den Code und den zugehörigen URL in den Ergebnis-Channel results. Abbildung 2 zeigt den Ablauf des kompilierten Binaries, erst kommen die ersten drei Anfragen relativ schnell zurück, dann, nach einer Pause, schießt das Hauptprogramm die geschlagene fünf Sekunden trödelnde Unterfunktion ab, und der Status-Code kommt in der Tat wie in Zeile 60 vorgegeben, als 501 zum Vorschein.

Die Kombination aus zwei verschachtelten Goroutinen in chkurl() ist übrigens erforderlich, weil das Senden von Daten in einen Channel und die Extraktion am anderen Ende synchronisiert erfolgen müssen. Wenn ein Programm einfach erst in den Channel sendet, um anschließend daraus zu lesen, funktioniert das nicht, da der Sendeauftrag ewig hängt, wenn noch kein Empfänger lauscht. Damit das Ganze in Listing 3 denn auch flutscht, schickt der Sender seine Werte in einer asynchron laufenden Goroutine in den Channel, und der Empfänger kann sich hinterher in aller Ruhe andocken und sobald er dies tut, kommen die Daten durch den Kanal angewanzt.

Präzision statt Sekundenschlaf

Listing 3 verlässt sich übrigens nicht wie die vorherigen Programme auf unzuverlässige Sleep-Kommandos zur Synchronisation beim Einsammeln der von den Arbeiterbienen erzeugten Daten. Trudeln die Daten ungewöhnlich langsam ein, was auf dem Internet ohne weiteres möglich ist, kann es sein, dass das Programm sich schon beendet, bevor die letzte Goroutine ihr Ergebnis über den Kanal hochgeschickt hat. Listing 3 nutzt deshalb in den Zeilen 28 und 32 zwei for-Schleifen, die jeweils bis drei zählen. Einmal, um dreimal chkurl() aufzurufen, und später, um dreimal Ergebnisse über den Rückgabekanal einzusammeln. Die Reihenfolge von Anfragen und Ergebnissen wird so zwar eventuell durcheinandergewürfelt, aber der Programmlauf garantiert, dass alle Ergebnisse komplett vorliegen und nicht etwa eines außen vor bleibt.

Nach diesem Muster stellen Go-Programme Code bereit, dessen Bestandteile theoretisch gleichzeitig laufen könnten. Richtig parallel läuft der Code natürlich nur, falls die Plattform dies unterstützt, durch einen Prozessor mit mehreren Cores, zum Beispiel. Der Unterschied zwischen "Concurrency" und "Parallelism" ist also durchaus relevant, wie Go-Guru Rob Pike in einem sehenswerten Video ([3]) eindrucksvoll erklärt.

Infos

[1]

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

[2]

"Go Context", https://blog.golang.org/context

[3]

"Concurrency is not parallelism", Vortrag von Rob Pike, 2013, https://blog.golang.org/waza-talk

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