Concurrency in Go begrenzen: errgroup, Worker Pools und Backpressure
Begrenze die parallele Arbeit, statt sie einfach abzubrechen.
Unbegrenztes Fan-out ist der Standardausfallmodus der Go-Konkurrenz: Jede go-Anweisung ist eine weitere laufende Aufgabe, ohne Obergrenze und ohne Verantwortlichen. Dieser Artikel behandelt die Einführung beider: Obergrenzen und Verantwortung.
Abbruch und Begrenzung sind separate Steuerungsmechanismen. Abbruch entscheidet, wann Arbeit stoppt; Begrenzung entscheidet, wie viel davon gleichzeitig existiert. Ein Service kann perfekt abbrechen und trotzdem zusammenbrechen, weil zehntausend Goroutinen unabhängig voneinander entschieden haben, dass jetzt ein guter Zeitpunkt ist, die Datenbank aufzurufen.

Die Seiten rund um diese hier decken die andere Hälfte des Problems ab. Go context.Context richtig gemacht behandelt den Context als Steuerungsfläche für das Stoppen von Arbeit; hier geht es um die Kapazitätsfläche — errgroup.SetLimit, Semaphore-Kanäle, Worker-Pools und begrenzte Warteschlangen — sowie um die eine Leckage-Form, die auch dann über errgroup.Wait hinaus überlebt, wenn jeder Abbruchpfad korrekt verdrahtet erscheint.
Fünf Primitive, eine Entscheidungstabelle
| Primite | Teilweise Ergebnisse | Fail-fast | Backpressure | Dynamische Arbeit | Am besten für |
|---|---|---|---|---|---|
sync.WaitGroup + errors.Join |
ja | nein | nein | nein | Wartung-sämtlicher Batches, bei denen jedes Ergebnis zählt |
errgroup.WithContext |
nein | ja | nein | nein | Fan-out, bei dem der erste Fehler die Anfrage beendet |
errgroup.SetLimit(n) |
nein | ja | ja (blockiert Go) |
nein | Begrenztes Fail-Fast-Fan-out |
| Semaphore-Kanal | ja | manuell | ja (blockiert Akquise) | ja | Eigenes Zulassungssteuerung |
| Worker-Pool | ja | manuell | ja (Warteschlange füllt sich) | ja | Gleichmäßige Streams, langlebige Worker |
Zwei Fragen bestimmen die Zeile. Erstens: Wenn eine Aufgabe fehlschlägt, möchten Sie die Ergebnisse der anderen noch? Wenn ja, verwerfen Fail-Fast-Gruppensemantiken Arbeit, die Sie brauchten. Zweitens: Kommt die Arbeit kontinuierlich an oder handelt es sich um einen festen Batch, den Sie vor dem Start zählen können? Gruppen behandeln Batches; Pools und Warteschlangen behandeln Streams.
errors.Join ist das, was die einfache WaitGroup-Zeile praktikabel macht — es sammelt die Fehler aller Worker, anstatt nur den ersten zu behalten, was gut zu den Grenzumsetzungsregeln in Go Error Handling Architecture passt.
Das Leck, das errgroup.Wait überlebt
Der bekannteste Ausfallmodus von errgroup hat nichts mit Begrenzung zu tun. Es ist eine Abbruchlücke, und sie versteckt sich hinter Code, der korrekt aussieht:
g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // Pufferlos
go func() { // Producer — niemand besitzt diese Goroutine
for i := 0; i < 100; i++ {
results <- i
}
close(results)
}()
g.Go(func() error { // Consumer — Gruppenmitglied
for r := range results {
if r == 5 {
return fmt.Errorf("Speichern fehlgeschlagen")
}
}
return nil
})
err := g.Wait() // Gibt bei dem Fehler des Consumers zurück
Wenn der Consumer zurückkehrt, wird gctx abgebrochen und Wait gibt zurück. Der Producer ist an results <- i blockiert — und Kontextabbruch blockiert einen Kanalversand nicht auf. Da niemand diese Goroutine besitzt, bleibt sie für die gesamte Lebensdauer des Prozesses blockiert. Führt man diese Form in einer Schleife aus, ist das Leck exakt linear:
| Variante | Aktive Goroutinen nach 500 Iterationen |
|---|---|
Versand ohne ctx.Done-Arm |
501 (+500 auslaufend, ein pro Iteration) |
Versand in select mit <-gctx.Done() eingebettet |
502 (+1 Baseline) |
// die Lösung: jede Kanaloperation innerhalb einer abbrechbaren Aufgabe
// erhält einen Done-Arm
select {
case results <- i:
case <-gctx.Done():
return
}
Die gleiche Lücke existiert auf der Empfangsseite. Die Regel ist mechanisch: Jede Kanaloperation innerhalb von Code, der abgebrochen werden kann, erhält einen <-ctx.Done()-Arm, und jede Goroutine hat einen Besitzer, der auf sie wartet oder sie abbricht. goleak in der CI erfasst die Fälle, die Code-Reviews übersehen — es fügt sich in das gleiche automatisierte Qualitäts-Gate ein wie die Tools in Go Linters: Essential Tools for Code Quality.
Zwei verwandte Formen sind nennenswert. Wenn der Producer selbst ein Gruppenmitglied ist, erzeugt derselbe Code kein Leck, sondern eine Deadlock — Wait blockiert für immer, während er auf den blockierten Versand wartet, was zumindest laut fehlschlägt. Und ein Producer, der mit select sendet, aber auf einem Kanal empfängt, dessen Consumer aufgehört hat, leckt auf die gleiche Weise; Done-Arme gehören auf beiden Seiten.
errgroup.SetLimit: Begrenztes Fail-Fast-Fan-out
SetLimit verwandelt eine errgroup in eine zulassungssteuergesteuerte Gruppe. Jeder Go-Aufruf über dem Limit blockiert, bis ein Slot frei wird:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
g.Go(func() error {
return process(ctx, item)
})
}
err := g.Wait()
Drei Semantiken sind in der Produktion wichtig. Go blockiert, daher übt die obige Schleife Backpressure auf den Producer aus — das ist normalerweise das Gewünschte, bedeutet aber, dass die Goroutine des Aufrufers ins Stocken geraten kann, daher benötigt ein Request-Handler, der einen großen Batch speist, sein eigenes Timeout-Budget für die gesamte Schleife. SetLimit(0) blockiert jeden Go-Aufruf für immer. Und die Änderung des Limits, während Goroutinen aktiv sind, löst einen Panik aus — das Limit ist für die Lebensdauer der Gruppe festgelegt.
SetLimit erbt die Gruppensemantik: Der erste Fehler bricht den Kontext ab und Wait gibt den ersten Fehler zurück, wobei die Ergebnisse laufender Arbeit verworfen werden. Es ist das richtige Primitiv, wenn das Versagen der gesamten Operation bei dem ersten Fehler korrekt ist — das Abrufen einer Datenseite, der Aufruf eines Satzes unabhängiger Health-Checks, das Verteilen einer Anfrage auf Shards.
Semaphore-Kanäle: Backpressure als Feature
Ein gepufferter Kanal mit der Kapazität n ist ein Semaphore ohne Abhängigkeiten:
sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // Blockiert, wenn 8 Aufgaben laufen
go func() {
defer wg.Done()
defer func() { <-sem }()
_ = process(ctx, item)
}()
}
wg.Wait()
Die Akquise erfolgt vor der go-Anweisung, sodass Goroutinen nur erstellt werden, wenn ein Slot existiert. Die Puffergröße ist der Vertrag: Sie ist gleichzeitig die Obergrenze der Parallelität und die Warteschlange der ausstehenden Zulassungen.
Das Primitiv verdient seinen Platz, wenn man eine Zulassungssteuerung braucht, die die Gruppen nicht ausdrücken — kontextbewusste Akquise, Pools pro Abhängigkeit oder teilweise Ergebnisse bei Fehlern:
select {
case sem <- struct{}{}: // Slot akquiriert
case <-ctx.Done(): // Aufrufer hat aufgegeben, bevor er hinein kam
return ctx.Err()
}
Gemessen an derselben Arbeitslast liegen der Semaphore-Kanal und SetLimit(8) innerhalb von 1,5 % voneinander — die Wahl zwischen ihnen ist Semantik, nicht Leistung.
Worker-Pools: Für Streams, nicht Batches
Ein fester Pool langlebiger Worker trennt die Zulassung (die Warteschlange) von der Ausführung (die Worker):
func pool(ctx context.Context, workers int) chan<- func() {
jobs := make(chan func())
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case f, ok := <-jobs:
if !ok {
return
}
f()
case <-ctx.Done():
return
}
}
}()
}
return jobs
}
Pools passen zu Arbeit, die kontinuierlich ankommt — Warteschlangen-Consumer, Polling-Schleifen, Request-Worker — wo eine Batch-gruppe ständig erstellt und abgebaut würde. Seit Go 1.25 entfernt sync.WaitGroup.Go den Boilerplate des Add/Done-Paarings für die allgemeine Form. Der Shutdown-Vertrag ist der Teil, der richtig gemacht werden muss: Das Schließen von jobs beendet die Worker, nachdem die Warteschlange geleert wurde; das Abbrechen von ctx verlässt die wartende Arbeit. Beides ist legitim, aber nur eines kann das dokumentierte Verhalten sein. Go 1.27 geht in dieselbe Richtung weiter — generische Methoden, neue go fix-Moderne-Werkzeuge und ein in-memory httptest.NewTestServer — alles abgedeckt im Go 1.27 Upgrade Guide.
Begrenzte Warteschlangen: Blockieren oder Verwerfen
Ein gepufferter Kanal ist auch eine Warteschlange mit einer Obergrenze. Wenn der Puffer voll ist, blockiert der Producer — und dieser Moment ist die Designentscheidung. Blockieren überträgt den Druck nach oben an den, der die Warteschlange speist; Verwerfen stößt Last ab, zum Preis verlorener Arbeit. Eine Überwachungspipeline kann verwerfen; eine Bestellopipeline muss blockieren oder in persistenten Speicher ablaufen.
select {
case queue <- event: // Zugelassen
default: // Warteschlange voll: Verwerfungsregel lebt hier
dropped.Add(1)
}
Wie auch immer die Regel ist, machen Sie sie explizit und messbar. Ein stummer default-Zweig ist der Ort, an dem Ereignisse verschwinden.
Was Begrenzung kostet — gemessen
Die gleiche Arbeitslast, 10.000 Aufgaben mit jeweils 5 ms simulierter I/O, Go 1.27.1:
| Strategie | Peak-Goroutinen | Echtzeit |
|---|---|---|
Unbegrenztes go pro Aufgabe |
~10.000 gestartet | 18 ms |
errgroup.SetLimit(8) |
8 | 6,9 s |
| Semaphore-Kanal (Cap 8) | 8 | 6,6 s |
Der unbegrenzte Lauf gewinnt in der Echtzeit, weil nichts im Experiment zurückdrückt — zehntausend Timer-Goroutinen schlafen einfach parallel. Das ist genau die Falle. Die Kosten unbegrenzten Fan-out sind nie die CPU in Ihrem eigenen Prozess; es sind zehntausend gleichzeitige Verbindungen, ein Abhängiges, das unter dem Burst beginnt, Timeouts zu liefern, und ein Speichergewebe, das der Tiefe der Warteschlange desjenigen folgt, den Sie aufrufen. Bei einer echten Abhängigkeit beendet der unbegrenzte Lauf sich nicht in 18 ms — er läuft in ein Timeout. Die begrenzten Läufe zahlen 6,6 Sekunden, weil 10.000 Aufgaben ÷ 8 Slots × 5 ms 6,25 s unvermeidliche Serialisierung sind, und die Cap ist der Punkt: Sie verwandelt einen unkontrollierten Burst in eine vorhersehbare 6,25-Sekunden-Entlastung.
Eine Verfeinerung ist wichtig, wenn die Cap höher ist als ein Handvoll Slots: Halten Sie die Cap bei oder unter dem, was die Abhängigkeit parallel aufrechterhalten kann, und leiten Sie sie von dieser Grenze ab (Verbindungspool-Größe, Ratenlimit, Worker-Kapazität), anstatt von einem Gefühl über „vernünftige“ Parallelität.
Messen und Testen begrenzten Codes
Drei Signale decken den Großteil davon ab. Trends der Goroutine-Anzahl (/sched/goroutines:goroutines exportiert über den Prometheus-Go-Collector) fangen Lecks als Steigung. Scheduler-Latenz (/sched/latencies:seconds) fängt Sättigung — ausführbare Goroutinen, die auf CPU-Zeit warten, ist echter Druck, unabhängig von der Anzahl. Ein pprof-Delta von zwei Goroutine-Dumps zeigt genau, wo die blockierten Goroutinen leben.
Für die Tests sind begrenzte Worker der Fall, für den Testieren von Concurrent Go Code mit synctest entwickelt wurde: Simulierte Zeit lässt einen vollständigen 10.000-Aufgaben-Drain in Millisekunden ablaufen, synctest.Wait ersetzt Sleep-and-Hope für Ruhe, und ein Bubble schlägt schnell fehl, wenn ein Worker dauerhaft blockiert bleibt — das oben genannte Leck-Reprodukt stirbt in einem Test-Bubble, nicht in der Produktion.
Im Service-Maßstab taucht dieselbe Begrenzungsfrage zwischen Services wieder auf, wo das Semaphore zu einem Ratenlimit wird und der Circuit Breaker die Abhängigkeit schützt — Circuit Breaker Pattern in Go deckt diese Schicht ab, und Go Microservices für AI/ML-Orchestrierung zeigt die durch Warteschlangen gestützten Formen, die die Orchestrierungsschicht annimmt.
Auswahl
beim ersten Fehler?] D -- ja --> E[WaitGroup + errors.Join
oder Semaphore-Kanal] D -- nein --> F[errgroup.WithContext] F --> G[Parallelitäts-Obergrenze benötigt?] G -- ja --> H[errgroup.SetLimit] G -- nein --> I[Gruppe ohne Limit
nur wenn der Batch nachweisbar klein ist] C --> J[Volle Warteschlange: Blockieren oder Verwerfen?] J -- blockieren --> K[Backpressure nach oben] J -- verwerfen --> L[Last abstoßen, Verwerfungen zählen]
Die Tabelle und das Flowchart reduzieren sich auf eine Gewohnheit: Bevor Sie go schreiben, benennen Sie die Obergrenze und den Besitzer. Die Obergrenze ist SetLimit, ein Semaphore oder eine begrenzte Warteschlange; der Besitzer ist ein Wait, ein ctx.Done-Arm auf jeder Kanaloperation oder beides. Jeder Goroutine-Erstellungsort, der diese beiden Namen auf einen Blick benennen kann, ist einer, der niemanden um 3 Uhr morgens anruft.