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() blockiert main, bis alle drei Goroutines Done aufrufen.

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, kann Wait() vor Add ausgefü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 mehrere return-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 Mutex enthält, nachdem du sie verwendet hast. go vet warnt dich in diesem Fall mit der Nachricht copylocks. Außerdem hat Go keinen reentrant Lock: Wenn eine Goroutine Lock() 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 von g.Go wird blockiert, bis ein Slot verfügbar ist. Dadurch vermeidest du, den Backend-Service zu überlasten, wenn du Tausende von Bestellungen hast.
  • g.Go nimmt eine Funktion vom Typ func() error und du brauchst Add/Done nicht aufzurufen.
  • g.Wait() wartet, bis alle Goroutines fertig sind, und gibt dann den ersten Fehler zurück. Später auftretende Fehler werden ignoriert.
  • fetchOrder hört auf ctx.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. Wenn fetchOrder time.Sleep verwendet, 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:

  • Add innerhalb einer Goroutine aufrufen. Wait könnte zu früh zurückkehren. Rufe Add vor dem go-Statement auf, oder verwende wg.Go.
  • WaitGroup oder Mutex als 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 Lock machen. 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.Group verwenden (var g errgroup.Group), aber trotzdem erwartet, dass der Context abgebrochen wird. Nur WithContext erstellt 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 SetLimit oder 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: errgroup speichert nur den ersten Fehler. Wenn du alle Fehler kennen musst, zum Beispiel um zu berichten, welche Bestellungen defekt sind, lass jede Goroutine ihren Fehler in errs[i] schreiben, dann füge sie nach dem Wait-Aufruf mit errors.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.

golangconcurrencygoroutineerrgroupsync