Concurrency in Go begrenzen: errgroup, Worker Pools und Backpressure
Begrenzen Sie die laufende Arbeit, statt sie nur abzubrechen.
Unbegrenztes Fan-out ist der Standard-Fehlermodus bei der Go-Konkurrenz: Jede go-Anweisung erzeugt eine weitere Aufgabe, die in der Ausführung ist, ohne Obergrenze und ohne Verantwortlichen. Dieser Artikel beschäftigt sich damit, beides zu schaffen.
Abschaltung und Begrenzung sind separate Steuerungen. Die Abschaltung bestimmt, wann die Arbeit aufhört; die Begrenzung bestimmt, wie viel davon gleichzeitig existiert. Ein Service kann perfekt abschalten und trotzdem zusammenbrechen, weil zehntausend Goroutinen unabhängig voneinander beschlossen haben, dass jetzt ein guter Zeitpunkt ist, die Datenbank aufzurufen.

Die umliegenden Seiten behandeln die andere Hälfte des Problems. Go context.Context richtig gemacht behandelt den Kontext als Steuerungsfläche für das Stoppen von Arbeit; hier steht die Kapazitätsfläche im Fokus – errgroup.SetLimit, Semaphore-Kanäle, Worker-Pools und begrenzte Warteschlangen – sowie der eine Leck-Modus, der errgroup.Wait überlebt, selbst wenn jeder Abschaltungspfad korrekt verdrahtet zu sein scheint.
Fünf Primitiven, eine Entscheidungstabelle
| Primitiv | Teilweise Ergebnisse | Fail-fast | Backpressure | Dynamische Arbeit | Am besten geeignet für |
|---|---|---|---|---|---|
sync.WaitGroup + errors.Join |
Ja | Nein | Nein | Nein | Wait-all-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 | Begrenzter Fail-fast-Fan-out |
| Semaphore-Kanal | Ja | Manuell | Ja (blockiert bei Akquise) | Ja | Custom-Zulassungssteuerung |
| Worker-Pool | Ja | Manuell | Ja (Warteschlange füllt sich) | Ja | Gleichmäßige Ströme, langlebige Worker |
Zwei Fragen bestimmen die Zeile. Erstens: Wenn eine Aufgabe fehlschlägt, möchten Sie dann noch die Ergebnisse der anderen? Wenn ja, verwirft die Fail-fast-Gruppensemantik Arbeit, die Sie gebraucht hätten. Zweitens: Erreicht die Arbeit kontinuierlich, oder ist es ein fester Batch, den Sie vor dem Start zählen können? Gruppen verarbeiten Batches; Pools und Warteschlangen verarbeiten Ströme.
errors.Join ist das, was die einfache WaitGroup-Zeile praktikabel macht – es sammelt jeden Fehler der Worker, anstatt nur den ersten zu behalten, was sich mit den Regeln zur Grenzumsatzung in Go-Fehlerbehandlung-Architektur paart.
Das Leck, das errgroup.Wait überlebt
Der bekannteste Fehlermodus von errgroup hat nichts mit der Begrenzung zu tun. Es ist eine Lücke bei der Abschaltung, und sie versteckt sich hinter Code, der korrekt aussieht:
g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // gepuffert
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("save failed")
}
}
return nil
})
err := g.Wait() // gibt bei dem Fehler des Consumers zurück
Wenn der Consumer zurückkehrt, wird gctx abgesagt und Wait gibt zurück. Der Producer ist bei results <- i festgefahren – und eine Kontext-Abschaltung blockiert nicht einen Kanalsend. Da niemand diese Goroutine besitzt, bleibt sie für die Lebensdauer des Prozesses festgefahren. Führen Sie diese Form in einer Schleife aus, ist das Leck genau linear:
| Variante | Aktive Goroutinen nach 500 Iterationen |
|---|---|
Send ohne ctx.Done-Arm |
501 (+500 geleakt, eine pro Iteration) |
Send in select mit <-gctx.Done() |
502 (+1 Basislinie) |
// die Lösung: Jeder Kanaloperation in einer abschaltbaren Aufgabe
// erhält einen Done-Arm
select {
case results <- i:
case <-gctx.Done():
return
}
Dasselbe Problem existiert auf der Empfangsseite. Die Regel ist mechanisch: Jede Kanaloperation in Code, der abgesagt werden kann, erhält einen <-ctx.Done()-Arm, und jede Goroutine hat einen Besitzer, der auf sie wartet oder sie abschaltet. goleak in der CI erkennt die Fälle, die die Code-Review verfehlt – es passt in dasselbe automatisierte Qualitäts-Gate wie die Werkzeuge in Go-Linters: Wesentliche Werkzeuge für Code-Qualität.
Zwei verwandte Formen sind namentlich zu erwähnen. Wenn der Producer ein Gruppenmitglied ist, erzeugt derselbe Code kein Leck, sondern einen Deadlock – Wait blockiert für immer auf den festgefahrenen Send, was wenigstens laut fehlschlägt. Und ein Producer, der mit select sendet, aber auf einem Kanal empfängt, dessen Consumer aufgehört hat, leakt auf die gleiche Weise; Done-Arme gehören auf beide Seiten.
errgroup.SetLimit: Begrenzter Fail-fast-Fan-out
SetLimit verwandelt eine errgroup in eine gruppenweite Zulassungssteuerung. Jeder Go-Aufruf über die Grenze hinaus 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 Schleife oben Backpressure auf den Producer aus – das ist in der Regel das Gewünschte, aber es bedeutet, dass die Goroutine des Aufrufers zum Stillstand kommen kann, daher benötigt ein Request-Handler, der einen großen Batch speist, ein eigenes Timeout-Budget um die gesamte Schleife. SetLimit(0) blockiert jeden Go-Aufruf für immer. Und das Ändern des Limits, während Goroutinen aktiv sind, verursacht einen Panic – das Limit ist für die Lebensdauer der Gruppe festgelegt.
SetLimit erbt die Gruppensemantik: Der erste Fehler sagt den Kontext ab und Wait gibt den ersten Fehler zurück und verwirft Ergebnisse von noch in der Ausführung befindlicher Arbeit. Es ist das richtige Primitiv, wenn das Fehlschlagen der gesamten Operation beim ersten Fehler korrekt ist – das Abrufen einer Datenseite, das Aufrufen einer Reihe unabhängiger Health-Checks, das Ausfahren einer Anfrage auf Shards.
Semaphore-Kanäle: Backpressure als Funktion
Ein gepufferter Kanal der Kapazität n ist eine 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 in der Ausführung sind
go func() {
defer wg.Done()
defer func() { <-sem }()
_ = process(ctx, item)
}()
}
wg.Wait()
Die Akquise erfolgt vor der go-Anweisung, daher werden Goroutinen nur erzeugt, 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 Sie Zulassungssteuerung benötigen, die die Gruppen nicht ausdrücken – kontextbewusste Akquise, Pools pro nachgelagertem System oder teilweise Ergebnisse bei Fehlschlag:
select {
case sem <- struct{}{}: // Slot akquiriert
case <-ctx.Done(): // Aufrufer hat aufgegeben, bevor er reinkam
return ctx.Err()
}
Gemessen am gleichen Arbeitslastprofil beenden sich der Semaphore-Kanal und SetLimit(8) innerhalb von 1,5 % voneinander – die Wahl zwischen ihnen ist Semantik, nicht Performance.
Worker-Pools: Für Ströme, nicht für Batches
Ein fester Pool aus langlebigen Workern 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 für Arbeit, die kontinuierlich eintrifft – Warteschlangen-Consumer, Polling-Schleifen, Request-Worker – wo eine pro-Batch-Gruppe endlos erstellt und abgebaut würde. Seit Go 1.25 entfernt sync.WaitGroup.Go den Boilerplate-Code für das Add/Done-Paar für die häufige Form. Der Shutdown-Vertrag ist der Teil, der richtig sein muss: Das Schließen von jobs beendet die Worker, nachdem die Warteschlange leer ist; das Abschalten von ctx verwerft die wartende Arbeit. Beides ist legitim, aber nur eines kann das dokumentierte Verhalten sein.
Begrenzte Warteschlangen: Blocken oder Verwerfen
Ein gepufferter Kanal ist auch eine Warteschlange mit einer Obergrenze. Sobald der Puffer voll ist, blockiert der Producer – und dieser Moment ist die Designentscheidung. Blocken leitet den Druck flussaufwärts an den weiter, der die Warteschlange speist; Verwerfen entlastet das System auf Kosten verlorener Arbeit. Eine Überwachungspipeline kann verwerfen; eine Bestellpipeline muss blocken oder in dauerhaften Speicher überlaufen.
select {
case queue <- event: // zulassungsrecht erteilt
default: // Warteschlange voll: Die Verwerfungslogik lebt hier
dropped.Add(1)
}
Egal welche Politik, machen Sie sie explizit und messbar. Eine stille default-Zweigung ist der Ort, an dem Ereignisse verschwinden.
Was die Begrenzung kostet – gemessen
Die gleiche Arbeitslast, 10.000 Aufgaben mit je 5 ms simulierter I/O, Go 1.27.1:
| Strategie | Spitzen-Goroutinen | Echtzeit |
|---|---|---|
Unbegrenztes go pro Aufgabe |
~10.000 gestartet | 18 ms |
errgroup.SetLimit(8) |
8 | 6,9 s |
| Semaphore-Kanal (Kapazität 8) | 8 | 6,6 s |
Der unbegrenzte Lauf gewinnt in der Echtzeit, weil nichts im Experiment Druck zurückgibt – zehntausend Timer-Goroutinen schlafen einfach parallel. Das ist genau die Falle. Die Kosten für unbegrenzten Fan-out sind nie die CPU in Ihrem eigenen Prozess; es sind zehntausend gleichzeitige Verbindungen, ein nachgelagertes System, das unter dem Burst anfängt zu timeouten, und ein Speichergraph, der der Tiefe der Warteschlange dessen folgt, den Sie aufrufen. Bei einer realen Abhängigkeit schließt der unbegrenzte Lauf nicht in 18 ms ab – er timeoutet. Die begrenzten Läufe zahlen 6,6 Sekunden, weil 10.000 Aufgaben ÷ 8 Slots × 5 ms 6,25 s unvermeidlicher Serialisierung entspricht, und die Obergrenze ist der Punkt: Sie verwandelt einen unkontrollierten Burst in ein vorhersehbares 6,25-Sekunden-Leeren.
Eine Verfeinerung ist wichtig, wenn die Obergrenze höher ist als eine Handvoll Slots: Halten Sie die Obergrenze auf oder unter dem, was das nachgelagerte System parallel unterstützen kann, und leiten Sie sie von dieser Grenze ab (Verbindungspool-Größe, Rate-Limit, Worker-Kapazität), anstatt von einem Gefühl über „vernünftige“ Parallelität.
Messen und Testen von begrenztem Code
Drei Signale decken den Großteil davon ab. Trends der Goroutine-Zählung (/sched/goroutines:goroutines, exportiert über den Prometheus Go Collector) fangen Leaks als Steigung ab. Scheduler-Latenz (/sched/latencies:seconds) fängt Sättigung ab – ausführungsbereite Goroutinen, die auf CPU-Zeit warten, sind echter Druck, unabhängig von der Zählung. Ein pprof-Delta zweier Goroutine-Dumps zeigt genau, wo die festgefahrenen Goroutinen leben.
Für die Tests sind begrenzte Worker der Fall, für den Testen von konkurrentem Go-Code mit synctest gebaut wurde: Künstliche Zeit macht einen kompletten Ablauf von 10.000 Aufgaben in Millisekunden laufen, synctest.Wait ersetzt Sleep-and-Hope für Quieszenz, und eine Blase schlägt schnell fehl, wenn ein Worker dauerhaft blockiert bleibt – das Leck-Beispiel oben stirbt in einer Testblase statt in der Produktion.
Auf Service-Ebene taucht dieselbe Begrenzungsfrage zwischen Services wieder auf, wo die Semaphore zu einem Rate-Limit wird und der Circuit Breaker das nachgelagerte System schützt – Circuit Breaker Muster in Go deckt diese Schicht ab, und Go Microservices für AI/ML-Orchestrierung zeigt die warteschlangengestü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[Obergrenze der Parallelität benötigt?] G -- ja --> H[errgroup.SetLimit] G -- nein --> I[Gruppe ohne Limit
nur wenn der Batch nachweisbar klein ist] C --> J[Volle Warteschlange: blocken oder verwerfen?] J -- blocken --> K[Backpressure flussaufwärts] J -- verwerfen --> L[Last abwerfen, Verwerfungen zählen]
Die Tabelle und das Flowchart komprimieren sich zu einer Gewohnheit: Bevor Sie go schreiben, benennen Sie die Obergrenze und den Besitzer. Die Obergrenze ist SetLimit, eine Semaphore oder eine begrenzte Warteschlange; der Besitzer ist ein Wait, ein ctx.Done-Arm auf jeder Kanaloperation, oder beides. Jeder Goroutine-Erstellungspunkt, der diese beiden Namen auf einen Blick benennen kann, ist einer, der niemanden um 3 Uhr morgens anruft.