GoprogramowanieStarszy programista backendu w Go

W jaki sposób scheduler **Go** zapobiega głodzeniu innych działających goroutines przez jedną goroutine związaną z CPU, nie polegając na systemie operacyjnym?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Scheduler Go stosuje hybrydowy model multitaskingu współpracy i preempcji, aby zapobiec głodzeniu bez interwencji systemu operacyjnego. Od wersji 1.14 czas wykonania wstrzykuje asynchroniczne punkty preempcji, wysyłając sygnały SIGURG do wątków uruchamiających goroutines, które przekroczyły swój czas wykonywania (zazwyczaj 10 ms). Gdy obsługa sygnałów wykryje bezpieczny punkt — na przykład, gdy goroutine ma zamiar wywołać funkcję lub uzyskać dostęp do stosu — scheduler zapisuje kontekst i przełącza się na inną uruchamialną goroutine. Ten mechanizm zapewnia, że nawet ściśle związane z CPU pętle bez wywołań funkcji nie mogą nieprzerwanie monopolizować Procesora (P).

Sytuacja z życia

Nasza platforma do handlu o wysokiej częstotliwości doświadczyła katastrofalnych skoków latencji podczas zmienności na rynku, gdy jedna z goroutines analitycznych wykonujących złożone symulacje Monte Carlo powodowała zamrażanie procesów przetwarzania zamówień na setki milisekund. Problem wynikał z tego, że goroutine wykonywała ścisłą matematyczną pętlę bez wywołań funkcji, uniemożliwiając schedulerowi jej wstrzymanie przed wersją Go 1.14.

Oceniliśmy trzy wyraźne podejścia, aby rozwiązać tę rywalizację. Pierwsza opcja polegała na ręcznym wstawianiu wywołań runtime.Gosched() w pętlach symulacji. To podejście zapewniło natychmiastową ulgę, ale wprowadziło znaczne koszty utrzymania i wymagało, aby programiści posiadali głęboką wiedzę o schedulerze, co stworzyło delikatny kod, który mógłby cofnąć się w wyniku refaktoryzacji.

Drugie rozwiązanie polegało na izolacji obciążenia analitycznego w osobnej mikroserwisie z ograniczeniami CPU. Chociaż zapewniało to twardą izolację i niezależne skalowanie, nadmiarowe koszty serializacji w sieci oraz dodatkowa latencja komunikacji międzyprocesowej naruszały nasze wymagania dotyczące latencji sub-milisekundowej dla obliczeń ryzyka.

Ostatecznie zdecydowaliśmy się na aktualizację środowiska czasowego do Go 1.20 i wyraźne dostrojenie GOMAXPROCS do fizycznych rdzeni CPU. Ta aktualizacja zapewniła asynchroniczną preempcję za pomocą sygnałów, umożliwiając schedulerowi przymusowe zwolnienie goroutines związanej z CPU co 10 ms bez modyfikacji kodu. Metryki po wdrożeniu pokazały stabilizację latencji P99 na poziomie 8 ms podczas szczytowego obciążenia, eliminując kaskady czasowego wyprzedzenia i zachowując prostotę architektury jednego procesu.

Co często przegapią kandydaci

Dlaczego ścisła pętla bez wywołań funkcji powoduje problemy z harmonogramowaniem w starszych wersjach Go, a nie w nowszych?

Przed Go 1.14 scheduler polegał wyłącznie na współpracy w preempcji, co oznaczało, że goroutines dobrowolnie zwalniały tylko podczas wywołań funkcji, operacji na kanałach lub kontencji mutexów. Ścisła pętla wykonująca czyste operacje arytmetyczne nigdy nie osiągała bezpiecznego punktu, skutecznie monopolizując swój Procesor (P) do momentu zakończenia. Nowoczesny Go wykorzystuje asynchroniczną preempcję, wysyłając sygnały SIGURG do wątku, co wyzwala przełączenie kontekstu w następnym bezpiecznym punkcie, niezależnie od tego, czy występuje wywołanie funkcji.

Jak scheduler Go decyduje, która goroutine będzie działać następnie, gdy Procesor (P) stanie się dostępny?

Scheduler wdraża algorytm kradzieży pracy, który najpierw sprawdza lokalną kolejkę uruchamiania bieżącego P, a następnie próbuje ukraść połowę goroutines z lokalnej kolejki innego P przy użyciu zrandomizowanego indeksu startowego w celu zmniejszenia rywalizacji. Jeśli lokalne kolejki są puste, sprawdza globalną kolejkę uruchamiania co 61 tick scheduler, aby zapobiec głodzeniu nowo utworzonych goroutines. Ta hierarchiczna selekcja minimalizuje koszty synchronizacji, jednocześnie zapewniając równoważenie obciążenia we wszystkich dostępnych wątkach Maszyny (M).

Co się dzieje z Procesorem (P), gdy goroutine wykonuje blokującą funkcję syscall, taką jak operacja I/O pliku?

Kiedy goroutine blokuje na syscall, czas wykonania Go natychmiast odłącza wątek Maszyny (M) od jego P i przypisuje to P nowemu lub bezczynnym M, co pozwala innym goroutines kontynuować wykonywanie na tej samej abstrakcji wątku OS. Oryginalna M wchodzi w syscall i czeka na zakończenie operacji przez jądro; po powrocie próbuje ponownie przejąć swoje pierwotne P lub parkuje się, jeśli P jest teraz przypisane do innego wątku. Ta multiplikacja M:N zapobiega bezczynności wątków OS podczas I/O, utrzymując wysokie wykorzystanie CPU w tysiącach goroutines.