Softwareentwicklung: Concurrency in Go

Seite 3: Unit-Tests im Einsatz gegen Data Race

Inhaltsverzeichnis

Diese Vermutung mit einem Browser von Hand zu prüfen, ist so gut wie unmöglich. Jedoch kann ein einfacher Unit-Test (Listing 10) Abhilfe schaffen. Er startet mehrere Goroutinen, die den Handler nebenläufig aufrufen. Dafür wird in einem Testfile main_test.go eine Testfunktion angelegt. ts initialisiert einen leeren Server. Da es in dem internen Test möglich ist, die zu prüfenden Handler-Funktionen direkt aufzurufen, braucht es dafür keinen HTTP-Server. Die Testfunktion lässt sich auf diese Weise möglichst einfach halten. Im nächsten Schritt wird unter handler die zu testende Handler-Funktion aus der Methode handleCount geholt.

Im Anschluss gilt es, die Hilfsfunktion getCounter zu definieren. Für das Ergebnis wird für den http.ResponseWriter der Recorder aus dem httptest-Paket geholt. Er speichert intern die Antwort des Handlers, wodurch das Ergebnis im Test auswertbar ist. Da der Handler keinen Request auswertet, lässt sich in diesem Test einfach ein leerer http.Request übergeben. Die Funktion handler wird anschließend ausgeführt und das Ergebnis aus dem Body in den out-Channel geschrieben.

Listing 10: Test des Handlers auf eine Data Race

func TestServerRace(t *testing.T) {
	ts := &server{}
	handler := ts.handleCount()
	getCounter := func(out chan []byte) {
		w := httptest.NewRecorder()
		handler(w, &http.Request{})
		resp := w.Result()
		body, _ := io.ReadAll(resp.Body)
		out <- body
	}
	out := make(chan []byte)
	go getCounter(out)
	go getCounter(out)
	<-out
	<-out
	want := 2
	if ts.counter != want {
		t.Errorf("Got: %d - Want: %d", ts.counter, want)
	}
}

Der nächste Schritt sieht das Initialisieren des out-Channels vor. Anschließend startet getCounter zweimal als Goroutine, um danach aus dem Channel gelesen zu werden. Die zwei Goroutinen simulieren zwei nahezu gleichzeitige Requests an den Server. Durch <-out wartet die Testfunktion, bis die beiden anderen Goroutinen fertig sind und ignoriert dabei die Werte aus dem Channel. Mit go test lässt sich der Test ausführen und siehe da – alles grün. Der Counter funktioniert anscheinend korrekt. Lediglich der Test des Codes auf ein Data Race steht noch aus. go test -race aktiviert den Race Detector – wie schon vermutet, kommt es bei dem Counter zu einem Data Race (Listing 11).

Listing 11: Test entdeckt Data Race

--- FAIL: TestServerRace (0.00s)
    testing.go:1312: race detected during execution of test
FAIL
exit status 1
FAIL    heise/concserver/raceserver     0.287s

Eine Lösung ist die Verwendung eines Worker, der über einen Channel das Ereignis zum Hochzählen bekommt. Dies entspricht dem vorher vorgestellten Fan-In-Pattern. Da der Handler am Ende den aktuellen Zählerstand ausgeben soll, sollte auch dieser über einen Channel zurückgesendet werden. In dem Fall kommt ein Channel of Channel zum Einsatz. runCounter startet den Worker. Die Goroutine wartet dabei auf Ereignisse aus dem Channel. In dem Fall werden nun Channels für das Ergebnis verschickt. Sobald ein Channel ankommt, erhöht sich der Counter. Der aktuelle Stand ist dem erhaltenen Channel zu entnehmen.

Jeder Request erzeugt innerhalb des Handlers einen Channel, der dann verschickt wird. Der Zählerstand des Counters wird anschließend aus diesem Channel wieder ausgelesen (Listing 12).

Um den Code besser zu strukturieren, wäre es möglich, die Kommunikation über die Channels in eine eigene Funktion auszulagern. Diese Funktion lässt sich direkt vom Handler aus aufrufen und stellt sicher, dass ein nebenläufiger Zugriff nicht zu Data Races führt. Um das zu prüfen, gilt es, den Test noch einmal aufzurufen, der dann auch mit Race Detector erfolgreich ist (Listing 13).

Listing 12: Server nach dem Refactoring

type server struct {
	router  *http.ServeMux
	server  *http.Server
	counter int
	inc     chan chan int
}

func (s *server) runCounter() {
	go func() {
		for resultChan := range s.inc {
			s.counter++
			resultChan <- s.counter
		}
	}()
}

func (s *server) handleCount() http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		resultChan := make(chan int)
		s.inc <- resultChan
		io.WriteString(w, fmt.Sprintf("Counter: %03d", <-resultChan))
	}
}

Listing 13: Eigene Funktion für Kommunikation mit Server

func (s *server) incCounter() int {
	resultChan := make(chan int)
	s.inc <- resultChan
	return <-resultChan
}

Eine andere Möglichkeit ist die Verwendung eines Mutex. Damit wird ein Bereich gesperrt, sobald eine Goroutine darauf zugreift. Der Mutex wird zum einem dem server hinzugefügt. Zum anderen lässt sich die incCounter-Methode umschreiben. Diese Option hat den großen Vorteil, dass der Code einfacher zu verstehen ist und auch ohne das Konstrukt eines Channels auskommt (Listing 14).

Für einfache Operationen, wie das Hochzählen eines Counters, gibt es noch eine weitere Option. Dabei bietet sich das atomic-Paket an. Mit der Funktion AddInt32() lässt sich ein passender Integer-Wert mit einem int32 addieren. Dadurch wird die Methode incCounter um einiges einfacher. Auch hier ist der Test erfolgreich und zeigt kein Data Race an (Listing 15).

Listing 14: Verwendung eines mutex

type server struct {
	router  *http.ServeMux
	server  *http.Server
	counter int
	mtx     *sync.RWMutex
}

func (s *server) incCounter() int {
	s.mtx.Lock()
	defer s.mtx.Unlock()
	s.counter++
	return s.counter
}

Listing 15: Sicherer Counter mit dem atomic-Paket

func (s *server) incCounter() int32 {
	return atomic.AddInt32(&s.counter, 1)
}