Aus Linux-Magazin 08/2021

Race Conditions aufspüren mit Go

© Scott Betts / 123RF.com

Ziehen sich parallel laufende Programmteile gegenseitig den Teppich unter den Füßen weg, liegt das oft an Race Conditions. Mike Schilli zeigt, wie der Go-Compiler sie aufspürt und wie sie sich vermeiden lassen.

Passen Programmierer nicht auf, kommen sich parallel laufende Programmteile ständig in die Quere, ob als Prozess, als Thread oder als Goroutine. 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.

Die verbreitete Annahme, dass Komponenten, die ein Programm eine nach der anderen aufruft, auch in derselben Reihenfolge zum Einsatz kommen, ist eine Illusion, die sich leicht mit einem Beispiel widerlegen lässt. Dabei spielt allerdings auch noch der Zufall mit. 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.

Dass unsynchronisierte Goroutinen nicht in der Reihenfolge ihrer Definition ablaufen, auch wenn das Programm sie eine nach der anderen startet, illustrieren das 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 kompilierten Programms klar, dass Chaos herrscht und die Goroutinen ihre Meldungen wild durcheinander in die Ausgabe schreiben.

Listing 1

orderfail.go

package main
import (
  "fmt"
)
func main() {
  done := make(chan bool)
  n := 10
  for i := 0; i < n; i++ {
    go func(id int) {
      fmt.Printf("goroutine %d\n", id)
      done <- true
    }(i)
  }
  for i := 0; i < n; i++ {
    <-done
  }
}
Abbildung 1: Unsynchronisierte Goroutinen laufen in eigenwilliger Reihenfolge ab.

Abbildung 1: Unsynchronisierte Goroutinen laufen in eigenwilliger Reihenfolge ab.

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

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 einsetzen, wie Channels oder Mutex-Konstrukte, damit Gos Runtime die gewünschte Reihenfolge auch einhält und zur Laufzeit nicht wieder Chaos herrscht.

Listing 2 exerziert das mit zehn Channels durch. Die Goroutinen warten jeweils kurz nach ihrem Start, bis auf dem ihnen zugewiesenen Channel eine Nachricht ankommt. So lange blockiert jede Goroutine in der Leseanweisung aus dem Channel-Array »starters« in Zeile 17. Die mit der ersten Goroutine startende Ereigniskette tritt dann Zeile 27 nach der For-Schleife los, indem sie in den ersten Channel einen Wert hineinschreibt.

Listing 2

orderok.go

package main
import (
  "fmt"
)
func main() {
  done := make(chan bool)
  n := 10
  starters := make([](chan bool), n)
  for i := 0; i < n; i++ {
    starters[i] = make(chan bool)
  }
  for i := 0; i < n; i++ {
    go func(id int) {
      <-starters[id]
      fmt.Printf("Running %d\n", id)
      if id < n-1 {
        starters[id+1] <- true
      }
      // [... DO WORK ...]
      done <- true
    }(i)
  }
  starters[0] <- true
  for i := 0; i < n; i++ {
    <-done
  }
}

Das setzt die Goroutine mit der »id« 0 frei, deren blockierende Leseanweisung in Zeile 17 nun freischaltet. Daraufhin gibt sie ihre Meldung aus und schreibt, damit die Welle weiterrollt, in den Channel mit der »id+1« (also 1). Dies tritt wiederum Goroutine 1 los, die ihrerseits Goroutine 2 auslöst. Der Reigen setzt sich kontrolliert fort, bis Goroutine 9 den Abschluss des Programms einleitet.

Das Verfahren reduziert naturgemäß die Nebenläufigkeit aller Goroutinen, die nun nicht alle quasi-gleich anlaufen, sondern aufeinander warten – aber nur so lange, wie die einzelne Goroutine braucht, um im Channel die nächste anzustoßen. Was danach innerhalb der einzelnen Goroutinen passiert (in Zeile 22 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 13 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 10 um die Buchung. Während die eine sich glücklich wähnt und schon die Erfolgsmeldung ausgibt, testet die zweite ebenfalls die Variable »seats«, die immer noch auf 1 steht, und macht sich daran, den Sitzplatz ebenfalls zu buchen. Die Folge: Ein überbuchtes Flugzeug und verärgerte Passagiere.

Listing 3

airline.go

package main
import (
  "fmt"
  "time"
)
func main() {
  seats := 1
  for i := 0; i < 2; i++ {
    go func(id int) {
      time.Sleep(100 * time.Millisecond)
      if seats > 0 {
        fmt.Printf("%d booked!\n", id)
        seats = 0
      } else {
        fmt.Printf("%d missed out.\n", id)
      }
    }(i)
  }
  time.Sleep(1 * time.Second)
  fmt.Println("")
}

Die Ausgabe im oberen Teil von Abbildung 2 zeigt, dass Listing 3 tatsächlich wiederholt Doppelbuchungen zulässt – und das abhängig von der Länge der in Zeile 12 eingefügten, den eigentlichen Buchungsprozess simulierenden Sekundenschlafanweisung. So geht es nicht.

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

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

Das offensichtliche Problem: Zwei gleichzeitig laufende Programmstränge teilen sich die Variable »seats« während der Zeit, die zwischen der Prüfung »seats > 0« in Zeile 13 und dem Zurücksetzen der Variable mit »seats = 0« in Zeile 15 verstreicht. Wenn der zweite Programmstrang eine Prüfung vornimmt, während der erste den Platz gerade bucht, wähnt er sich ebenfalls im Besitz eines freien Sitzes, weil »seats« immer noch auf 1 steht. Damit ist der Buchungsfehler programmiert.

Das Problem lässt sich lösen, indem man das gleichzeitige Prüfen und Setzen der Variable entweder in einer einzigen atomaren Anweisung vornimmt oder den Programmbereich mit beiden Anweisungen als kritische Sektion deklariert, die andere Goroutinen aussperrt, solange eine Goroutine sich darin aufhält.

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 9 anlegt. Dank des Puffers kann je eine Goroutine einen Wert in den Channel hineinschreiben, ohne dass der gleich blockiert [1]. Will die nächste Goroutine ebenfalls einen Wert senden, blockiert der Channel aber so lange, bis eine Anweisung den ersten Wert wieder herausliest, was am Ende der kritischen Sektion in Zeile 21 passiert.

So durchquert immer nur eine Goroutine gleichzeitig die kritische Sektion und es spielt keine Rolle, 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 buchungswilligen Passagiers meldet, dass keine Sitze mehr zur Verfügung stehen. So soll’s sein.

Listing 4

airline-ok.go

package main
import (
  "fmt"
  "time"
)
func main() {
  seats := 1
  booking := make(chan bool, 1)
  for i := 0; i < 2; i++ {
    go func(id int) {
      time.Sleep(100 * time.Millisecond)
      booking <- true
      if seats > 0 {
        fmt.Printf("%d booked!\n", id)
        seats = 0
      } else {
        fmt.Printf("%d missed out.\n", id)
      }
      <-booking
    }(i)
  }
  time.Sleep(1 * time.Second)
  fmt.Println("")
}

Raser melden

Bei der Entwicklung hilft Go, solche Race Conditions zu erkennen, wenn Sie den Quellcode mit der Option »-race« übersetzen. Dann merkt die Go-Runtime zur Laufzeit, wenn sich zwei Goroutinen um eine Variable reißen, und gibt eine entsprechende Fehlermeldung aus (Abbildung 3). Das 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 Codeabdeckung erzielt.

Abbildung 3: Das mit der Option &raquo;-race&laquo; kompilierte Programm bemerkt Race Conditions zur Laufzeit.

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

Klassische Informatik

Die klassische Methode Quertreiber auszubremsen, nutzt sogenannte Mutex-Konstrukte (mutually exclusive). 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 zeigt die Implementierung der Buchungsfunktion »book()«, deren Aufruf dort in Zeile 16 von einem Mutex-Paar umrankt steht. Vor dem Aufruf riegelt Zeile 15 mit »mutex.Lock()« den Bereich für nachfolgende Goroutinen ab, nach getaner Arbeit öffnet Zeile 17 mit »mutex.Unlock()« die Schleuse wieder.

Der Übersichtlichkeit halber kapselt nun die Funktion »book()« ab Zeile 25 den Buchungsvorgang. Als Eingabe nimmt sie die ID der Goroutine sowie einen Pointer auf die Variable »seats« entgegen, deren Wert sie bei einer erfolgreichen Buchung entsprechend anpasst.

Listing 5

airline-mutex.go

package main
import (
  "fmt"
  "sync"
  "time"
)
func main() {
  seats := 1
  mutex := &sync.Mutex{}
  for i := 0; i < 2; i++ {
    go func(id int) {
      time.Sleep(100 * time.Millisecond)
      mutex.Lock()
      book(id, &seats)
      mutex.Unlock()
    }(i)
  }
  time.Sleep(1 * time.Second)
  fmt.Println("")
}
func book(id int, seats *int) {
  if *seats > 0 {
    fmt.Printf("%d booked!\n", id)
    *seats = 0
  } else {
    fmt.Printf("%d missed out.\n", id)
  }
}

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: Mehrere Programmteile können durch nachfolgende Aufrufe von »Add()« beliebig viele Sperren aufbauen, die dann ebenso viele Aufrufe von »Done()« wieder abbauen müssen, bevor »Wait()« wieder Durchlass gewährt.

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

Listing 6

airline-wg.go

package main
import (
  "fmt"
  "sync"
  "time"
)
func main() {
  seats := 1
  wg := &sync.WaitGroup{}
  for i := 0; i < 2; i++ {
    go func(id int) {
      time.Sleep(100 * time.Millisecond)
      wg.Wait()
      wg.Add(1)
      book(id, &seats)
      wg.Done()
    }(i)
  }
  time.Sleep(1 * time.Second)
  fmt.Println("")
}
func book(id int, seats *int) {
  if *seats > 0 {
    fmt.Printf("%d booked!\n", id)
    *seats = 0
  } else {
    fmt.Printf("%d missed out.\n", id)
  }
}

Fazit

Wie immer in Go gibt es auch beim Zügeln heranstürmender Goroutinen mehrere Lösungsvarianten – schließlich handelt es sich um allseits bekannte Programmierprobleme.

Lassen sich zwei zusammengehörige Anweisungen wie das Prüfen und Setzen einer geteilten Variablen nicht atomar erledigen, muss ein Synchronisationswerkzeug ran, um das kritische Zeitfenster zwischen den Anweisungen gegen hereindrängende Programmsträngen temporär abzuriegeln.

Auf keinen Fall darf man das spätere Abräumen vergessen, auch wenn die Buchung aus irgendwelchen Gründen fehlschlägt. Ansonsten baut man ewige Sperren ins Programm, die auch noch Ressourcen fressen. ((uba)/jlu)

Infos

  1. Go: Mike Schilli, “Let’s Go”, LM 07/2021, S. 20, https://www.lm-online.de/46332
  2. Listings zu diesem Artikel: http://www.linux-magazin.de/static/listings/magazin/2021/08/snapshot/
DIESEN ARTIKEL ALS PDF KAUFEN
EXPRESS-KAUF ALS PDFUmfang: 5 HeftseitenPreis €0,99
(inkl. 19% MwSt.)
LINUX-MAGAZIN KAUFEN
EINZELNE AUSGABE Print-Ausgaben Digitale Ausgaben
ABONNEMENTS Print-Abos Digitales Abo
TABLET & SMARTPHONE APPS Readly Logo
E-Mail Benachrichtigung
Benachrichtige mich zu:
0 Kommentare
Älteste
Neuste Beste Bewertung
Inline Feedbacks
Alle Kommentare anzeigen
Nach oben