Concurrency in Go begrenzen: errgroup, Worker Pools und Backpressure

Begrenze die parallele Arbeit, statt sie einfach abzubrechen.

Inhaltsverzeichnis

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.

glasartiger Trichter, der einen Fluss leuchtender Partikel durch ein reguliertes Gate drosselt

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

flowchart TD A[Arbeit zur gleichzeitigen Ausführung] --> B[Fester Batch oder kontinuierlicher Stream?] B -- kontinuierlicher Stream --> C[Worker-Pool oder begrenzte Warteschlange] B -- fester Batch --> D[Teilweise Ergebnisse benötigt
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.

Abonnieren

Neue Beiträge zu Systemen, Infrastruktur und KI-Engineering.