Ograniczanie współbieżności w Go: errgroup, pula workerów i backpressure

Ograniczaj pracę w toku, a nie tylko jej anulowanie.

Page content

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.

szklany lejek ograniczający przepływ świecących cząstek przez regulowaną bramę

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

flowchart TD A[Praca do wykonania równolegle] --> B{Stała partia czy ciągły strumień?} B -- ciągły strumień --> C[Pula pracowników lub ograniczona kolejka] B -- stała partia --> D{Potrzebne częściowe rezultaty
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.

Przydatne linki

Subskrybuj

Otrzymuj nowe wpisy o systemach, infrastrukturze i inżynierii AI.