Golang: Mutex

Bei der Verwendung von Goroutinen kann es mitunter vorkommen, dass aus mehreren Goroutinen auf dieselben Ressourcen zugegriffen wird, was zu Race Conditions führen kann. Hierbei handelt es sich um schwer nachvollziehbare Fehler, welche häufig erst bei der Ausführung des Programms auffallen. Um zu verhindern, dass mehrere Goroutinen gleichzeitig eine Variable bearbeiten, bietet Golang aus dem sync-Package Mutex an.

Im nachfolgenden Beispiel soll schrittweise der Aufbau eines kleinen Programms zeigen, wie mit Hilfe von Mutex eine Race Condition verhindert wird. Wie dabei zu sehen ist, gibt es zunächst nur eine inc-Funktion, welche die Variable number um eins erhöht. Aus der main-Funktion wird diese Funktion 100-mal aufgerufen und danach wird number ausgegeben. In der Ausgabe, ist auch beim mehrmaligen Ausführen des Programms zu sehen, dass number gleich 100 ist.

package main

import (
    "fmt"
)

var number = 0

func inc() {
    number = number + 1
}

func main() {
    for i := 0; i < 100; i++ {
        inc()
    }
	
    fmt.Println(number)
}

Ausgabe

$ go run mutex.go
100
$ go run mutex.go
100
$ go run mutex.go
100

Das Beispiel soll dabei möglichst einfach gehalten werden. Bei der inc-Funktion könnte es sich auch um eine Funktion mit einer aufwendigeren Aufgabe handeln, welche mehr Zeit für die Ausführung benötigt. Hierbei könnte nun die Anforderung bestehen, den Programmablauf unter Einsatz von Goroutinen zu beschleunigen. Wie aus den vorherigen Beiträgen hervorgeht, ist dies relativ einfach zu bewerkstelligen, in dem der Aufruf der inc-Funktion das go-Statement vorangestellt wird.

Dabei ist darauf zu achten, dass WaitGroups verwendet werden, da sonst die main-Funktion durchgelaufen ist, bevor alle Goroutinen ausgeführt wurden.

package main

import (
    "fmt"
    "sync"
)

var number = 0

func inc(wg *sync.WaitGroup) {
    defer wg.Done()
    number = number + 1
}

func main() {
    var wg sync.WaitGroup

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go inc(&wg)
    }

    wg.Wait()
    fmt.Println(number)
}

Ausgabe

$ go run mutex.go
100
$ go run mutex.go
98
$ go run mutex.go
96
$ go run mutex.go
99

Wie in der Ausgabe zu sehen, ist das Ergebnis nun nicht mehr durchgehend 100. Obwohl WaitGroups verwendet und somit alle Goroutinen ausgeführt werden, schwankt das Ergebnis. Es lässt sich bereits erahnen, dass es schwierig ist, solche Fehler in einem größeren Programm ausfindig zu machen. Vor allem, wenn es trotz des Fehlers vorkommt, dass das richtige Ergebnis geliefert wird, wie hier gleich bei der ersten Ausführung des Programms.

Race Condition mit Hilfe des Go Race Detector finden

Golang bietet seit Go Version 1.1 einen Race Detector an. Hierbei lässt sich das Programm mit dem zusätzlichen Parameter -race ausführen. Stößt der Race Detector dabei auf eine Race Condition, wird dies in der Ausgabe dargestellt. Die Ausführung des letzten Beispiels führt somit zur folgenden Ausgabe:

$ go run -race mutex.go
==================
WARNING: DATA RACE
Read at 0x000001228360 by goroutine 8:
  main.inc()
      /mutex/mutex.go:12 +0x6e

Previous write at 0x000001228360 by goroutine 7:
  main.inc()
      /mutex/mutex.go:12 +0x8a

Goroutine 8 (running) created at:
  main.main()
      /mutex/mutex.go:20 +0xab

Goroutine 7 (finished) created at:
  main.main()
      /mutex/mutex.go:20 +0xab
==================
100
Found 1 data race(s)
exit status 66

In der Ausgabe ist zu sehen, dass der Race Detector, eine Race Condition entdeckt hat und wo diese aufgetreten ist. So ist von Zeile 12 bis 18 zu sehen, dass der Aufruf der Goroutine in Zeile 20 des Go-Codes ( go inc(&wg) ) zu dem Fehler führt, wobei in den Zeilen von 4 bis 6 der Ausgabe steht, wo genau die Race Condition aufgetreten ist. In diesem Fall ist das die Zeile 12, was im Go-Code die Durchführung der Addition ist  ( number = number + 1 )

Um besser zu verstehen, warum es bei dieser Zeile zu einer Race Condition kommt, soll die folgende Grafik den Ablauf anhand von zwei Goroutinen veranschaulichen. (Hinweis: die Grafik soll nur rudimentär den Ablauf darstellen, um das Problem zu verdeutlichen).

Ablauf zweier Goroutinen und Zugriff auf den selben Speicher

Wichtig ist dabei zu beachten, dass die Addition von number mit eins (number = number + 1) in mehreren Schritten durchgeführt wird. Dazu gehören das Laden von number aus dem Speicher, die eigentliche Addition von number mit eins und das Zurückschreiben von number in den Speicher. Da die Goroutinen diese einzelnen Schritte auch abwechselnd durchführen können, kann es dazu kommen, dass eine zweite Goroutine einen Wert von number aus dem Speicher liest, welcher nicht mehr stimmt.

Wie in der Abbildung zu sehen ist, addiert die erste Goroutine 1 auf number, wobei number initial 0 ist. Doch bevor diese Goroutine den Wert zurückschreibt, hat die zweite Goroutine ebenfalls den initialen Wert (0) von number aus dem Speicher gelesen. Am Ende der beiden Goroutinen überschreiben beide den Wert von number mit 1. Obwohl zwei Goroutinen ausgeführt wurden und als Ergebnis erwartet wird, dass number den Wert 2 angenommen hat, ist das hier nicht der Fall.

In dem hier gezeigten Beispiel, bei dem 100 Goroutinen gestartet werden, kommt dieses Phänomen öfter vor, was dazu führt, dass das Ergebnis regelmäßig unter 100 liegt.

Race Condition mittels Mutex verhindern

Um zu verhindern, dass mehrere Goroutinen gleichzeitig auf bestimmte Variablen zugreifen können, kann das aus dem sync-Package zur Verfügung gestellte Mutex verwendet werden. Mutex bietet dafür die zwei Funktionen Lock() und Unlock() an. Das folgende Beispiel wurde so erweitert, dass keine Race Condition mehr Auftritt:

package main

import (
    "fmt"
    "sync"
)

var number = 0

func inc(wg *sync.WaitGroup, mux *sync.Mutex) {
    defer wg.Done()
	
    mux.Lock()
    number = number + 1
    mux.Unlock()
}

func main() {
    var wg sync.WaitGroup
    var mux sync.Mutex

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go inc(&wg, &mux)
    }

    wg.Wait()
    fmt.Println(number)
}

Ausgabe

$ mutex go run -race mutex.go
100
$ mutex go run -race mutex.go
100
$ mutex go run -race mutex.go
100

Wie der Ausgabe zu entnehmen ist, wurde die Ausführung des Programms wieder mittels -race Parameter gestartet. Dabei trat weder ein Fehler auf, noch ist das Ergebnis falsch. Dank des Aufrufes von mux.Lock() und mux.Unlock() wird nun verhindert, dass mehrere Goroutinen gleichzeitig auf die Variable number zugreifen und diese bearbeiten.

Schlusswort

Race Conditions können für ein unvorhergesehenes Verhalten bei der Ausführung von Programmen sorgen, indem mehrere Goroutinen gleichzeitig auf einen Speicher (Critical Section) zugreifen. Dank des Race Detectors lassen sich in Golang jedoch diese Race Conditions aufdecken und mit Hilfe von Mutex und der Funktionen Lock() und Unlock() beheben.

Show Comments