Przewodnik po wieloprocesorowych modelach serwerów sieciowych

Opublikowany: 2022-03-11

Jako ktoś, kto od wielu lat pisze wysokowydajny kod sieciowy (moja praca doktorska dotyczyła serwera pamięci podręcznej dla aplikacji rozproszonych przystosowanych do systemów wielordzeniowych), widzę wiele samouczków na ten temat, które całkowicie pomijają lub pomijają jakąkolwiek dyskusję podstaw modeli serwerów sieciowych. Dlatego ten artykuł jest pomyślany jako przydatny przegląd i porównanie modeli serwerów sieciowych, którego celem jest rozwianie tajemnicy pisania wysokowydajnego kodu sieciowego.

Który model serwera sieciowego powinienem wybrać?

Ten artykuł jest przeznaczony dla „programistów systemowych”, tj. programistów back-end, którzy będą pracować z niskopoziomowymi szczegółami swoich aplikacji, implementując kod serwera sieciowego. Zwykle odbywa się to w C++ lub C, chociaż obecnie większość nowoczesnych języków i frameworków oferuje przyzwoitą funkcjonalność niskopoziomową, z różnymi poziomami wydajności.

Przyjmę za powszechną wiedzę, że skoro łatwiej jest skalować procesory przez dodawanie rdzeni, naturalne jest dostosowanie oprogramowania do korzystania z tych rdzeni najlepiej, jak to możliwe. W związku z tym pojawia się pytanie, jak podzielić oprogramowanie między wątki (lub procesy), które mogą być wykonywane równolegle na wielu procesorach.

Przyjmę też za pewnik, że czytelnik zdaje sobie sprawę, że „współbieżność” w zasadzie oznacza „wielozadaniowość”, czyli kilka instancji kodu (nie ma znaczenia, czy ten sam kod, czy inny, nie ma znaczenia), które są aktywne w tym samym czasie. Współbieżność można osiągnąć na jednym procesorze, a przed erą współczesną zwykle tak było. W szczególności współbieżność można osiągnąć poprzez szybkie przełączanie między wieloma procesami lub wątkami na jednym procesorze. W ten sposób stare, jednoprocesorowe systemy potrafiły uruchamiać wiele aplikacji jednocześnie, w sposób, który użytkownik postrzegałby jako aplikacje uruchamiane jednocześnie, chociaż tak naprawdę nie było. Z drugiej strony równoległość oznacza w szczególności, że kod jest wykonywany w tym samym czasie, dosłownie, przez wiele procesorów lub rdzeni procesorów.

Partycjonowanie aplikacji (na wiele procesów lub wątków)

Na potrzeby tej dyskusji w dużej mierze nie ma znaczenia, czy mówimy o wątkach, czy pełnych procesach. Współczesne systemy operacyjne (z godnym uwagi wyjątkiem Windows) traktują procesy prawie tak samo lekkie jak wątki (lub w niektórych przypadkach odwrotnie, wątki zyskały cechy, które czynią je tak samo ważnymi jak procesy). Obecnie główna różnica między procesami i wątkami polega na możliwościach komunikacji między procesami lub między wątkami oraz udostępniania danych. Tam, gdzie ważne jest rozróżnienie między procesami a wątkami, zrobię odpowiednią notatkę, w przeciwnym razie można bezpiecznie uznać słowa „wątek” i „proces” w tej sekcji za zamienne.

Typowe zadania aplikacji sieciowych i modele serwerów sieciowych

Ten artykuł dotyczy konkretnie kodu serwera sieciowego, który z konieczności realizuje następujące trzy zadania:

  • Zadanie 1: Nawiązanie (i zerwanie) połączeń sieciowych
  • Zadanie 2: Komunikacja sieciowa (IO)
  • Zadanie 3: Przydatna praca; tj. ładunek lub powód istnienia aplikacji

Istnieje kilka ogólnych modeli serwerów sieciowych do dzielenia tych zadań między procesy; mianowicie:

  • MP: wieloprocesowy
  • SPED: pojedynczy proces, sterowany zdarzeniami
  • SEDA: etapowa architektura sterowana zdarzeniami
  • AMPED: Asymetryczny, wieloprocesowy, sterowany zdarzeniami
  • SYMPED: Symetryczny wieloprocesowy sterowany zdarzeniami

Są to nazwy modeli serwerów sieciowych używane w społeczności akademickiej i pamiętam, że przynajmniej dla niektórych z nich znalazłem synonimy „na wolności”. (Same nazwy są oczywiście mniej ważne – prawdziwą wartością jest to, jak wnioskować o tym, co się dzieje w kodzie).

Każdy z tych modeli serwerów sieciowych jest dokładniej opisany w poniższych sekcjach.

Model wieloprocesowy (MP)

Model serwera sieciowego MP to ten, którego wszyscy uczyli się jako pierwszy, zwłaszcza gdy uczyli się o wielowątkowości. W modelu MP istnieje proces „nadrzędny”, który akceptuje połączenia (Zadanie nr 1). Po nawiązaniu połączenia proces główny tworzy nowy proces i przekazuje mu gniazdo połączenia, dzięki czemu na połączenie przypada jeden proces. Ten nowy proces zwykle działa z połączeniem w prosty, sekwencyjny sposób: odczytuje coś z niego (zadanie nr 2), następnie wykonuje obliczenia (zadanie nr 3), a następnie coś do niego zapisuje (zadanie nr 2) Ponownie).

Model MP jest bardzo prosty w implementacji i faktycznie działa bardzo dobrze, o ile całkowita liczba procesów pozostaje dość niska. Jak nisko? Odpowiedź naprawdę zależy od tego, z czym wiążą się zadania 2 i 3. Załóżmy, że liczba procesów lub wątków nie powinna przekraczać około dwukrotności liczby rdzeni procesora. Gdy w tym samym czasie aktywnych jest zbyt wiele procesów, system operacyjny ma tendencję do spędzania zbyt dużo czasu na thrashowaniu (tj. żonglowaniu procesami lub wątkami wokół dostępnych rdzeni procesora), a takie aplikacje zazwyczaj kończą się zużywaniem prawie całego procesora czas w kodzie „sys” (lub jądra), wykonując niewiele faktycznie użytecznej pracy.

Plusy: Bardzo prosty do wdrożenia, działa bardzo dobrze, o ile liczba połączeń jest niewielka.

Wady: ma tendencję do przeciążania systemu operacyjnego, jeśli liczba procesów jest zbyt duża, i może powodować fluktuacje opóźnienia, gdy sieciowe operacje wejścia/wyjścia czekają na zakończenie fazy ładunku (obliczeń).

Model oparty na zdarzeniach pojedynczego procesu (SPED)

Model serwera sieciowego SPED stał się sławny dzięki kilku stosunkowo niedawnym aplikacjom serwerów sieciowych o wysokim profilu, takim jak Nginx. Zasadniczo wykonuje wszystkie trzy zadania w tym samym procesie, multipleksując między nimi. Aby być wydajnym, wymaga dość zaawansowanych funkcji jądra, takich jak epoll i kqueue. W tym modelu kod jest sterowany połączeniami przychodzącymi i „zdarzeniami” danych i implementuje „pętlę zdarzeń”, która wygląda tak:

  • Zapytaj system operacyjny, czy są jakieś nowe „zdarzenia” sieciowe (takie jak nowe połączenia lub przychodzące dane)
  • Jeśli są dostępne nowe połączenia, nawiąż je (Zadanie nr 1)
  • Jeśli są dostępne dane, przeczytaj je (Zadanie #2) i postępuj zgodnie z nimi (Zadanie #3)
  • Powtarzaj, aż serwer się wyłączy

Wszystko to odbywa się w jednym procesie i można to zrobić niezwykle wydajnie, ponieważ całkowicie unika się przełączania kontekstu między procesami, co zwykle obniża wydajność w modelu MP. Jedyne przełączniki kontekstu pochodzą z wywołań systemowych, a te są minimalizowane przez działanie tylko na określonych połączeniach, do których są przypisane pewne zdarzenia. Ten model może obsługiwać jednocześnie dziesiątki tysięcy połączeń, o ile praca nad ładunkiem (zadanie nr 3) nie jest nadmiernie skomplikowana ani nie wymaga dużych zasobów.

Istnieją jednak dwie główne wady tego podejścia:

  1. Ponieważ wszystkie trzy zadania są wykonywane sekwencyjnie w jednej iteracji pętli, praca nad ładunkiem (zadanie nr 3) jest wykonywana synchronicznie ze wszystkim innym, co oznacza, że ​​jeśli obliczenie odpowiedzi na dane otrzymane przez klienta zajmuje dużo czasu, wszystko inne zatrzymuje się, gdy to się dzieje, wprowadzając potencjalnie ogromne wahania w opóźnieniu.
  2. Używany jest tylko jeden rdzeń procesora. Ponownie ma to tę zaletę, że absolutnie ogranicza liczbę przełączników kontekstu wymaganych przez system operacyjny, co zwiększa ogólną wydajność, ale ma tę istotną wadę, że inne dostępne rdzenie procesora w ogóle nic nie robią.

Z tych powodów potrzebne są bardziej zaawansowane modele.

Plusy: Może być wysoce wydajny i łatwy w systemie operacyjnym (tj. wymaga minimalnej interwencji systemu operacyjnego). Wymaga tylko jednego rdzenia procesora.

Wady: Wykorzystuje tylko jeden procesor (niezależnie od dostępnej liczby). Jeśli praca ładunku nie jest jednolita, skutkuje nierównomiernym opóźnieniem odpowiedzi.

Model etapowej architektury sterowanej zdarzeniami (SEDA)

Model serwera sieciowego SEDA jest nieco skomplikowany. Rozkłada złożoną, sterowaną zdarzeniami aplikację na zestaw etapów połączonych kolejkami. Jeśli jednak nie zostanie starannie zaimplementowany, jego wydajność może ucierpieć z powodu tego samego problemu, co w przypadku MP. Działa to tak:

  • Praca nad ładunkiem (Zadanie nr 3) jest podzielona na jak najwięcej etapów lub modułów. Każdy moduł implementuje pojedynczą określoną funkcję (pomyśl „mikrousługi” lub „mikrojądra”), która znajduje się w oddzielnym procesie, a te moduły komunikują się ze sobą za pośrednictwem kolejek wiadomości. Ta architektura może być reprezentowana jako graf węzłów, gdzie każdy węzeł jest procesem, a krawędzie są kolejkami wiadomości.
  • Pojedynczy proces wykonuje Zadanie nr 1 (zwykle zgodnie z modelem SPED), które odciąża nowe połączenia do określonych węzłów punktu wejścia. Węzły te mogą być albo węzłami czystej sieci (zadanie nr 2), które przekazują dane do innych węzłów w celu obliczenia, lub mogą również implementować przetwarzanie ładunku (zadanie nr 3) . Zwykle nie ma procesu „nadrzędnego” (np. takiego, który zbiera i agreguje odpowiedzi i wysyła je z powrotem przez połączenie), ponieważ każdy węzeł może odpowiadać samodzielnie.

Teoretycznie model ten może być dowolnie złożony, a graf węzłów może zawierać pętle, połączenia z innymi podobnymi aplikacjami lub węzły faktycznie wykonywane w zdalnych systemach. W praktyce jednak, nawet przy dobrze zdefiniowanych wiadomościach i wydajnych kolejkach, myślenie i wnioskowanie o zachowaniu systemu jako całości może być niewygodne. Przekazywanie wiadomości może zniszczyć wydajność tego modelu w porównaniu z modelem SPED, jeśli praca wykonywana w każdym węźle jest krótka. Wydajność tego modelu jest znacznie niższa niż modelu SPED, dlatego jest zwykle stosowany w sytuacjach, gdy praca nad obciążeniem jest złożona i czasochłonna.

Plusy: Marzenie najlepszego architekta oprogramowania: wszystko jest podzielone na schludne, niezależne moduły.

Wady: Złożoność może eksplodować tylko na podstawie liczby modułów, a kolejkowanie wiadomości jest nadal znacznie wolniejsze niż bezpośrednie współdzielenie pamięci.

Asymetryczny, wieloprocesowy model sterowany zdarzeniami (AMPED)

Serwer sieciowy AMPED to poskromiona, łatwiejsza do modelowania wersja SEDA. Nie ma tylu różnych modułów i procesów, ani kolejek wiadomości. Oto jak to działa:

  • Implementuj zadania nr 1 i nr 2 w jednym „nadrzędnym” procesie, w stylu SPED. Jest to jedyny proces wykonujący sieciowe IO.
  • Zaimplementuj Zadanie nr 3 w oddzielnym procesie „robotniczym” (prawdopodobnie uruchamianym w wielu instancjach), połączonym z procesem głównym za pomocą kolejki (jedna kolejka na proces).
  • Gdy dane zostaną odebrane w procesie „głównym”, znajdź niewykorzystany (lub bezczynny) proces roboczy i przekaż dane do jego kolejki komunikatów. Proces główny jest wysyłany przez proces, gdy odpowiedź jest gotowa, po czym przekazuje odpowiedź do połączenia.

Ważną rzeczą jest tutaj to, że praca nad ładunkiem jest wykonywana w stałej (zazwyczaj konfigurowalnej) liczbie procesów, która jest niezależna od liczby połączeń. Zaletą jest to, że ładunek może być dowolnie złożony i nie wpłynie na IO sieci (co jest dobre dla opóźnień). Istnieje również możliwość zwiększenia bezpieczeństwa, ponieważ tylko jeden proces wykonuje sieciowe operacje we/wy.

Plusy: Bardzo czyste oddzielenie pracy sieciowej IO i ładunku.

Wady: Wykorzystuje kolejkę komunikatów do przekazywania danych między procesami, co w zależności od charakteru protokołu może stać się wąskim gardłem.

Model SYmmetric oparty na zdarzeniach wieloprocesowych (SYMPED)

Model serwera sieciowego SYMPED jest pod wieloma względami „świętym Graalem” modeli serwerów sieciowych, ponieważ przypomina posiadanie wielu instancji niezależnych procesów „robotniczych” SPED. Jest realizowany przez pojedynczy proces akceptujący połączenia w pętli, a następnie przesyłający je do procesów roboczych, z których każdy ma pętlę zdarzeń podobną do SPED. Ma to bardzo korzystne konsekwencje:

  • Procesory są ładowane dla dokładnie takiej liczby procesów, które zostały wygenerowane, które w każdym momencie wykonują sieciowe operacje we/wy lub przetwarzanie ładunku. Nie ma możliwości dalszego zwiększania wykorzystania procesora.
  • Jeśli połączenia są niezależne (tak jak w przypadku HTTP), nie ma komunikacji między procesami między procesami roboczymi.

To właśnie robią nowsze wersje Nginx; tworzą niewielką liczbę procesów roboczych, z których każdy uruchamia pętlę zdarzeń. Aby było jeszcze lepiej, większość systemów operacyjnych udostępnia funkcję, dzięki której wiele procesów może niezależnie nasłuchiwać połączeń przychodzących na porcie TCP, eliminując potrzebę specjalnego procesu dedykowanego pracy z połączeniami sieciowymi. Jeśli aplikacja, nad którą pracujesz, może zostać zaimplementowana w ten sposób, polecam to zrobić.

Plusy: Ścisły górny pułap wykorzystania procesora, z kontrolowaną liczbą pętli podobnych do SPED.

Wady: Ponieważ każdy z procesów ma pętlę podobną do SPED, jeśli praca ładunku jest nierównomierna, opóźnienie może się ponownie różnić, tak jak w normalnym modelu SPED.

Niektóre sztuczki niskiego poziomu

Oprócz wyboru najlepszego modelu architektonicznego dla Twojej aplikacji, istnieją pewne triki niskiego poziomu, które można wykorzystać do dalszego zwiększenia wydajności kodu sieciowego. Oto krótka lista niektórych z bardziej skutecznych:

  1. Unikaj dynamicznej alokacji pamięci. Jako wyjaśnienie spójrz po prostu na kod popularnych alokatorów pamięci – używają one złożonych struktur danych, muteksów i jest w nich po prostu tyle kodu (na przykład jemalloc to około 450 KiB kodu C!). Większość powyższych modeli można zaimplementować z całkowicie statyczną (lub wstępnie przydzieloną) siecią i/lub buforami, które zmieniają właściciela tylko w razie potrzeby między wątkami.
  2. Użyj maksimum, które może zapewnić system operacyjny. Większość systemów operacyjnych umożliwia nasłuchiwanie wielu procesów w jednym gnieździe i implementuje funkcje, w których połączenie nie zostanie zaakceptowane, dopóki w gnieździe nie zostanie odebrany pierwszy bajt (lub nawet pierwsze pełne żądanie!). Użyj sendfile(), jeśli możesz.
  3. Zrozum protokół sieciowy, którego używasz! Na przykład zwykle ma sens wyłączenie algorytmu Nagle'a, a wyłączenie utrzymywania się może mieć sens, jeśli wskaźnik (ponownych) połączeń jest wysoki. Dowiedz się więcej o algorytmach kontroli przeciążenia TCP i sprawdź, czy warto wypróbować jeden z nowszych.

Mogę opowiedzieć o nich więcej, a także o dodatkowych technikach i sztuczkach do zastosowania, w przyszłym poście na blogu. Ale na razie, miejmy nadzieję, zapewnia to użyteczną i pouczającą podstawę dotyczącą wyborów architektonicznych do pisania wysokowydajnego kodu sieciowego oraz ich względnych zalet i wad.