Serwer net/http w Go stosuje model gorutyn na połączenie połączony z M:N strategią planowania czasu wykonania. Kiedy serwer akceptuje połączenie TCP, natychmiast uruchamia lekką gorutynę, aby obsłużyć cały cykl życia tego połączenia, co pozwala głównemu cyklowi akceptowania na natychmiastowe przyjęcie następnego połączenia. Te gorutiny są multiplikowane na ograniczonej puli wątków OS przez planista Go, który parkuluje gorutyny wykonujące blokujące operacje I/O i ponownie planuje te, które mogą być uruchomione na dostępnych wątkach. Ta architektura pozwala serwerowi na utrzymywanie setek tysięcy jednoczesnych połączeń, wykorzystując jedynie garstkę wątków jądra, unikając nadmiarowych kosztów pamięci tradycyjnych serwerów z wątkami na połączenie.
Musieliśmy zbudować bramkę telemetryczną w czasie rzeczywistym zdolną do jednoczesnego przetwarzania danych z 50 000 urządzeń IoT przez utrzymujące się połączenia HTTP/1.1.
Opis problemu: Nasz początkowy prototyp wykorzystujący Python z Twisted zapewnił potrzebną konkurencyjność, ale szybko stał się nie do utrzymania z powodu złożonych łańcuchów wywołań zwrotnych oraz głęboko zagnieżdżonego obsługi błędów. Kiedy próbowaliśmy podejścia Java używającego wątków na połączenie w celu uproszczenia kodu, napotkaliśmy ograniczenie liczby wątków systemu operacyjnego przy około 32 000 połączeń, co spowodowało awarię JVM z OutOfMemoryError: unable to create new native thread, ponieważ każdy wątek zużywał ponad 1MB pamięci wirtualnej.
Rozważane różne rozwiązania:
Asyncio z jawnie zadeklarowanymi maszynami stanów: Rozważaliśmy migrację do asyncio w Pythonie, aby użyć jednego pętli zdarzeń z korutynami. To znacznie zmniejszyłoby wykorzystanie pamięci w porównaniu do wątków, ale wymagałoby przepisania całej naszej logiki parsowania protokołów na składnię async/await i wprowadziłoby ryzyko przypadkowego zablokowania pętli zdarzeń przez operacje wymagające dużej mocy CPU. Debugowanie śladów stosów w granicach asynchronicznych także okazało się notorycznie trudne dla naszego zespołu deweloperskiego.
Poziome szardowanie instancji JVM: Rozważaliśmy uruchomienie dziesięciu mniejszych instancji Java za load balancerem, gdzie każda instancja obsługiwałaby 5 000 wątków. To podejście rozwiązało limit wątków na proces, ale wprowadziło znaczne skomplikowanie operacyjne, wymagało dodatkowych zasobów sprzętowych oraz skomplikowało zarządzanie wspólnym stanem i przywiązaniem połączeń w klastrze. Koszt operacyjny utrzymania tego mikro-klastra przewyższał korzyści płynące z pozostawania przy Java.
Model gorutyn na połączenie w Go: Postanowiliśmy zaimplementować bramkę w Go, korzystając z pakietów standardowej biblioteki net/http i net. Metoda Serve serwera automatycznie uruchamia lekką gorutynę dla każdego przyjętego połączenia TCP, a planista czasu wykonania Go przezroczysto multiplikuje je na ograniczonej puli wątków OS. To pozwoliło nam pisać prosty, synchronicznie wyglądający kod I/O, który skalowałby się do setek tysięcy połączeń bez ręcznego zarządzania maszyną stanów.
Wybrane rozwiązanie i dlaczego: Wybraliśmy implementację w Go, ponieważ oferowała skalowalność systemów opartych na zdarzeniach w połączeniu z prostotą programowania wątkowego. Czas wykonania automatycznie zajmuje się złożonością planowania i nieblokującego I/O, co pozwala naszym programistom skupić się na logice biznesowej, a nie na prymitywach konkurencyjnych. Co więcej, początkowy rozmiar stosu gorutyny wynoszący 2KB oznacza, że teoretycznie moglibyśmy obsługiwać miliony połączeń w ramach naszego budżetu pamięciowego.
Wynik: System produkcyjny skutecznie zarządzał 75 000 równoczesnymi, utrzymującymi się połączeniami na jednym serwerze ośmiordzeniowym, zużywając mniej niż 4GB RAM. Wykorzystanie CPU pozostawało stabilne na poziomie 35-40%, ponieważ planista skutecznie ukrywał opóźnienia I/O, a my wyeliminowaliśmy operacyjny ciężar zarządzania klastrem szardowanych instancji Java.
Jak planista Go zapobiega problemowi stada gromadzącego się, gdy tysiące gorutin blokują odbiór na tym samym kanale?
Planista Go używa kolejki oczekiwania fifo (pierwszy do przyjęcia, pierwszy do wypuszczenia) dla kanałów, nie budzi wszystkich. Kiedy wysyłający zapisuje do kanału, planista budzi dokładnie jedną czekającą gorutynę z kolejki odbioru (tę, która czekała najdłużej). Zapewnia to, że tylko jedna gorutyna konsumuje wartość, zapobiegając problemowi stada gromadzącego się, gdzie wiele gorutin budzi się, konkurowało o blokadę i wszystkie oprócz jednej zasypiają z powrotem. Kandydaci często błędnie zakładają, że operacje kanałów rozgłaszają do wszystkich czekających, jak zmienne warunkowe.
Dlaczego zwiększenie GOMAXPROCS ponad liczbę fizycznych rdzeni CPU może pogorszyć wydajność serwera Go HTTP z ograniczeniem I/O?
Chociaż planista Go jest preemtywny od wersji 1.14, posiadanie więcej wątków OS (M) niż rdzeni zwiększa koszt przełączania kontekstu na poziomie jądra. Dla serwerów obsługujących I/O nadmiar wątków może prowadzić do tego, że planista spędza więcej czasu na zarządzaniu kolejkami uruchamiania i przekazywaniu wątków niż na wykonywaniu kodu użytkownika. Ponadto każdy wątek OS zużywa zasoby jądra (pamięć na przechowywanie lokalnych informacji wątków i stosy jądra), co może wywierać presję na system operacyjny, gdy jest nadmiernie skalowany powyżej niezbędnej równoległości.
Jak serwer Go net/http obsługuje kolejkę SO_BACKLOG TCP, gdy szybkość akceptacji gorutin tymczasowo opóźnia się w stosunku do szybkości przybywania połączeń?
Serwer polega na kolejce oczekiwania jądra (kontrolowanej przez net.ListenConfig's Backlog lub domyślne ustawienia systemowe). Jeśli gorutyny są wolne w uruchamianiu lub obsługuje powoli akceptowanie połączeń z nasłuchującego, jądro kolejkowe nadchodzi SYN-y w backlogu. Gdy zostanie wypełniony backlog, jądro odrzuca nowe połączenia za pomocą TCP RST. Pętla Accept() w Go działa w swojej własnej gorutynie i powinna w idealnym przypadku szybko uruchamiać gorutyny obsługujące. Jednak jeśli uruchomienie obsługi jest opóźnione (np. z powodu przerw GC lub kontencji mutexów w middleware), połączenia są tracone. Kandydaci często przegapiają, że Go nie implementuje kolejkowania połączeń w przestrzeni użytkownika; całkowicie polega na backlogu jądra, a dostosowanie SOMAXCONN lub ListenConfig.Backlog jest kluczowe dla absorpcji skoków.