Historia: Przed Go 1.14, runtime utrzymywał jedną globalną stertę timerów chronioną centralnym blokowaniem. Wszystkie goroutines tworzące lub modyfikujące timery rywalizowały o ten blok, co tworzyło poważne wąskie gardło skalowalności w serwerach sieciowych o dużej przepustowości, zarządzających tysiącami jednoczesnych połączeń z limitami czasowymi.
Problem: Wraz ze wzrostem liczby rdzeni, globalne blokowanie timerów stało się punktem serializacji. Kiedy goroutine wywołała time.AfterFunc lub zmodyfikowała istniejący timer, musiała uzyskać globalny blok, zaktualizować strukturę 4-heap i potencjalnie obudzić dedykowany wątek timerowy. Ten zserializowany dostęp uniemożliwiał operacjom timerów skalowanie poziome z rdzeniami CPU, degradując opóźnienia przy obciążeniu.
Rozwiązanie: Go 1.14 przeprojektowało system timerów na użycie stert timerów per-P (procesora). Każdy logiczny procesor utrzymuje swoją własną 64-heap (wariant 4-heap) timerów. Kiedy timer jest tworzony lub resetowany, runtime wykonuje algorytm bezblokujący, korzystając z atomowych operacji porównania i zamiany na słowie stanu timera (timery są reprezentowane przez struktury runtime.timer). Jeśli timer jest modyfikowany przez inny P niż jego właściciel, runtime używa atomowej aktualizacji, aby przenieść go między stertami bez blokowania pierwotnego goroutine. Timerproc jest teraz zintegrowany z pętlą findRunnable planisty, umożliwiając każdemu P skanowanie swojej lokalnej sterty bez globalnej synchronizacji.
// Koncepcyjna reprezentacja modyfikacji timera func resetTimer(t *timer, when int64) { // Bezblokowa przejście stanu z użyciem atomików for { old := atomic.Load(&t.status) if old == timerWaiting || old == timerRunning { // Próbuj ukraść lub zaktualizować atomowo if atomic.CompareAndSwap(&t.status, old, timerModifying) { t.when = when // Rebalans w lokalnej stercie P atomic.Store(&t.status, timerWaiting) break } } } }
Opis problemu: Bramkarz handlu wysokiej częstotliwości napisany w Go doświadczył skoków latencji przekraczających 10ms podczas otwarcia rynku, mimo niskiej wykorzystania CPU. Profilowanie ujawniło, że 40% całkowitego zatoru mutexa pochodziło z operacji runtime.timer, a konkretnie z przedłużania limitów czasowych odczytu połączeń za pomocą SetReadDeadline. Zespół operacyjny początkowo podejrzewał opóźnienie sieciowe, ale tracer wykonania Go wskazał, że globalne blokowanie timerów było winowajcą.
Różne rozważane rozwiązania:
Jednym z podejść było wdrożenie zegara w przestrzeni użytkownika poza standardową biblioteką. To podzieliłoby timery na wiadra w oparciu o czas wygaśnięcia, korzystając z kołowej buforowej stałej wielkości. Chociaż to wyeliminowało zator blokowania runtime, wprowadziło znaczne skomplikowanie: zespół handlowy musiałby utrzymywać osobną goroutine do zaawansowania koła, obsługiwać przepełnienia wiader dla długich limitów czasowych i zapewniać bezpieczeństwo pamięci bez gwarancji runtime. Dodatkowo, granularity koła była niewystarczająca dla wymogów handlu sub-milisekundowego, a realizacja groziła obciążeniem konserwacyjnym.
Innym rozważanym rozwiązaniem było agresywne grupowanie i ponowne użycie obiektów time.Timer, aby zminimalizować alokacje. To zmniejszyło presję GC, ale nie rozwiązało podstawowego zatoru na globalnym blokowaniu timerów przy wywołaniu Reset() lub Stop(). Zespół zbadał także użycie time.Ticker z grupowanymi sprawdzaniami limitów czasowych, ale to naruszało wymogi giełdy dotyczące natychmiastowego zamykania połączenia w przypadku upływu czasu, co czyniło je niezgodnymi ze specyfikacjami regulacyjnymi.
Wybrane rozwiązanie i rezultat: Zespół przeszedł do Go 1.15 (włączając ulepszenia timerów per-P) i zastąpił bezpośrednie wywołania SetReadDeadline niestandardowym opakowaniem połączenia, które zarządzało przedłużeniami limitów czasowych za pomocą wywołań zwrotnych time.AfterFunc, zamiast resetować absolutne limity czasowe. Ta zmiana rozdzieliła wpisy timerów pomiędzy wszystkimi dostępnymi Ps, zmniejszając zator mutexa do nieznacznych poziomów. Rezultatem było 95% zmniejszenie latencji p99 (z 12ms do 0.6ms) w czasie szczytowej objętości handlowej, umożliwiając bramce obsługę 100 000 jednoczesnych połączeń bez degradacji planisty.
Jak runtime obsługuje migrację timerów, gdy goroutine przechodzi między Ps, i dlaczego timery nie mogą po prostu podążać za goroutine?
Timery są związane z P, w którym zostały stworzone lub ostatnio zresetowane, a nie z goroutine. Gdy goroutine migruje między Ps podczas kradzieży pracy, timer pozostaje na stercie oryginalnego P, aby uniknąć obciążenia atomowego podczas każdego przełączania kontekstu. Jeśli timer wystrzeli, runtime zauważa, że powiązany goroutine działa teraz na innym P i umieszcza wywołanie zwrotne w kolejce uruchomieniowej tego P. Ta separacja jest kluczowa, ponieważ sterty timerów wymagają utrzymania invariantów sterty; pozwolenie timerom na migrację z goroutines wymagałoby blokowania zarówno źródłowej, jak i docelowej sterty timerów P podczas każdego kradzieży, co wprowadziłoby ponownie zator, który projekt per-P wyeliminował.
Jaki konkretny wyścig warunkowy wymusza cztero-stanową maszynę stanu atomowego (timerIdle, timerWaiting, timerRunning, timerModifying) w realizacji timera?
Maszyna stanowa zapobiega "utratom wybudzenia" wyścigu, w którym timer jest resetowany na późniejszy czas po tym, jak został wybrany do wykonania, ale przed jego uruchomieniem wywołania zwrotnego. Bez stanów atomowych, P A mógłby wybrać timer z swojej sterty (oznaczając go jako działający), podczas gdy P B jednocześnie go resetuje. Cztery stany zapewniają, że operacja Reset widzi stan timerModifying lub timerRunning i kręci się, aż timer będzie bezpieczny do modyfikacji. Kandydaci często pomijają, że timerModifying działa jako przejściowy spin-lock podczas zmian stanu, zapobiegając wywołaniu zwrotnego od wykonania z przestarzałymi danymi lub całkowitego zagubienia.
Dlaczego runtime utrzymuje strukturę 64-heap dla timerów zamiast standardowej sterty binarnej, i jak to się odnosi do optymalizacji linii pamięci podręcznej?
64-heap (4-heap) zmniejsza głębokość drzewa do około log₄(n) poziomów w porównaniu do log₂(n), minimalizując ściganie wskaźników i błędy w pamięci podręcznej podczas operacji sift-up i sift-down. W standardowej binarnej stercie każde porównanie wymaga załadowania dwóch dzieci (potencjalnie dwóch linii pamięci podręcznej); 4-heap ładuje cztery dzieci jednocześnie, co mieści się w jednej linii pamięci 64-bajtowej w nowoczesnych architekturach x86_64. Ta struktura jest świadomym kompromisem: chociaż zwiększa liczbę porównań na poziom, znacząco zmniejsza błędy pamięci podręcznej, które dominują latencję operacji sterty timerów, gdy zarządzane są tysiące timerów na P.