sobota, 7 stycznia 2012

Nieblokujące serwery sterowane zdarzeniami cz. 2: Równoległa obsługa żądań

W poprzedniej części streściłem w wielkim skrócie czym są sterowane zdarzeniami nieblokujące serwery. W tym jednak chciałbym wrócić do korzeni i opisać skąd w ogóle wziął się pomysł na coś takiego.

Obecnie wykorzystywane serwery muszą obsługiwać wiele żądań jednocześnie aby zwiększyć swoją przepustowość. Potrzeba ta zaistniała, z kilku powodów. Po pierwsze wielordzeniowe procesory i maszyny wieloprocesorowe muszą być wykorzystane w pełni. Wykonywanie tylko jednej operacji w danym czasie jest oczywistym marnotrawieniem wynalazków techniki. Po drugie większość aplikacji nie jest zwykłym algorytmem, który korzystając tylko i wyłącznie z przydzielonej mu pamięci RAM wykonuje kolejne kroki algorytmu. Programy te komunikują się z innymi procesami, np. bazami danych, innymi serwerami lub wykonują operacje na dysku. W chwili gdy oczekują na te tzw. "zewnętrzne zasoby" mogłyby obsłużyć inne żądania. Sposobów równoległej obsługi wielu żądań jest wiele. Oto najważniejsze z nich:

  1. Wątek tworzony dla każdego połączenia
  2. Pula wątków
  3. Jeden wątek sterowany zdarzeniami wykonujący nieblokujące operacje we/wy
  4. Jeden wątek szefa (boss) i wiele wątków pracowników (workers)
Zanim wejdę w szczegóły chciałbym nadmienić, że pisząc o wątkach mam na myśli zarówno wątki jak i procesy. To znaczy, że aby zapewnić równoległe przetwarzanie żądań można użyć jednego z nich (lub obu jeśli ktoś zapragnie). O różnicach między wątkami i procesami napisano już naprawdę wiele, dlatego nie będę tu wchodził w takie szczegóły.

Pierwszy sposób jest najbardziej oczywisty. Metodą obsługi wielu żądań jednocześnie jest stworzenie wątku dla każdego zaakceptowanego połączenia. Na przykład dla każdego połączenia HTTP serwer tworzy wątek, wykonuje przetwarzanie w wątku, zwraca wyniki w postaci strony HTML a następnie usuwa wątek.  Rozwiązanie proste jednak najmniej efektywne (o tym za chwilę).

Drugi sposób - pula wątków - to rozwinięcie pierwszego. Powstał z dwóch powodów.  Pierwszy powód to zagrożenie jakim jest zbyt duża liczba utworzonych wątków, która może spowodować błąd braku pamięci lub spowolnienie pracy serwera. Każdy wątek tworzy i przetwarza swoje dane (na stosie i stercie - o tym napiszę w kolejnych częściach cyklu), które przechowywane są w pamięci. Zbyt duża liczba utworzonych wątków może sprawić, że zabraknie pamięci RAM. Wpływa to także na przepustowość serwera. Procesor ma ograniczoną liczbę rdzeni, a wątków mogą być tysiące. W takim wypadku system operacyjny zarządza, który wątek ma się teraz uruchamiać. Aby wątek nie miał monopolu to system musi co chwila dawać szansę innym wątkom dokonując tzw. przełączania kontekstu. Operacja ta jest bardzo szybka, jednak wykonując ją setki tysięcy razy można znacznie spowolnić działanie serwera. Drugi nieco mniej ważny powód to długi czas tworzenia wątku lub procesu. Zamiast tworzyć nowy wątek za każdym razem gdy nawiązywane jest połączenie wykorzystywany jest już wcześniej utworzony wątek. Po wykonaniu wszystkich operacji w wątku jest on zwalniany i trafia z powrotem do puli. Teraz można go ponownie wykorzystać.

Trzeci sposób jest zdecydowanie bardziej innowacyjny od poprzednich. Metoda ta zrywa z wielowątkowości całkowicie, a mimo to jest w stanie obsłużyć żądania wielu użytkowników jednocześnie. Jak to możliwe? Wspominałem na początku tego posta, że serwery w kółko na coś czekają - na bazę danych czy na dysk. W czasie tego oczekiwania można by wykorzystać ten pojedynczy wątek na obsługę kolejnego żądania. Nie jest to jednak proste z technicznego punktu widzenia. Wszystkie funkcje, które wykonują jakiekolwiek operacje we/wy np. funkcja odczytu pliku, funkcja uruchamiająca zapytanie na bazie danych musiałaby zlecić wykonanie operacji nie blokując jednak wątku w oczekiwaniu na odpowiedź. Natomiast resztę kodu obsługi żądania musiałaby uruchomić później. Poniżej zamieszczam pseudkod, który pokazuje jako mogłaby wyglądać funkcja obsługi żądania:

funkcja obslugujŻądanie() {
  uruchomZapytanie( { 
    kod ktory ma się uruchomić po otrzymaniu odpowiedzi z bazy
  } );
}

Dowolna funkcja wykonująca operacje we/wy powinna zgłosić systemowi operacyjnemu chęć otrzymania zdarzenia (tzw. gotowości zasobu). Przykładowo funkcja uruchamiająca zapytanie w bazie danych z przykładu powyżej oczekuje odpowiedzi z bazy w postaci listy rekordów. Dane będą dostępne dopiero gdy baza danych wykona zapytanie i zacznie wysyłać pierwsze bajty odpowiedzi poprzez gniazdko. Funkcja ta powinna więc nasłuchiwać zdarzenia gotowości do odczytu z tego gniazdka. 

Po uruchomieniu funkcji obslugujŻądanie serwer wraca do początkowego stanu i może obsłużyć kolejne żądanie. Następnym razem serwer sprawdza czy nie pojawiły się nowe zdarzenia. Okazuje się, że baza danych przesyła odpowiedź. W tym momencie serwer uruchamia kod, który wcześniej został oznaczony do uruchomienia po odpowiedzi z bazy danych.

Poza tymi komplikacjami model numer 3. nie jest też pozbawiony innych wad. Jedną z nich jest niewykorzystanie procesorów wielordzeniowych, gdyż wątek jest zawsze jeden. W tej sytuacji trzeba ręcznie uruchomić np. tyle procesów ile jest rdzeni, co nieco komplikuje kwestie administracji - nie chodzi tu bynajmniej o napisanie skryptu, który uruchamia na przykład 4 procesy zamiast jednego. Każdy proces to serwer nasłuchujący na wskazanym porcie. Nie mogą jednak nasłuchiwać na jednym porcie. Dlatego konieczne jest wykorzystanie jakiegoś serwera "z przodu" (np. load balancera), który przychodzące żądania pokieruje do któregoś z procesów.

Drugą wadą - znacznie poważniejszą jest dość nietypowy sposób pisania kodu. Nie pisze się bowiem programów w standardowy sekwencyjny sposób, linijka po linijce. Zamiast tego trzeba używać tzw. callback'ów czyli funkcji, które uruchamiają się na dane zdarzenie (np. gdy przyjdzie odpowiedź z bazy danych, gdy zostanie przesłane żądanie HTTP itp.). Powyższy przykład pseudo-kodu ilustruje problem. Kod przekazany do funkcji uruchomZapytanie to argument będący blokiem kodu, który nie uruchamia się natychmiast, a dopiero po otrzymaniu zdarzenia z systemu operacyjnego informującym o tym, że baza danych przesłała odpowiedź.

Co jednak zyskujemy korzystając z serwera opartego na jednym wątku sterowanym zdarzeniami? Przede wszystkim dwie rzeczy: mniejsze zużycie pamięci RAM oraz większą przepustowość w sytuacji gdy liczba równoległych żądań jest liczona w setkach a nawet tysiącach. Ile tak naprawdę tej pamięci zyskujemy oraz czasu procesora zostanie opisane w następnej części cyklu.

Metoda numer cztery to zmodyfikowana wersja metody pojedynczego wątku. Owszem, tu także jest jeden wątek, który wyciąga zdarzenia informujące o przesyłaniu żądań do serwera. Jednak po nawiązaniu połączenia wykorzystywana jest pula pracowników (workers). Każdy wątek z puli obsługuje jedno połączenie. Rozwiązanie to jest o tyle dobre, że wykorzystane są procesory wielordzeniowe a także w kodzie, który obsługuje żądanie nie ma konieczności wykorzystywania nietypowych rozwiązań z funkcjami typu callback, gdyż uśpienie wątku w oczekiwaniu na odpowiedź z bazy danych nie powstrzymuje serwera od obsługi innych żądań (usypianie ma swoje wady o czym w następnej części).

W praktyce stosuje się różne przedstawione tu metody, w zależności od potrzeb dotyczących wydajności a także łatwości utrzymania. Pierwsza metoda z tworzeniem wątku dla każdego zaakceptowanego połączenia jest raczej książkowa i nie powinna być używana w produkcyjnym kodzie. Użycie puli jest już wiele lepsze i niektóre ze starszych serwerów realizują wielozadaniowość właśnie w ten sposób. Pojedynczy wątek wykorzystywany jest zaś przez ostatnio bardzo popularny Node.js. Serwery NginxTomcat lub Jetty wykorzystują podejście z jednym wątkiem głównym i wieloma pracownikami. W następnej części porównam wszystkie te rozwiązania pod kątem wydajności oraz zużycia pamięci.

2 komentarze:

  1. Dlatego do pracy na socketach używam Apache Mina :)

    OdpowiedzUsuń
  2. Słuszny wybór Piotrze :) Podobnie dobrym (może nawet lepszym?) rozwiązaniem jest Netty. W każdym razie obie technologie w jakimś stopniu upraszczają i systematyzują tworzenie sterowanych zdarzeniami serwerów i klientów w Javie.

    OdpowiedzUsuń