Worker Pool in Go: Goroutine-Limits, Ergebnisse und sichere Stornierung
1. Übersicht
Goroutines in Go sind sehr billig, daher ist es üblich, einfach go für jede Aufgabe aufzurufen. Wenn die Zahl der Aufgaben in die Zehntausende geht und jede Aufgabe eine Datenbank oder HTTP aufruft, erschöpft dieses Vorgehen leicht den Connection Pool oder überschreitet das Rate Limit des externen Dienstes.
Dieser Artikel zeigt dir, wie man einen Worker Pool baut – eine feste Anzahl von Goroutines, die Jobs von einem Channel erhalten. Nach dem Lesen wirst du wissen, wie man einen einfachen Pool schreibt, Ergebnisse und Fehler sammelt, ihn mit context abbricht, mit errgroup.SetLimit vereinfacht und testet, dass der Pool tatsächlich die Parallelität begrenzt.
2. Das Problem: unbegrenzte Goroutines
Betrachte diesen Code zur Verarbeitung einer Bestellliste:
for _, o := range orders {
go chargeOrder(o) // 50.000 Bestellungen -> 50.000 Goroutines rufen Payment API auf
}
Jede Goroutine verbraucht nur wenige KB Speicher, also liegt das Problem normalerweise nicht beim RAM. Das Problem liegt bei den dahinter liegenden Ressourcen:
- Der Connection Pool der Datenbank hat nur etwa 20–50 Verbindungen.
- Das Payment Gateway begrenzt die Zahl der Anfragen pro Sekunde.
- Die Zahl der Datei-Deskriptoren eines Prozesses ist begrenzt.
Der Worker Pool löst das, indem er zwei Konzepte trennt. Die Zahl der Aufgaben kann sehr groß sein, aber die Zahl der gleichzeitig laufenden Aufgaben wird durch die Zahl der Worker begrenzt.
3. Basis-Worker-Pool
Das Modell besteht aus drei Teilen:
- Ein Channel
jobsmit zu erledigenden Aufgaben. - N Goroutines als Worker, jeder looped über den Channel.
- Eine
sync.WaitGroupzum Warten auf das Ende aller Worker.
package main
import (
"fmt"
"sync"
"time"
)
type Order struct {
ID int
Amount float64
}
func worker(id int, orders <-chan Order, wg *sync.WaitGroup) {
defer wg.Done()
for o := range orders {
time.Sleep(100 * time.Millisecond) // simuliert Payment API Aufruf
fmt.Printf("worker %d verarbeitet Bestellung %d (%.0f)\n", id, o.ID, o.Amount)
}
}
func main() {
orders := make(chan Order)
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
wg.Add(1)
go worker(i, orders, &wg)
}
start := time.Now()
for i := 1; i <= 9; i++ {
orders <- Order{ID: i, Amount: float64(i) * 10}
}
close(orders)
wg.Wait()
fmt.Println("fertig nach", time.Since(start).Round(100*time.Millisecond))
}
Der Code funktioniert folgendermaßen:
orders <-chan Orderist ein Nur-Empfangs-Channel vom Standpunkt des Worker. Der Compiler blockiert den Worker, wenn er versucht, auf den Channel zu schreiben oder ihn zu schließen.for o := range ordersbeendet sich automatisch, wenn der Channel geschlossen ist und keine Elemente mehr vorhanden sind.close(orders)ist das Signal “Arbeit erledigt”. Wenn du diese Zeile vergisst, wartet der Worker ewig undwg.Wait()erzeugt einen Deadlock.wg.Add(1)muss vorgo worker(...)aufgerufen werden, nicht innerhalb der Goroutine.
Beispielausgabe (die Reihenfolge der Zeilen kann unterschiedlich sein):
worker 3 verarbeitet Bestellung 1 (10)
worker 1 verarbeitet Bestellung 2 (20)
worker 2 verarbeitet Bestellung 3 (30)
worker 2 verarbeitet Bestellung 4 (40)
...
fertig nach 300ms
9 Bestellungen geteilt durch 3 Worker, jede Bestellung dauert 100ms, also dauert die Gesamtzeit etwa 300ms statt 900ms mit sequentieller Verarbeitung.
Hinweis: Ab Go 1.25 kannst du
wg.Go(func() { worker(i, orders) })schreiben. Diese Methode ruft automatischAddundDoneauf und vermeidet damit ein Vergessen.
4. Ergebnisse und Fehler sammeln
In der Praxis musst du normalerweise wissen, welche Bestellung erfolgreich war und welche einen Fehler hatte. Dazu fügen wir einen Channel results hinzu:
package main
import (
"errors"
"fmt"
"sync"
)
type Order struct {
ID int
Amount float64
}
type Result struct {
OrderID int
Fee float64
Err error
}
func chargeOrder(o Order) (float64, error) {
if o.Amount <= 0 {
return 0, errors.New("ungültiger Betrag")
}
return o.Amount * 0.02, nil // 2% Gebühr
}
func main() {
orders := []Order{{1, 100}, {2, 0}, {3, 250}, {4, 80}}
jobs := make(chan Order)
results := make(chan Result)
var wg sync.WaitGroup
for range 3 {
wg.Add(1)
go func() {
defer wg.Done()
for o := range jobs {
fee, err := chargeOrder(o)
results <- Result{OrderID: o.ID, Fee: fee, Err: err}
}
}()
}
go func() {
for _, o := range orders {
jobs <- o
}
close(jobs)
}()
go func() {
wg.Wait()
close(results)
}()
for r := range results {
if r.Err != nil {
fmt.Printf("Bestellung %d Fehler: %v\n", r.OrderID, r.Err)
continue
}
fmt.Printf("Bestellung %d Gebühr %.2f\n", r.OrderID, r.Fee)
}
}
Die Ausgabe sieht etwa so aus:
Bestellung 1 Gebühr 2.00
Bestellung 3 Gebühr 5.00
Bestellung 2 Fehler: ungültiger Betrag
Bestellung 4 Gebühr 1.60
Es gibt drei wichtige Punkte zu beachten:
- Das Versenden von Jobs läuft in einer eigenen Goroutine. Wenn
maingleichzeitig Jobs versendet und Ergebnisse liest, wird der Worker beiresults <-blockiert, währendmainbeijobs <-blockiert wird. Das Ergebnis ist ein Deadlock. - Die dritte Goroutine wartet auf
wg.Wait()und schließt dannresults. Erst wenn alle Worker fertig sind, ist sicher, dass niemand mehr inresultsschreibt. Die allgemeine Regel: nur die Seite, die sendet, schließt den Channel. - Fehler werden in
Resultverpackt statt den ganzen Pool zu stoppen. Der Fehler einer Bestellung sollte andere Bestellungen nicht blockieren.
Die Reihenfolge der Ergebnisse entspricht nicht der Eingabereihenfolge. Wenn du die Reihenfolge erhalten musst, nimm den Index mit im Job oder verwende die Methode aus Abschnitt 6.
5. Pool mit Context stornieren
Wenn eine HTTP-Anfrage storniert wird oder der Timeout überschritten wird, muss der Pool anhalten statt weiterhin die Payment API aufzurufen. Das machst du, indem der Worker auf ctx.Done() hört:
func worker(ctx context.Context, jobs <-chan Order, results chan<- Result) {
for {
select {
case <-ctx.Done():
return
case o, ok := <-jobs:
if !ok {
return
}
fee, err := chargeOrder(o)
select {
case results <- Result{OrderID: o.ID, Fee: fee, Err: err}:
case <-ctx.Done():
return
}
}
}
}
Die Producer-Seite braucht auch ein ähnliches select:
go func() {
defer close(jobs)
for _, o := range orders {
select {
case jobs <- o:
case <-ctx.Done():
return
}
}
}()
Und erstelle ein Context mit Timeout an der Aufrufstelle:
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
Der Grund für select in beiden Senderichtungen: Wenn der Worker ctx nur beim Empfangen von Jobs prüft, kann er trotzdem bei results <- steckenbleiben, während die lesende Seite bereits weg ist. Das ist eine häufige Quelle für Goroutine-Lecks.
Hinweis:
selecthilft dem Worker nur, zwischen Jobs auszusteigen. WennchargeOrdereinen HTTP-Aufruf macht, musst ductxdort übergeben (http.NewRequestWithContext), um die laufende Anfrage abbrechen zu können.
6. Mit errgroup.SetLimit vereinfachen
Channel, WaitGroup und context von Hand zu schreiben ist fehleranfällig. Das Paket errgroup hat SetLimit, mit dem du einen Pool mit Limit in wenigen Zeilen erstellen kannst. Zuerst die Abhängigkeit hinzufügen:
go get golang.org/x/sync/errgroup
Diese generische Funktion verarbeitet ein Slice mit maximal limit Goroutines, erhält die Ergebnisreihenfolge und stoppt beim ersten Fehler:
package pool
import (
"context"
"golang.org/x/sync/errgroup"
)
func Process[T, R any](
ctx context.Context,
limit int,
items []T,
fn func(context.Context, T) (R, error),
) ([]R, error) {
results := make([]R, len(items))
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(limit)
for i, item := range items {
g.Go(func() error {
r, err := fn(ctx, item)
if err != nil {
return err
}
results[i] = r
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
Der Code funktioniert so:
g.SetLimit(limit)lässtg.Goblockieren, wenn bereitslimitGoroutines laufen. Vom Durchsatz her ist das gleichwertig mit einem Worker Pool.errgroup.WithContextgibt einctxzurück, das sofort abgebrochen wird, wenn eine Funktion einen Fehler zurückgibt. Andere Aufgaben, diectxverwenden, bekommen das Stopsignal.results[i] = rist sicher bei gleichzeitiger Ausführung, da jede Goroutine in einen eigenen Index schreibt. Kein Mutex nötig in diesem Fall.- Ab Go 1.22 werden die Schleifenvariablen
iunditembei jeder Iteration neu erstellt, daher hat die Closure nicht mehr das Problem mit gemeinsamen Variablen wie in älteren Versionen.
Der Aufruf sieht so aus:
fees, err := pool.Process(ctx, 5, orders, func(ctx context.Context, o Order) (float64, error) {
return paymentService.Charge(ctx, o)
})
Der Unterschied zum selbstgeschriebenen Pool: errgroup erstellt eine neue Goroutine für jeden Item statt N Worker wiederverwendet zu werden. Bei den meisten Anwendungen ist dieser Unterschied unbedeutend. Wenn du brauchst, dass Worker einen eigenen Zustand behalten, z. B. jeder Worker hat eine Verbindung, dann schreib den Pool selbst wie in Abschnitt 3–5.
7. Worker Pool testen
Du solltest zwei Hauptverhalten testen. Erstens: Der Pool überschreitet limit niemals. Zweitens: Fehler werden korrekt zurückgegeben. Um die Parallelität zu messen, verwenden wir atomic.Int32:
package pool
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
)
func TestProcess_LimitsConcurrencyAndKeepsOrder(t *testing.T) {
var running, maxSeen atomic.Int32
items := []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 10}
got, err := Process(context.Background(), 3, items, func(_ context.Context, n int) (int, error) {
cur := running.Add(1)
defer running.Add(-1)
for {
m := maxSeen.Load()
if cur <= m || maxSeen.CompareAndSwap(m, cur) {
break
}
}
time.Sleep(10 * time.Millisecond)
return n * 2, nil
})
if err != nil {
t.Fatalf("Fehler nicht erwartet: %v", err)
}
if maxSeen.Load() > 3 {
t.Errorf("Gleichzeitig laufend: %d, Limit ist 3", maxSeen.Load())
}
want := []int{2, 4, 6, 8, 10, 12, 14, 16, 18, 20}
for i := range want {
if got[i] != want[i] {
t.Fatalf("got[%d] = %d, want %d", i, got[i], want[i])
}
}
}
func TestProcess_ReturnsFirstError(t *testing.T) {
errDeclined := errors.New("Karte abgelehnt")
_, err := Process(context.Background(), 2, []int{1, 2, 3}, func(_ context.Context, n int) (int, error) {
if n == 2 {
return 0, errDeclined
}
return n, nil
})
if !errors.Is(err, errDeclined) {
t.Fatalf("errDeclined erwartet, got %v", err)
}
}
Teste mit Race Detector:
go test -race ./...
ok example.com/shop/pool 0.061s
Die CompareAndSwap-Schleife aktualisiert maxSeen ohne Sperre. Das Flag -race ist bei gleichzeitigem Code Pflicht, da es Zugriffe auf gemeinsame Daten findet, die leicht übersehen werden.
8. Häufige Fehler und wann man keinen Pool braucht
Häufige Fehler:
close(jobs)vergessen: Worker beendet sich nie, Programm zeigtfatal error: all goroutines are asleep - deadlock!.- Channel auf der Empfängerseite schließen, oder zweimal schließen:
panic: close of closed channel. wg.Addinnerhalb der Goroutine aufrufen:Waitkönnte zurückkehren, bevor die GoroutineAddaufruft.- Job senden und Ergebnis lesen auf der gleichen Goroutine mit unbufferederm Channel: verursacht Deadlock wie in Abschnitt 4 gezeigt.
- Nicht auf
ctx.Done()beim Ergebnis senden achten: führt zu Goroutine-Lecks, wenn die Leseseite schon weg ist. - Zahl der Worker willkürlich wählen: Bei CPU-gebundenen Aufgaben nutze
runtime.NumCPU(). Bei I/O-gebundenen Aufgaben, orientiere dich an den Grenzen der Ressource hinten, z. B. Connection-Pool-Größe.
Wann man keinen Worker Pool braucht:
- Die Zahl der Aufgaben ist klein und bekannt, z. B. 3 Services parallel aufrufen. Hier reicht
errgroupohneSetLimit. - Aufgaben sind extrem schnell, nur wenige Mikrosekunden. Der Overhead durch den Channel könnte größer sein als die Arbeit selbst; sequentiell oder Batch-Verarbeitung ist effizienter.
- Du musst nach Rate begrenzen (Anfragen/Sekunde), nicht nach Zahl gleichzeitig. Dafür brauchst du einen Rate Limiter, z. B.
golang.org/x/time/rate. Der Worker Pool ersetzt ihn nicht.
9. Fazit
Der Worker Pool hilft dir, die Parallelität in Go unter Kontrolle zu halten: Die Zahl der Aufgaben kann riesig sein, aber die Ressourcen dahinter bleiben geschützt. Der Artikel hat diese Schritte durchgegangen:
- Einfacher Pool mit Channel und
sync.WaitGroup. - Ergebnisse und Fehler über einen
results-Channel sammeln, Channel an der richtigen Stelle schließen. - Sicher mit
contextin beiden Senderichtungen abbrechen. - Mit
errgroup.SetLimitund einer generischenProcess-Funktion vereinfachen. - Parallelitätslimit und Fehlerbehandlung mit
go test -racetesten.
Für die meisten Fälle ist errgroup.SetLimit die einfachste und fehlerunanfälligste Wahl. Schreib den Pool selbst nur, wenn Worker einen eigenen Zustand brauchen oder wenn du feingranulare Kontrolle über Job- und Ergebnisfluss brauchst.