Schlimme Raser (Linux-Magazin, August 2021)

Passen Programmierer nicht auf wie die Haftelmacher, kommen sich parallel laufende Programmteile, ob als Prozess, als Thread, oder als Goroutine, laufend in die Quere. Wer es dem Zufall überlässt, in welcher Reihenfolge Systemkomponenten Daten lesen oder modifizieren, führt Zeitbomben in den Code ein, die früher oder später detonieren und schwer zu analysierende Laufzeitfehler hinterlassen.

Oft wird angenommen, dass Komponenten, die ein Programm eine nach der anderen aufruft, auch in der gleichen Reihenfolge zum Einsatz kommen, aber das ist eine Illusion, die sich leicht an einem Beispiel widerlegen lässt. Dabei spielt allerdings auch noch der Zufall mit, und so kann es durchaus sein, dass etwas einmal funktioniert, aber dann nach einer kleinen, oft auch davon unabhängigen Änderung im Code abstürzt. Auch die Last auf dem verwendeten System kann hineinspielen, dann funktioniert etwas bei schleichendem Betrieb vielleicht tadellos, fällt aber bei hoher Last unerwartet auseinander.

Abbildung 1: Unsynchronisierte Goroutinen laufen in eigenwilliger Reihenfolge ab.

Dass unsynchronisierte Goroutinen nicht in der Reihenfolge ihrer Definition ablaufen, auch wenn sie das Programm eine nach der anderen abfeuert, illustiert Listing 1 und die Ausgaben im oberen Teil von Abbildung 1. Die for-Schleife startet zwar entsprechend der Indexnummern in i erst Goroutine 0, dann 1, dann 2, und so weiter, aber der obere Teil von Abbildung 1 macht anhand der Ausgabe des compilierten Programms klar, dass Chaos herrscht und die Goroutinen ihre Meldungen wild durcheinander in die Ausgabe schreiben.

Jede der in der for-Schleife erzeugten 10 Goroutinen gibt den aktuellen Schleifenindex ganz nach Lehrbuch als Parameter an die jeweilige Goroutine weiter, damit sich nicht alle die gleiche Variable teilen. Damit das Programm nach der for-Schleife nicht gleich abbricht, sondern wartet, bis alle Goroutinen sich beendet haben, schickt jede von ihnen am Ende ihres Arbeitslebens eine Nachricht in den Channel done, und die abschließende for-Schleife ab Zeile 19 sammelt die Meldungen von dort auch wieder ein, und bricht erst ab, wenn auch die letzte Goroutine ihren Abschied verkündet hat.

Listing 1: orderfail.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05 )
    06 
    07 func main() {
    08   done := make(chan bool)
    09 
    10   n := 10
    11 
    12   for i := 0; i < n; i++ {
    13     go func(id int) {
    14       fmt.Printf("goroutine %d\n", id)
    15       done <- true
    16     }(i)
    17   }
    18 
    19   for i := 0; i < n; i++ {
    20     <-done
    21   }
    22 }

Immer der Reihe nach

Wer aber nun wirklich will, dass zuerst Goroutine 0 anläuft, dann Goroutine 1, und so weiter, muss einen Mechanismus zur Synchronisation wie Channels oder Mutex-Konstrukte einsetzen, damit Gos Runtime die eincodierte Reihenfolge auch einhält und zur Laufzeit nicht wieder Chaos herrscht. Listing 2 macht dies mit 10 Channels, und die Goroutinen warten jeweils kurz nach ihrem Start, bis auf dem ihnen zugewiesenen Channel eine Nachricht ankommt. Bis das passiert, blockiert jede Goroutine in der Leseanweisung aus dem Channel-Array starters in Zeile 19. Die mit der ersten Goroutine startende Ereigniskette tritt dann Zeile 29 nach der for-Schleife los, indem sie in den ersten Channel einen Wert hineinschreibt.

Listing 2: orderok.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05 )
    06 
    07 func main() {
    08   done := make(chan bool)
    09 
    10   n := 10
    11 
    12   starters := make([](chan bool), n)
    13   for i := 0; i < n; i++ {
    14     starters[i] = make(chan bool)
    15   }
    16 
    17   for i := 0; i < n; i++ {
    18     go func(id int) {
    19       <-starters[id]
    20       fmt.Printf("Running %d\n", id)
    21       if id < n-1 {
    22         starters[id+1] <- true
    23       }
    24       // DO WORK
    25       done <- true
    26     }(i)
    27   }
    28 
    29   starters[0] <- true
    30 
    31   for i := 0; i < n; i++ {
    32     <-done
    33   }
    34 }

Dies wiederum setzt die Goroutine mit der id 0 frei, deren blockierende Leseanweisung in Zeile 19 nun freischaltet, worauf sie ihre Meldung ausgibt und, damit die Welle weiterrollt, den Channel mit der id+1 (also 1) beschreibt. Dies wiederum tritt Goroutine 1 los, die wiederum Goroutine 2 lostritt, sodass der Reigen sich nun kontrolliert fortsetzt, bis Goroutine 9 den Abschluss des Programms einleitet.

Das Verfahren reduziert natürlich die Nebenläufigkeit aller Goroutinen, die nun nicht alle quasi-gleich anlaufen, sondern aufeinander warten, aber nur solange, wie die einzelne Goroutine braucht, um im Channel die nächste anzustoßen. Was danach innerhalb der einzelnen Goroutinen passiert, in Listing 2 mit dem Platzhalter "DO WORK" kommentiert, geschieht wieder quasi-gleichzeitig.

Nur einer kann gewinnen

Welche verheerenden Folgen Race-Conditions in einer Applikation auslösen können, zeigt das Buchungsprogramm einer Fluggesellschaft in Listing 3. Es stellt in Zeile 14 fest, dass in der von zwei verschiedenen Goroutinen geteilten Variablen seats im Flugzeug noch ein Platz frei ist, gibt an den User eine Erfolgsmeldung aus und setzt die Anzahl der verbleibenden Sitze auf Null. Allerdings streiten sich zwei parallele Goroutinen in einer for-Schleife ab Zeile 11 um die Buchung, und während die eine sich glücklich wähnt, und schon die Erfolgsmeldung ausgibt, tested die zweite ebenfalls die Variable seats, die immer noch auf Eins steht, und macht sich daran, den Sitzplatz ebenfalls zu buchen. Ein überbuchtes Flugzeug ist die Folge, und verärgerte Passagiere, tumultartige Zustände am Terminal!

Listing 3: airline.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "time"
    06 )
    07 
    08 func main() {
    09   seats := 1
    10 
    11   for i := 0; i < 2; i++ {
    12     go func(id int) {
    13       time.Sleep(100 * time.Millisecond)
    14       if seats > 0 {
    15         fmt.Printf("%d booked!\n", id)
    16         seats = 0
    17       } else {
    18         fmt.Printf("%d missed out.\n", id)
    19       }
    20     }(i)
    21   }
    22 
    23   time.Sleep(1 * time.Second)
    24   fmt.Println("")
    25 }

Die Ausgabe im oberen Teil von Abbildung 2 zeigt, dass Listing 3 tatsächlich (und abhängig von der Länge der in Zeile 13 eingefügten den eigentlichen Buchungsprozess simulierenden Sekundenschlaf-Anweisung) wiederholt Doppelbuchungen zulässt. So geht's nicht.

Abbildung 2: Ohne Synchronisation buchen zwei Goroutinen schon mal denselben Sitz im Flugzeug.

Das Problem ist offensichtlich dass zwei gleichzeitig laufende Programmstränge sich die Variable seats teilen, und die Zeit, die zwischen der Prüfung seats > 0 in Zeile 14 und dem Zurücksetzen der Variable mit seats = 0 in Zeile 16 verstreicht. Wenn der zweite Programmstrang in der Zwischenzeit ebenfalls eine Prüfung vornimmt, wähnt er sich, weil seats immer noch auf Eins steht, ebenfalls im Besitz eines freien Sitzes, und damit ist der Buchungsfehler vorprogrammiert. Die Lösung des Problems ist offensichtlich das gleichzeitige Prüfen und Setzen der Variablen in entweder einer einzigen atomaren Anweisung vorzunehmen, oder den Programmbereich mit beiden Anweisungen als kritische Sektion zu deklarieren, die andere Goroutinen aussperrt, solange eine Goroutine sich darin aufhält.

Listing 4: airline-ok.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "time"
    06 )
    07 
    08 func main() {
    09   seats := 1
    10   booking := make(chan bool, 1)
    11 
    12   for i := 0; i < 2; i++ {
    13     go func(id int) {
    14       time.Sleep(100 * time.Millisecond)
    15       booking <- true
    16       if seats > 0 {
    17         fmt.Printf("%d booked!\n", id)
    18         seats = 0
    19       } else {
    20         fmt.Printf("%d missed out.\n", id)
    21       }
    22       <-booking
    23     }(i)
    24   }
    25 
    26   time.Sleep(1 * time.Second)
    27   fmt.Println("")
    28 }

Listing 4 zeigt eine mögliche Lösung des Problems mit dem gepufferten Channel booking der Tiefe 1, wie ihn die make-Anweisung in Zeile 10 anlegt. Dank des Puffers kann je eine Goroutine einen Wert in den Channel hineinschreiben, ohne dass der gleich blockiert ([2]). Kommt aber die nächste Goroutine an und will ebenfalls einen Wert senden, blockiert der Channel solange, bis irgendwer den ersten Wert wieder herausliest, was am Ende der kritischen Sektion in Zeile 22 passiert. So durchquert immer nur eine Goroutine gleichzeitig die kritische Sektion, und es ist egal, wie lange das Prüfen oder setzen der Variablen seats dauert, da in den Pausen niemand dazwischenfunkt. Der untere Teil von Abbildung 2 zeigt dann auch, dass jeweils nur eine Goroutine die Buchung vornimmt, während die andere zur Enttäuschung des buchwilligen Passagiers meldet, dass keine Sitze mehr zur Verfügung stehen. So ist's richtig.

Abbildung 3: Das mit der Option -race compilierte Programm bemerkt Race-Conditions zur Laufzeit.

Raser melden

Bei der Entwicklung hilft Go, solche Race-Conditions zu erkennen, wenn der Source-Code mit der Option -race übersetzt wurde. Dann merkt die Go-Runtime zur Laufzeit, wenn sich zwei Go-Routinen um eine Variable reißen und gibt eine entsprechende Fehlermeldung aus (Abbilding 3). Dies setzt allerdings voraus, dass sich das Programm während des Testlaufs in den Teilbereich begibt, der das Problem auslöst, deshalb ist es wichtig, dass die zugehörige Testsuite eine möglichst komplette Code-Abdeckung erreicht.

Klassische Informatik

Die klassische Methode, Quertreiber auszubremsen, nutzt sogenannte Mutex-("mutually exclusive")-Konstrukte. Gos sync-Paket stellt Mutex-Elemente bereit, die mit Lock() eine Straßensperre für nachfolgende Goroutinen errichten, und diese nach Abschluss der kritischen Region mit Unlock() wieder abräumen.

Listing 5: airline-mutex.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "sync"
    06   "time"
    07 )
    08 
    09 func main() {
    10   seats := 1
    11   mutex := &sync.Mutex{}
    12 
    13   for i := 0; i < 2; i++ {
    14     go func(id int) {
    15       time.Sleep(100 * time.Millisecond)
    16       mutex.Lock()
    17       book(id, &seats)
    18       mutex.Unlock()
    19     }(i)
    20   }
    21 
    22   time.Sleep(1 * time.Second)
    23   fmt.Println("")
    24 }
    25 
    26 func book(id int, seats *int) {
    27   if *seats > 0 {
    28     fmt.Printf("%d booked!\n", id)
    29     *seats = 0
    30   } else {
    31     fmt.Printf("%d missed out.\n", id)
    32   }
    33 }

Listing 5 zeigt die Implementierung der Buchungsfunktion book(), deren Aufruf dort in Zeile 17 von einem Mutex-Paar umrankt steht: Vor dem Aufruf riegelt Zeile 16 mit mutex.Lock() den Bereich für nachfolgende Goroutinen ab, und nach getaner Arbeit öffnet Zeile 18 mit mutex.Unlock() die Schleuse wieder.

Der Buchungsvorgang ist nun der Übersichtlichkeit halber in der Funktion book() ab Zeile 26 verkapselt, die als Eingabe die ID der Goroutine und einen Pointer auf die Variable seats entgegennimmt, deren Wert sie bei einer erfolgreichen Buchung entsprechend anpasst.

Höhenverstellbare Schranke

Eine Alternative zum Mutex-Konstrukt bietet das Konstrukt sync.WaitGroup, das ebenfalls aus dem sync-Paket der Go beiliegenden Core-Library stammt. Seine Add()-Funktion errichtet eine Sperre mit einstellbarer Höhe, auf deren Abbau ein anschließendes Wait() wartet. Die Funktion Done() räumt die Sperre ab, der Clou des Ganzen ist, dass mehrere Programmteile durch nachfolgende Aufrufe von Add() beliebig viele Sperren aufbauen können, die dann ebensoviele Aufrufe von Done() wieder abbauen müssen, bevor Wait() wieder Durchlass gewährt.

Listing 6: airline-wg.go

    01 package main
    02 
    03 import (
    04   "fmt"
    05   "sync"
    06   "time"
    07 )
    08 
    09 func main() {
    10   seats := 1
    11   wg := &sync.WaitGroup{}
    12 
    13   for i := 0; i < 2; i++ {
    14     go func(id int) {
    15       time.Sleep(100 * time.Millisecond)
    16       wg.Wait()
    17       wg.Add(1)
    18       book(id, &seats)
    19       wg.Done()
    20     }(i)
    21   }
    22 
    23   time.Sleep(1 * time.Second)
    24   fmt.Println("")
    25 }
    26 
    27 func book(id int, seats *int) {
    28   if *seats > 0 {
    29     fmt.Printf("%d booked!\n", id)
    30     *seats = 0
    31   } else {
    32     fmt.Printf("%d missed out.\n", id)
    33   }
    34 }

Listing 6 wartet dazu vor dem Beginn des kritischen Bereichs mit einer prophylaktischen Wait()-Anweisung in Zeile 16, fährt aber sogleich fort, denn im Originalzustand stehen keine Sperren im Weg. Zeile 17 baut kurz vor der Buchung dann mit Add(1) ein Hindernis ein, und anschließend kann book() in Zeile 18 in aller Ruhe und ungestört die Buchung vornehmen, bevor Zeile 19 mit Done() die Sperre wieder löst.

Wie immer gibt es auch beim Zügeln heranstürmender Goroutinen mehrere Lösungsvarianten, schließlich handelt es ich um allseits bekannte Programmierprobleme. Lassen sich zwei zusammengehörige Anweisungen wie das Prüfen und Setzen einer geteilten Variablen nicht atomar erledigen, muss ein Synchronisations-Tool ran, um das kritische Zeitfenster zwischen den Anweisungen gegenüber hereindrängenden Programmsträngen temporär abzuriegeln. Wichtig ist auch, das spätere Abräumen nicht zu vergessen, auch wenn die Buchung aus irgenwelchen Gründen fehlschlägt. Wer das vergisst, baut ewige Sperren ins Programm, die auch noch Resourcen fressen, also aufgepasst!

Infos

[1]

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

[2]

Michael Schilli, "Let's Go": Linux-Magazin 07/21, S.XXX, <U>https://www.linux-magazin.de/ausgaben/2021/07/snapshot/<U>

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