Ograniczanie współbieżności w Go: errgroup, pula workerów i backpressure
Ograniczaj pracę w toku, a nie tylko jej anulowanie.
Nielimitowana fan-out jest domyślnym trybem awarii w konkurencyjności Go: każde wywołanie go oznacza jedno dodatkowe zadanie w toku, bez limitu i bez właściciela. Ten artykuł dotyczy nadania obu tych elementów kontroli.
Anulowanie i ograniczanie to osobne mechanizmy sterowania. Anulowanie decyduje, kiedy praca się zatrzymuje; ograniczanie decyduje, ile z niej istnieje jednocześnie. Usługa może anulować pracę w sposób doskonały, a mimo to upaść, ponieważ dziesięć tysięcy goroutine niezależnie zdecydowało, że właśnie teraz to dobry moment na wywołanie bazy danych.

Strony otaczające tę pozycję poruszają drugą połowę problemu. Go context.Context Done Right traktuje kontekst jako płaszczyznę sterowania do zatrzymywania pracy; tutaj przedmiotem jest płaszczyzna pojemności — errgroup.SetLimit, kanały sygnalizacyjne, puli pracowników i ograniczone kolejki — oraz jeden kształt wycieku, który przetrwa errgroup.Wait, nawet gdy każda ścieżka anulowania wygląda na poprawnie spiętą.
Pięć prymitywów, jedna tabela decyzyjna
| Prymityw | Częściowe rezultaty | Natychmiastowa awaria | Backpressure | Dynamiczna praca | Najlepsze zastosowanie |
|---|---|---|---|---|---|
sync.WaitGroup + errors.Join |
tak | nie | nie | nie | partie wait-all, gdzie liczy się każdy rezultat |
errgroup.WithContext |
nie | tak | nie | nie | fan-out, w którym pierwszy błąd kończy żądanie |
errgroup.SetLimit(n) |
nie | tak | tak (blokuje Go) |
nie | ograniczony fail-fast fan-out |
| Kanał sygnalizacyjny | tak | ręcznie | tak (blokuje nabycie) | tak | niestandardowa kontrola dopuszczenia |
| Pula pracowników | tak | ręcznie | tak (kolejka zapełnia się) | tak | ciągłe strumienie, długoterminowi pracownicy |
Dwa pytania wybierają wiersz. Pierwsze: gdy jedno zadanie zawiedzie, czy nadal chcesz rezultatów pozostałych? Jeśli tak, semantyka grupy z natychmiastową awarią (fail-fast) wyrzuci pracę, której potrzebujesz. Drugie: czy praca napływa ciągłe, czy to ustalona partia, którą można policzyć przed uruchomieniem? Grupy obsługują partie; pulie i kolejki obsługują strumienie.
errors.Join to czynnik, który czyni zwykły wiersz WaitGroup wykonalnym — zbiera on błąd każdego pracownika zamiast zachowywać tylko pierwszy, co łączy się z zasadami translacji granic w Architektura obsługi błędów w Go.
Wyciek, który przetrwa errgroup.Wait
Najbardziej znany tryb awarii errgroup nie ma nic wspólnego z ograniczaniem. To luka w anulowaniu, ukryta za kodem, który wygląda na poprawny:
g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // bez bufora
go func() { // producent — nikt nie jest właścicielem tej goroutine
for i := 0; i < 100; i++ {
results <- i
}
close(results)
}()
g.Go(func() error { // konsument — członek grupy
for r := range results {
if r == 5 {
return fmt.Errorf("zapis nie powiódł się")
}
}
return nil
})
err := g.Wait() // zwraca przy błędzie konsumenta
Gdy konsument zwraca, gctx jest anulowane, a Wait zwraca wartość. Producent jest zablokowany na results <- i — a anulowanie kontekstu nie odblokuje wysyłania do kanału. Nikt nie jest właścicielem tej goroutine, więc pozostaje zablokowany przez cały czas trwania procesu. Wykonanie tego wzorca w pętli powoduje wyciek dokładnie liniowy:
| Wariant | Żywych goroutine po 500 iteracjach |
|---|---|
Wysłanie bez ramienia ctx.Done |
501 (+500 wyciekniętych, po jednym na iterację) |
Wysłanie zawarte w select z <-gctx.Done() |
502 (+1 bazowa) |
// naprawa: każda operacja na kanale wewnątrz zadania, które można anulować,
// otrzymuje ramię Done
select {
case results <- i:
case <-gctx.Done():
return
}
Ta sama luka istnieje po stronie odbioru. Zasada jest mechaniczna: każda operacja na kanale w kodzie, który może zostać anulowany, otrzymuje ramię <-ctx.Done(), a każda goroutine ma właściciela, który na nią czeka lub ją anuluje. goleak w CI łapie przypadki, które przegapia review — mieści się on w tym samym automatycznym brame jakości co narzędzia w Lintery Go: Istotne Narzędzia dla Jakości Kodu.
Warto nazwać dwa powiązane kształty. Jeśli producent jest członkiem grupy, ten sam kod powoduje nie wyciek, ale deadlock — Wait blokuje się na zawsze, czekając na zablokowane wysłanie, co co najmniej sprawia, że awaria jest głośna. A producent, który wysyła przez select, ale odbiera na kanale, którego konsument zakończył pracę, wycieka w ten sam sposób; ramiona Done należą po obu stronach.
errgroup.SetLimit: ograniczony fail-fast fan-out
SetLimit zamienia errgroup w grupę z kontrolowaną admissão. Każde wywołanie Go powyżej limitu blokuje się do momentu, aż zwolni się slot:
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
g.Go(func() error {
return process(ctx, item)
})
}
err := g.Wait()
Trzy znaczenia są ważne w środowisku produkcyjnym. Go blokuje się, więc powyższa pętla stosuje backpressure do producenta — to zwykle tego chcesz, ale oznacza to, że goroutine wywołującego może się zatrzymać, więc obsługa żądania karmiąca dużą partią potrzebuje własnego budżetu czasu na cały cykl pętli. SetLimit(0) blokuje każde wywołanie Go na zawsze. A zmiana limitu podczas aktywności goroutine powoduje panic — limit jest ustalony na czas życia grupy.
SetLimit dziedziczy semantykę grupy: pierwszy błąd anuluje kontekst, a Wait zwraca pierwszy błąd, odrzucając rezultaty pracy wciąż w toku. Jest to odpowiedni prymityw, gdy zawalenie całej operacji przy pierwszym błędzie jest poprawne — pobieranie jednej strony danych, wywoływanie zestawu niezależnych testów zdrowia, rozsyłanie żądania do shardów.
Kanały sygnalizacyjne: backpressure jako cecha
Zbuforowany kanał pojemności n to sygnalizacja bez zależności:
sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // blokuje się, gdy 8 zadań jest w toku
go func() {
defer wg.Done()
defer func() { <-sem }()
_ = process(ctx, item)
}()
}
wg.Wait()
Nabycie (acquire) dzieje się przed instrukcją go, więc goroutine są tworzone tylko wtedy, gdy istnieje slot. Rozmiar bufora jest umową: jest jednocześnie sufitem konkurencyjności i kolejką oczekujących na dopuszczenie.
Prymityw zdobywa swoje miejsce, gdy potrzebujesz kontroli dopuszczenia, której grupy nie wyrażają — nabycie świadome kontekstu, puli per zleceniodawca lub częściowe rezultaty przy awarii:
select {
case sem <- struct{}{}: // slot nabyty
case <-ctx.Done(): // wywołujący zrezygnował przed wejściem
return ctx.Err()
}
Pomierzone na tym samym obciążeniu, kanał sygnalizacyjny i SetLimit(8) kończą się w granicach 1,5% różnicy — wybór między nimi to semantyka, a nie wydajność.
Pule pracowników: dla strumieni, nie dla partii
Stała pula długoterminowych pracowników oddziela dopuszczenie (kolejkę) od wykonania (pracowników):
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
}
Pule pasują do pracy napływającej ciągle — konsumentów kolejków, pętli pollingowych, pracowników żądań — gdzie grupa per partia byłaby nieustannie tworzona i niszczone. Od Go 1.25, sync.WaitGroup.Go usuwa szablony parowania Add/Done dla wspólnego kształtu. Umowa wyłączenia to ta część, którą trzeba zrobić dobrze: zamykanie jobs kończy pracę pracowników po opróżnieniu kolejki; anulowanie ctx porzuca pracę w kolejce. Oba są legalne, ale tylko jedno może być udokumentowanym zachowaniem. Go 1.27 kontynuuje w tym samym kierunku — metody generyczne, nowe modernizatory go fix i pamięciowy httptest.NewTestServer — wszystko to opisano w Przewodnik po aktualizacji do Go 1.27.
Ograniczone kolejki: blokować czy porzucać
Zbuforowany kanał to również kolejka z limitem. Gdy bufor zapełni się, producent blokuje — i ten moment to decyzja projektowa. Blokowanie propaguje presję w górę do tego, kto karmi kolejkę; porzucanie (dropping) zrzuci obciążenie kosztem utraconej pracy. Potok monitoringu może porzucać; potok zamówień musi blokować lub przenosić do trwałej pamięci.
select {
case queue <- event: // dopuszczone
default: // kolejka pełna: polityka porzucania mieszka tutaj
dropped.Add(1)
}
Niezależnie od polityki, uczynij ją jawną i mierzalną. Cicha gałąź default to miejsce, w którym zdarzenia znikają.
Czego kosztuje ograniczanie — pomierzone
To samo obciążenie, 10 000 zadań po 5 ms symulowanego I/O każde, Go 1.27.1:
| Strategia | Szczyt goroutine | Czas rzeczywisty |
|---|---|---|
Nielimitowane go per zadanie |
~10 000 uruchomionych | 18 ms |
errgroup.SetLimit(8) |
8 | 6,9 s |
| Kanał sygnalizacyjny (pojemność 8) | 8 | 6,6 s |
Uruchomienie nielimitowane wygrywa czasem rzeczywistym, ponieważ nic w eksperymencie nie wywiera presji — dziesięć tysięcy goroutine z timerem po prostu śpi w równolegle. To dokładnie ta pułapka. Koszt nielimitowanego fan-out nigdy nie leży w CPU we własnym procesie; to dziesięć tysięcy równoczesnych połączeń, zleceniodawca zaczynający zgłaszać timeouty pod obciążeniem i wykres pamięci, który podąża za głębokością kolejki tego, kogo wywołujesz. W przypadku prawdziwej zależności uruchomienie nielimitowane nie kończy się w 18 ms — zgłasza timeout. Uruchomienia z ograniczeniami płacą 6,6 sekundy, ponieważ 10 000 zadań ÷ 8 slotów × 5 ms to 6,25 s nieuchronnej serializacji, a limit jest sednem: zamienia niekontrolowany wysyp w przewidywalny spadek 6,25-sekundowy.
Jedno udoskonalenie ma znaczenie, gdy limit jest wyższy niż kilka slotów: trzymaj limit na poziomie lub poniżej tego, co zleceniodawca może utrzymać równolegle, i wyprowadzaj go z tego limitu (rozmiar puli połączeń, limit stawki, pojemność pracowników), a nie z poczucia „rozsądnej” równoległości.
Mierzenie i testowanie kodu z ograniczeniami
Trzy sygnały pokrywają większość przypadków. Tendencje liczby goroutine (eksportowane przez zbieracz Go Prometheus jako /sched/goroutines:goroutines) łapią wycieki jako nachylenie. Opóźnienia planisty (/sched/latencies:seconds) łapią nasycenie — goroutine gotowe do uruchomienia czekające na czas CPU to realna presja, niezależnie od licznika. Różnica pprof między zrzutami goroutine wskazuje, gdzie mieszkają zablokowane goroutine.
Dla testów, ograniczeni pracownicy to przypadek, na który zaprojektowano Testowanie konkurencyjnego kodu Go za pomocą synctest: fałszywy czas sprawia, że pełne opróżnienie 10 000 zadań trwa milisekundy, synctest.Wait zastępuje spanie i liczenie na spokój, a bańka zawodzi szybko, gdy pracownik pozostaje trwale zablokowany — reprodukcja wycieku powyżej ginie w bańce testowej, a nie w produkcji.
W skali usługi to samo pytanie o ograniczanie pojawia się między usługami, gdzie sygnalizacja staje się limitem stawki, a wyzwalacz obwodowy (circuit breaker) chroni zleceniodawcę — Wzór wyzwalacza obwodowego w Go omawia tę warstwę, a Mikroserwisy Go dla Orkiestracji AI/ML pokazuje formy oparte na kolejkach, jakie przybiera warstwa orkiestracji.
Wybieranie
przy pierwszej awarii?} D -- tak --> E[WaitGroup + errors.Join
lub kanał sygnalizacyjny] D -- nie --> F[errgroup.WithContext] F --> G{Potrzebny limit konkurencyjności?} G -- tak --> H[errgroup.SetLimit] G -- nie --> I[grupa bez limitu
tylko gdy partia jest udowodniona jako mała] C --> J{Pełna kolejka: blokować czy porzucać?} J -- blokować --> K[Backpressure w górę] J -- porzucać --> L[Zrzuć obciążenie, policz porzucenia]
Tabela i diagram sprowadzają się do jednego nawyku: zanim napiszesz go, nazwij limit i właściciela. Limitem jest SetLimit, sygnalizacja lub ograniczona kolejka; właścicielem jest Wait, ramię ctx.Done na każdej operacji kanałowej, lub oba. Każde miejsce tworzenia goroutine, które może odpowiedzieć na te dwa nazwy jednym spojrzeniem, to takie, które nie obudzi nikogo o 3 nad ranem.