GoprogramowanieInżynier Backend Go

Jakie inwarianty zapewniają, że lokalny alokator w Go może obsługiwać żądania alokacji małych obiektów bez nabywania globalnego zamka?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia

Alokator pamięci w Go pochodzi z TCMalloc, alokatora pamięci z zachowaniem wątków zaprojektowanego przez Google dla wielowątkowych serwerów C++. Środowisko uruchomieniowe implementuje wielopoziomową pamięć podręczną, aby wyeliminować kontencję zamków w programach współbieżnych. Ten projekt priorytetyzuje przepustowość ponad efektywność pamięci w szybkiej ścieżce dla małych obiektów.

Problem

W wysoko współbieżnych usługach wymuszanie na każdej alokacji nabywania globalnego zamka na stercie spowodowałoby serializację goroutine i zniszczyłoby przepustowość. Wyzwanie polega na zapewnieniu O(1) latencji alokacji bez synchronizacji w typowym przypadku, jednocześnie zachowując bezpieczeństwo. Tradycyjne implementacje malloc cierpią na bounce linii pamięci podręcznej, gdy wiele procesorów rywalizuje o ten sam blok zamka.

Rozwiązanie

Środowisko uruchomieniowe utrzymuje pamięć podręczną per-P (mcache) zawierającą zakresy dla każdej z 67 klas wielkości. Gdy goroutine alokuje mały obiekt (≤32KB), albo inkrementuje wskaźnik graniczny, albo odbiera z lokalnej listy wolnych obiektów w swoim mcache, co nie wymaga operacji atomowych. Krytycznym inwariantem jest to, że mcache jest w danym momencie wyłącznie w posiadaniu jednego P, a alokacje nigdy nie przekraczają granic P, co unika współdzielonego stanu mutowalnego.

type PriceTick struct { Symbol uint32 Price float64 } func ProcessTick() { // Alokuje 16 bajtów z mcache bez blokowania tick := &PriceTick{} _ = tick }

Sytuacja z życia

Platforma handlu wysokiej częstotliwości przetwarzała 500 000 zdarzeń danych rynkowych na sekundę, z których każde zdarzenie wymagało tymczasowych struktur 24-bajtowych do normalizacji cen. Początkowa implementacja wykorzystywała globalny sync.Pool dla tych obiektów, co stało się poważnym punktem kontencji pod obciążeniem, konsumującym 35% czasu CPU na operacje atomowe i ruch koherencji pamięci.

Rozwiązanie A: Ręczne dzielenie puli

Zespół rozważył ręczne podzielenie puli na 256 wewnętrznych pod-puli wybranych przez hasz ID goroutine. Zalety: Rozkłada kontencję wzdłuż linii pamięci podręcznej. Wady: Nierównomierne wykorzystanie powoduje puchnięcie pamięci w bezczynnych piramidach, a skomplikowane zarządzanie głodowaniem jest wymagane, gdy lokalna piramida jest pusta, podczas gdy inne zawierają wolne obiekty.

Rozwiązanie B: Areny na pracownika

Rozważali wstępną alokację dużych aren pamięci dla goroutine pracownika z alokacją przy użyciu wskaźnika ogonowego. Zalety: Brak kontencji i ekstremalnie szybka ścieżka alokacji. Wady: Wymaga ręcznego zarządzania pamięcią, ryzyko wycieku pamięci, jeśli wskaźniki resetowania są źle obsługiwane, i komplikuje śledzenie cyklu życia obiektów wzdłuż granic asynchronicznych.

Rozwiązanie C: Alokacja na stosie i grupowanie

Wybrane podejście przekształciło procesor zdarzeń, aby używać struktur wartości zamiast wskaźników, utrzymując dane na stosie, gdzie to możliwe, i przetwarzając zdarzenia w partiach po 1000, aby rozłożyć alokacje. Zalety: Całkowicie eliminuje presję na stercie dla danych o krótkim okresie życia i nie wymaga prymitywów synchronizacyjnych. Wady: Wymagało znacznego refaktoryzowania interfejsów, które wcześniej oczekiwały semantyki wskaźników, i zwiększało użycie stosu na goroutine.

Wynik

Poprzez wdrożenie Rozwiązania C, usługa wyeliminowała 99% alokacji sterty w gorącym punkcie. Latencja P99 spadła z 12 milisekund do 180 mikrosekund, a cykle zbierania śmieci zmniejszyły się o 85%, co pozwoliło usłudze spełnić wymagania SLA poniżej jednej milisekundy.

Co często pomijają kandydaci

Jak Go ogranicza fragmentację pamięci podczas alokacji obiektów o różnych rozmiarach z zakresów o stałym rozmiarze?

Go wykorzystuje 67 odrębnych klas rozmiarów o określonej granularity (kroki 8-bajtowe do 512 bajtów, a następnie większe interwały). Obiekty są zaokrąglane do najbliższego rozmiaru klasy, co ogranicza wewnętrzną fragmentację do około 12.5%. Fragmentacja zewnętrzna jest minimalizowana, ponieważ każda mspan zawiera obiekty dokładnie jednej klasy rozmiaru, zapobiegając małym obiektom, które blokują duże bloki pamięci.

Dlaczego środowisko uruchomieniowe czyści mapy bitowe sterty, a nie pamięci widocznej dla użytkownika podczas alokacji?

Alokator utrzymuje informacje o typach i mapy bitowe wskaźników w strukturach metadanych heapArena, a nie w nagłówkach obiektów. Gdy pamięć jest alokowana, tylko mapy bitowe wskazujące sloty wskaźników są zerowane w razie potrzeby; pamięć danych jest zerowana na żądanie przez mutator lub podczas konkurencyjnego zamiatania. To podejście odkłada pracę, poprawia lokalność pamięci podręcznej i zmniejsza potrzebną szerokość pasma pamięci podczas alokacji.

Co zmusza span do przejścia z mcache z powrotem do mcentral podczas zbierania śmieci?

Podczas fazy zamiatania GC, środowisko uruchomieniowe bada spany trzymane w instancjach mcache. Jeśli span nie zawiera alokowanych obiektów (wszystkie sloty są zwolnione), P zwraca go do mcentral, zamiast go zatrzymywać. To zapobiega gromadzeniu pamięci i zapewnia zrównoważoną dystrybucję wolnej pamięci w procesorach, chociaż wiąże się to z kosztem ponownego uzyskania centralnego zamka.