sync.WaitGroup, Mutex und errgroup: Goroutines in Go koordinieren
1. Übersicht
Eine Goroutine in Go auszuführen ist sehr einfach, du brauchst nur das Schlüsselwort go hinzuzufügen. Die schwierigere Aufgabe ist zu wissen, wann die Goroutines fertig sind, gemeinsame Daten zu schützen und Fehler zu behandeln, wenn eine Goroutine scheitert.
Dieser Artikel behandelt drei Werkzeuge für diese drei Aufgaben: sync.WaitGroup, sync.Mutex und errgroup.Group. Nach dem Lesen wirst du wissen, welches Werkzeug du für jede Situation auswählst, wie du Tests mit -race schreibst und welche häufigen Fehler du vermeiden solltest.
Die Beispiele verwenden Go 1.22 und höher. Ab dieser Version erstellt jede for-Schleife eine neue Variable für jede Iteration, daher brauchst du id := id nicht mehr zu schreiben.
2. Beispielproblem
Angenommen, du hast eine Liste von Bestellungscodes (Order). Jede Bestellung muss einen langsamen Service aufrufen, der etwa 100ms dauert. Wenn du 10 Bestellungen nacheinander aufrufst, dauert es etwa 1 Sekunde. Wenn du sie parallel aufrufst, dauert es nur etwa 100ms.
Hier ist eine Funktion, die den Service-Aufruf simuliert:
package main
import (
"fmt"
"time"
)
func fetchOrder(id int) string {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("order-%d", id)
}
Wenn du einfach go fetchOrder(id) in der Schleife schreibst, endet die main-Funktion, bevor die Goroutines fertig sind. Das Programm beendet sich und die Ergebnisse gehen verloren. Der nächste Abschnitt behandelt dieses Problem.
3. sync.WaitGroup: Warten auf eine Gruppe von Goroutines
3.1. Grundlegende Verwendung
WaitGroup ist ein Zähler. Du erhöhst den Zähler mit Add, vermindert ihn mit Done, und Wait blockiert, bis der Zähler auf 0 zurückgeht.
func main() {
var wg sync.WaitGroup
ids := []int{1, 2, 3}
for _, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("fetched", fetchOrder(id))
}()
}
wg.Wait()
fmt.Println("all done")
}
Der obige Code funktioniert folgendermaßen:
wg.Add(1)wird vor dem Starten der Goroutine aufgerufen, in der übergeordneten Goroutine.defer wg.Done()stellt sicher, dass der Zähler immer verringert wird, auch wenn die Goroutine früh beendet wird oder einen Panic verursacht.wg.Wait()blockiertmain, bis alle drei GoroutinesDoneaufrufen.
Die Ausgabe könnte wie folgt aussehen. Die Reihenfolge der fetched-Zeilen ändert sich zwischen den Läufen:
fetched order-2
fetched order-1
fetched order-3
all done
3.2. WaitGroup.Go in Go 1.25
Ab Go 1.25 hat WaitGroup eine zusätzliche Go-Methode. Diese Methode kombiniert Add, go und Done in einen einzigen Aufruf:
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
fmt.Println("fetched", fetchOrder(id))
})
}
wg.Wait()
Dies ist kürzer zu schreiben und vermeidet zwei häufige Fehler: das Vergessen, Done aufzurufen, oder Add innerhalb einer Goroutine aufzurufen.
3.3. Ergebnisse sammeln ohne Lock
Du musst normalerweise die Ergebnisse speichern, nicht nur auf dem Bildschirm ausgeben. Eine sichere Möglichkeit ist, ein Slice mit vordefinierter Länge zu erstellen und jede Goroutine in einen eigenen Index schreiben zu lassen:
results := make([]string, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() {
results[i] = fetchOrder(id)
})
}
wg.Wait()
fmt.Println(results)
[order-1 order-2 order-3]
Da keine Goroutine in dasselbe Element schreibt, gibt es hier keinen Data Race. wg.Wait() etabliert auch eine happens-before-Beziehung. Daher kann die übergeordnete Goroutine nach Wait alle Daten vollständig lesen, die die untergeordneten Goroutines geschrieben haben.
Hinweis: Rufe
wg.Add(1)nicht innerhalb einer Goroutine auf. Wenn du das tust, kannWait()vorAddausgeführt werden und zu früh zurückgeben, wenn die Arbeit noch nicht erledigt ist.
4. sync.Mutex: Schutz gemeinsamer Daten
4.1. Wie ein Data Race aussieht
Angenommen jetzt, du musst den Umsatz nach Kunde (Customer) addieren, und mehrere Goroutines aktualisieren gleichzeitig eine Map:
revenue := map[string]float64{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
revenue["alice"] += 10
})
}
wg.Wait()
Dieses Programm könnte mit dem folgenden Fehler abstürzen:
fatal error: concurrent map writes
Ein Fehler bei einem einzelnen int ist noch gefährlicher. Das Programm stürzt nicht ab, sondern gibt leise ein falsches Ergebnis aus, z.B. 9730 statt 10000. Um diese Art von Fehler zu erkennen, führe den Code mit dem Race Detector aus:
go run -race main.go
WARNING: DATA RACE
Write at 0x00c000012345 by goroutine 8:
...
4.2. Mit Mutex beheben
sync.Mutex stellt sicher, dass zu jedem Zeitpunkt nur eine Goroutine den Code zwischen Lock und Unlock ausführt. Eine gängige Praxis ist, den Mutex zusammen mit den Daten, die er schützt, in derselben Struktur zu kapseln:
package shop
import "sync"
type RevenueTracker struct {
mu sync.Mutex
total map[string]float64
}
func NewRevenueTracker() *RevenueTracker {
return &RevenueTracker{total: make(map[string]float64)}
}
func (r *RevenueTracker) Add(customer string, amount float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.total[customer] += amount
}
func (r *RevenueTracker) Total(customer string) float64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.total[customer]
}
Ein paar Punkte, die du beachten solltest:
- Jeder Zugriff auf
total, einschließlich Lesezugriff, muss durch den Lock gehen. defer r.mu.Unlock()stellt sicher, dass der Lock immer freigegeben wird, auch wenn die Funktion mehrerereturn-Stellen hat.- Die Methoden verwenden einen Pointer-Receiver (
*RevenueTracker). Wenn du einen Value-Receiver verwendest, wird der Mutex bei jedem Aufruf kopiert, und der Lock schützt dann nichts mehr.
Wenn es viel mehr Lesezugriffe als Schreibzugriffe gibt, kannst du sync.RWMutex verwenden. Read-Methoden rufen RLock/RUnlock auf, Write-Methoden rufen Lock/Unlock auf. Mehrere Goroutines können gleichzeitig lesen, aber beim Schreiben hält nur eine Goroutine den Lock.
Hinweis: Kopiere keine Struktur, die einen
Mutexenthält, nachdem du sie verwendet hast.go vetwarnt dich in diesem Fall mit der Nachrichtcopylocks. Außerdem hat Go keinen reentrant Lock: Wenn eine GoroutineLock()zweimal auf demselben Mutex aufruft, kommt es zu einem Deadlock.
4.3. Wann du keinen Mutex brauchst
Für einfache Zähler ist sync/atomic knapper und schneller:
var processed atomic.Int64
processed.Add(1)
fmt.Println(processed.Load())
Wenn Daten von einer Goroutine zur anderen als Stream fließen müssen, sind Channels oft die bessere Wahl. Mutexes sind am besten geeignet, wenn du einen gemeinsamen Status hast, wie eine Map oder Struktur, und mehrere Goroutines müssen darauf lesen oder schreiben.
5. errgroup: WaitGroup mit Fehlerbehandlung
WaitGroup weiß nichts von Fehlern. Wenn fetchOrder einen error zurückgeben kann, musst du die Fehler selbst mit einem Mutex oder Channel sammeln und die verbleibenden Goroutines selbst abbrechen. Das Paket errgroup macht diese Dinge bereits für dich.
5.1. Abhängigkeit hinzufügen
errgroup befindet sich im Modul golang.org/x/sync, nicht in der Standardbibliothek:
go get golang.org/x/sync
5.2. Fehler sammeln und mit Context abbrechen
Der erste Schritt ist, fetchOrder umzuschreiben, so dass die Funktion einen context.Context akzeptiert und einen Fehler zurückgeben kann:
package shop
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type Order struct {
ID int
Customer string
}
func fetchOrder(ctx context.Context, id int) (Order, error) {
if id < 0 {
return Order{}, fmt.Errorf("invalid order id %d", id)
}
select {
case <-time.After(100 * time.Millisecond):
return Order{ID: id, Customer: "alice"}, nil
case <-ctx.Done():
return Order{}, ctx.Err()
}
}
func LoadOrders(ctx context.Context, ids []int) ([]Order, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(3)
orders := make([]Order, len(ids))
for i, id := range ids {
g.Go(func() error {
o, err := fetchOrder(ctx, id)
if err != nil {
return err
}
orders[i] = o
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return orders, nil
}
Der obige Code funktioniert folgendermaßen:
errgroup.WithContext(ctx)erstellt eine Gruppe und einen Kind-Context. Sobald eine Goroutine den ersten Fehler zurückgibt, wird der Kind-Context abgebrochen.g.SetLimit(3)erlaubt höchstens 3 Goroutines gleichzeitig. Der nächste Aufruf vong.Gowird blockiert, bis ein Slot verfügbar ist. Dadurch vermeidest du, den Backend-Service zu überlasten, wenn du Tausende von Bestellungen hast.g.Gonimmt eine Funktion vom Typfunc() errorund du brauchstAdd/Donenicht aufzurufen.g.Wait()wartet, bis alle Goroutines fertig sind, und gibt dann den ersten Fehler zurück. Später auftretende Fehler werden ignoriert.fetchOrderhört aufctx.Done(), also beenden sich die verbleibenden Goroutines früher, anstatt die vollen 100ms zu laufen.
Den Aufruf mit einem ungültigen Bestellungscode:
_, err := shop.LoadOrders(context.Background(), []int{1, 2, -5, 4})
fmt.Println(err)
invalid order id -5
Wenn du es versuchen möchtest, ohne zu blockieren, wenn das Limit erreicht ist, verwende g.TryGo(f). Diese Funktion gibt false zurück, anstatt zu warten.
Hinweis: Das Abbrechen des Contexts funktioniert nur, wenn der Code innerhalb der Goroutine aktiv
ctxüberprüft. WennfetchOrdertime.Sleepverwendet, ohne den Context zu überprüfen, wird die Goroutine bis zum Ende laufen, auch wenn die Gruppe fehlgeschlagen ist.
6. Tests mit -race schreiben
Go hat kein JUnit oder AssertJ. Du verwendest das testing-Paket der Standardbibliothek. Das Wichtigste ist, Tests immer mit dem -race-Flag auszuführen, da viele Concurrency-Fehler nur beim Race Detector sichtbar werden.
package shop
import (
"context"
"sync"
"testing"
)
func TestRevenueTracker_ConcurrentAdd(t *testing.T) {
tracker := NewRevenueTracker()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
tracker.Add("alice", 10)
})
}
wg.Wait()
if got := tracker.Total("alice"); got != 10000 {
t.Fatalf("Total(alice) = %v, want 10000", got)
}
}
func TestLoadOrders_ReturnsAllOrders(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, 2, 3})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, o := range orders {
if o.ID != i+1 {
t.Errorf("orders[%d].ID = %d, want %d", i, o.ID, i+1)
}
}
}
func TestLoadOrders_StopsOnFirstError(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, -1, 3})
if err == nil {
t.Fatal("expected error, got nil")
}
if orders != nil {
t.Errorf("expected nil orders, got %v", orders)
}
}
Tests ausführen:
go test -race ./...
ok example.com/shop 0.412s
Versuche, die beiden Lock/Unlock-Zeilen in Add zu löschen und führe dann erneut aus. Der Test wird mit WARNING: DATA RACE fehlschlagen oder mit concurrent map writes abstürzen. Dies ist eine schnelle Möglichkeit zu überprüfen, dass der Mutex wirklich notwendig ist.
Der zweite Test bestätigt auch, dass die Reihenfolge der Ergebnisse korrekt erhalten bleibt: orders[i] entspricht ids[i], auch wenn die Goroutines in beliebiger Reihenfolge beendet werden.
7. Häufige Fehler und Werkzeugwahl
Die häufigsten Fehler:
Addinnerhalb einer Goroutine aufrufen.Waitkönnte zu früh zurückkehren. RufeAddvor demgo-Statement auf, oder verwendewg.Go.WaitGroupoderMutexals Wert übergeben. Die Funktion erhält eine Kopie, und der ursprüngliche Zähler oder Lock wird nicht verändert. Übergebe stattdessen einen Pointer*sync.WaitGroup, oder verwende eine Closure.- Lock während I/O-Aufrufen halten. Zum Beispiel, einen HTTP-Aufruf innerhalb von
Lockmachen. Alle anderen Goroutines müssen warten, und das Programm verliert den Nutzen der parallelen Ausführung. Halte den Lock nur im kürzesten Code-Segment möglich. - Eine leere
errgroup.Groupverwenden (var g errgroup.Group), aber trotzdem erwartet, dass der Context abgebrochen wird. NurWithContexterstellt einen abbruchbaren Context. - Den Rückgabewert von
g.Wait()ignorieren. Dann verschwindet der Fehler, ohne dass jemand es merkt. - Unbegrenzte Goroutines für Zehntausende von Anfragen starten. Verwende
SetLimitoder einen Worker Pool.
Werkzeugwahl:
| Situation | Werkzeug |
|---|---|
| Warten auf mehrere Aufgaben ohne Fehler | sync.WaitGroup |
| Mehrere Goroutines ändern gemeinsame Map oder Struktur | sync.Mutex / sync.RWMutex |
| Einfacher Zähler oder Flag | sync/atomic |
| Aufgaben können fehlschlagen, Frühabbruch nötig, Goroutine-Limit | errgroup |
Hinweis:
errgroupspeichert nur den ersten Fehler. Wenn du alle Fehler kennen musst, zum Beispiel um zu berichten, welche Bestellungen defekt sind, lass jede Goroutine ihren Fehler inerrs[i]schreiben, dann füge sie nach demWait-Aufruf miterrors.Join(errs...)zusammen.
8. Fazit
sync.WaitGroup beantwortet die Frage “Wann wird es fertig?”. sync.Mutex beantwortet die Frage “Wer darf gerade die Daten ändern?”. errgroup verbindet Warten mit Fehlerbehandlung, Context-basiertem Abbruch und Begrenzung der parallelen Goroutines.
In realer Code ist errgroup.WithContext kombiniert mit SetLimit normalerweise die vernünftige Standardwahl, wenn man mehrere Services parallel aufruft. Egal welches Werkzeug du verwendest, führe immer go test -race aus. Dies ist die günstigste Methode, um Data Races zu fangen, bevor sie in die Produktion gehen.