GoprogramowanieSenior Go Developer

Oceń mechanizm, za pomocą którego środowisko uruchomieniowe Go odzyskuje nadmiar pamięci stosu goroutine, określając próg wykorzystania, który wyzwala deallokację oraz ostateczny los uwolnionych obszarów?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie.

Historia pytania

Przed wersją Go 1.3, środowisko uruchomieniowe stosowało segmentowane stosy, które dzieliły się na połączone kawałki na granicach wywołań funkcji. Ten projekt powodował poważne „gorące skoki” wydajności, gdy granica stosu była często przekraczana podczas wąskich pętli. Go 1.3 zastąpiło to ciągłymi stosami, które są kopiowane do większych, pojedynczych ciągłych obszarów podczas wzrostu. Jednak wczesne implementacje ciągłych stosów nigdy nie uwalniały pamięci z powrotem do sterty, co powodowało trwały wzrost RSS dla goroutine, które przejściowo wymagały głębokich stosów wywołań podczas inicjalizacji lub przetwarzania wsadowego. Go 1.5 wprowadziło automatyczne kurczenie stosu w celu odzyskania niewykorzystanej pamięci stosu podczas cykli zbierania śmieci, kończąc cykl zarządzania pamięcią dla stosów goroutine.

Problem

Bez mechanizmu kurczenia, goroutine, która tymczasowo wchodzi w głęboką rekurencję (np. przetwarzanie głęboko zagnieżdżonego dokumentu JSON lub przeszukiwanie skomplikowanego drzewa zależności), zatrzymywałaby swoją maksymalną alokację stosu na zawsze, nawet po powrocie do bezczynnej pętli zdarzeń. Prowadzi to do puchnięcia pamięci w długoterminowych aplikacjach, szczególnie tych używających puli roboczych, gdzie goroutine przeplatają się między zadaniami o wysokim stosie a stanami bezczynności. Wyzwanie polega na bezpiecznym identyfikowaniu, kiedy stos jest rzeczywiście niedostatecznie wykorzystywany, oraz przenoszeniu aktywnych ramek do mniejszego obszaru pamięci bez uszkadzania bieżących obliczeń, wskaźników alokowanych na stosie ani naruszania wymagań ABI dla konwencji wywołań.

Rozwiązanie

Środowisko uruchomieniowe Go kurczy stosy podczas fazy znaku GC, gdy skanowane są zestawy korzeni. Bada zużycie stosu każdego goroutine; jeśli wskaźnik high-water mark wykorzystywanego segmentu spada poniżej jednej czwartej (25%) aktualnie zaalokowanego rozmiaru stosu, środowisko uruchomieniowe alokuje nowy stos o połowę mniejszy niż obecny (ale nigdy mniejszy niż minimalne 2KB). Następnie środowisko uruchomieniowe asynchronicznie zatrzymuje docelowy goroutine w bezpiecznym miejscu, kopiuje żywe ramki stosu do nowego, mniejszego obszaru, używa map wskaźników generowanych przez kompilator do aktualizacji wszystkich wewnętrznych wskaźników odnoszących się do adresów stosu i zwalnia starą pamięć stosu z powrotem do alokatora mheap.

Sytuacja z życia

Prowadziliśmy usługę przetwarzania dzienników o wysokiej wydajności, gdzie każda goroutine zajmowała się analizą potencjalnie głęboko zagnieżdżonych ładunków JSON (do 10 000 poziomów w czasie ataków z uszkodzonym wejściem). Po przetworzeniu te goroutine wracały do sync.Pool, aby czekać na nowe połączenia. Obserwowaliśmy, że pamięć RSS usługi rosła liniowo wraz z liczbą goroutine w puli, nigdy nie uwalniając pamięci, nawet w okresach bezczynności, co ostatecznie powodowało OOM kills w kontenerach z limitami 4GB, mimo że rzeczywisty zestaw roboczy wynosił tylko 200MB.

Rozważaliśmy wymuszenie zakończenia pracy goroutine w puli po określonej liczbie przetworzonych zapytań i uruchomienie świeżych zastępstw. To zagwarantowałoby zwolnienie pamięci stosu, ponieważ nowe goroutine zaczynają z minimalnym rozmiarem 2KB. Jednak to podejście wprowadziło znaczące obciążenie CPU z ciągłych operacji tworzenia i niszczenia goroutine, zakłóciło optymalizacje puli połączeń TCP i spowodowało większe opóźnienia w długościach ogonów z powodu zimnych startów pamięci podręcznej.

Wprowadzenie twardego limitu na wzrost stosu za pomocą debug.SetMaxStack zapobiegłoby nadmiernej alokacji podczas zdarzeń głębokiej rekurencji. Chociaż chroniło to przed OOM, powodowało to, że legalne, ale głębokie zadania analizy panikowały z komunikatem runtime: goroutine stack exceeds 1000000000-byte limit. Skutkowało to utratą danych klientów i błędami usług, które naruszały nasze SLA niezawodności, co czyniło to nieakceptowalnym w produkcji.

Ocenialiśmy okresowe wywoływanie runtime.GC(), a następnie debug.FreeOSMemory() co 30 sekund, aby wymusić skanowanie stosu i jego kurczenie. To skutecznie zmniejszyło RSS, ale wprowadziło przerwy „stop-the-world” od 5 do 10 ms przy każdym wywołaniu, co naruszało nasze wymagania dotyczące latencji p99 poniżej 2 ms dla warstwy API i zwiększało wykorzystanie CPU o 15% z powodu wymuszonego pełnego zbierania.

Ostatecznie polegaliśmy na natywnym mechanizmie kurczenia stosu Go, upewniając się, że uruchamiamy Go 1.20+ i dostosowaliśmy GOGC, aby wyzwalać częstsze zbiory śmieci (ustawiając go na 50 zamiast 100). To zwiększyło częstotliwość możliwości kurczenia stosu bez interwencji ręcznej. Przebudowaliśmy również parser, aby używać iteracyjnego podejścia z wyraźnie alokowanym pamięcią stosu do śledzenia ścieżek, co zmniejszyło maksymalną głębokość rekurencji z 10 000 do 100. Połączenie to pozwoliło na naturalne kurczenie, które występowało wystarczająco często, aby utrzymać pamięć w ryzach.

RSS usługi ustabilizował się na około 800MB pod obciążeniem, w porównaniu do wcześniejszego limitu 3.8GB. Profile stosów goroutine pokazały, że 95% pracowników w puli utrzymywało minimalny rozmiar stosu 2KB między zapytaniami, a wybuchy występowały tylko podczas aktywnej analizy. OOM kills całkowicie ustały, a latencja p99 pozostała poniżej 1.5ms, ponieważ uniknęliśmy ręcznych przerw GC i obrotowego procesu goroutine.

Co kandydaci często przeoczają

Czy kurczenie stosu występuje natychmiast po powrocie funkcji i zmniejszeniu wskaźnika stosu?

Nie, środowisko uruchomieniowe nie monitoruje dekrementów wskaźnika stosu w czasie rzeczywistym, aby wyzwalać natychmiastową deallokację. Kurczenie odbywa się wyłącznie podczas fazy znaczenia zbierania śmieci, gdy harmonogram skanuje wszystkie stosy goroutine. Środowisko uruchomieniowe sprawdza wskaźnik high-water mark użycia stosu od ostatniego GC. Jeśli ten wskaźnik high-water mark jest poniżej 25% aktualnej alokacji fizycznej, tylko wtedy logika kurczenia jest uruchamiana. Ta leniwa ocena rozkłada koszt kopiowania stosów na wszystkie goroutine w okresie, gdy świat jest już wstrzymany na znaki, chociaż faktyczna kopia wymaga zatrzymania poszczególnej goroutine.

Jaki jest dokładny stosunek kurczenia i minimalny rozmiar oraz czy środowisko uruchomieniowe kiedykolwiek zwraca pamięć do systemu operacyjnego?

Gdy stos kwalifikuje się do kurczenia, środowisko uruchomieniowe alokuje nowy stos o połowę mniejszy od aktualnego. Ta geometryczna redukcja zapobiega wstrząsom, gdzie goroutine oscylujące nieznacznie powyżej i poniżej progu ciągle rosną i maleją. Nowy rozmiar jest ograniczony do minimum przez minimalny rozmiar stosu platformy, zwykle 2KB na systemach 64-bitowych. Pamięć ze starego stosu jest zwracana do mheap środowiska uruchomieniowego, a nie bezpośrednio do systemu operacyjnego. System operacyjny odzyskuje tę pamięć fizyczną tylko wtedy, gdy zbieracz ustali, że sterta była bezczynna i przekraczało to cel, lub jeśli debug.FreeOSMemory() zostanie wywołane.

Czy goroutine jest zatrzymywany podczas kurczenia stosu i jak są aktualizowane wskaźniki?

Tak, kurczenie wymaga zatrzymania docelowego goroutine w bezpiecznym punkcie, podobnie jak w przypadku wzrostu stosu. Środowisko uruchomieniowe musi skopiować żywe ramki do nowej lokalizacji w pamięci i zaktualizować wszystkie wskaźniki, które odnoszą się do zmiennych alokowanych na stosie. Kompilator generuje mapy wskaźników, które identyfikują, które słowa w każdej ramce są wskaźnikami. Podczas kurczenia środowisko uruchomieniowe korzysta z tych map, aby znaleźć i dostosować wewnętrzne wskaźniki, aby wskazywały na nowe adresy stosu. Operacja ta nie jest współbieżna; goroutine nie może być wykonywane podczas kopiowania, ale inne goroutines mogą nadal działać.