Softwareentwicklung: Concurrency in Go
Seite 3: Unit-Tests im Einsatz gegen Data Race
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
Ein Worker ĂĽbernimmt die Arbeit
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
}
Mutex erleichtert das Code-Verständnis
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)
}