niedziela, 29 stycznia 2012

Nieblokujące serwery sterowane zdarzeniami cz. 3: Zyski

Nieblokujące serwery sterowane zdarzeniami mogą mieć większą przepustowość od klasycznego synchronicznego (czyli blokującego) podejścia z wykorzystaniem puli wątków. Do tej pory skupiłem się na wprowadzeniu w temat (część pierwsza cyklu) oraz opisałem jak można zapewnić równoległą obsługę żądań (część druga). Teraz nasuwa się pytanie ile (dokładnie) możemy na tym zyskać i jakie konkretne problemy rozwiązać. Mylne jest bowiem założenie, że dowolną tworzoną aplikację powinniśmy tworzyć w oparciu o tą a nie inną architekturę. Każdy dobry programista/projektant/architekt wie, że zawsze powinniśmy starać się dobierać narzędzia odpowiednie do problemu, który chcemy rozwiązać. W poprzednich częściach cyklu napisałem, że wykorzystując asynchroniczne serwery sterowane zdarzeniami oszczędzamy na takich zasobach jak pamięć oraz procesor. W tym poście opiszę ile można oszczędzić i w jakich przypadkach.

W poprzednim poście opisałem sposoby równoległej obsługi żądań. Podejście nieblokujące sterowane zdarzeniami w porównaniu do pozostałych charakteryzowało się tym, że wykorzystywało znacznie mniejszą liczbę wątków lub procesów (od jednego do kilku wątków, na ogół nie więcej niż liczba dostępnych procesorów/rdzeni). Pomimo tak małej liczby wątków serwery te potrafiły obsługiwać bardzo duże ilości żądań równoległych o liczbie wielokrotnie większej niż ilość użytych wątków, oszczędzając przy tym na zasobach. Ile jednak można zaoszczędzić i czy gra jest warta świeczki?

Aby zrozumieć ile można zyskać trzeba znowu zejść o wiele warstw niżej - aż do systemu operacyjnego i sposobu w jaki obsługuje on procesy oraz wątki. Procesy od wątków różnią się między sobą znacznie, jednak nie zamierzam tu opisywać wszystkich różnic. Skupię się tylko na wykorzystaniu przez nich zasobów (tj. pamięci oraz czasu procesora).

Wykorzystanie pamięci RAM

Zarówno proces jak i wątek posiadają na własność jakąś część pamięci wirtualnej. Istotne tu jest słowo "na własność", które oznacza, że dostęp do tej pamięci ma tylko dany proces lub dany wątek. Inne procesy i wątki nie mogą jej odczytywać ani modyfikować.  Każdy proces posiada na własność m.in. stertę, stos oraz kod programu. Wątki posiadają na własność tylko stos - stertę oraz kod współdzielą z innymi wątkami. Można wykonać obliczenia, ile więcej pamięci wirtualnej pochłonie przykładowe 1000 wątków/procesów w stosunku do 10 wątków/procesów. W przypadku wątków obliczenie ilości konsumowanej pamięci wirtualnej jest stosunkowo proste. Przy tworzeniu każdego wątku deklaruje się bowiem rozmiar stosu. Dokładnie taki rozmiar zostanie alokowany przez system operacyjny. Nieważne jak bardzo zagnieżdżone będą wywołania w kodzie - stos wątku przez cały cykl życia wątku zajmuje tyle samo pamięci. Teraz trzeba zadać pytanie ile pamięci potrzeba na stos wątku. Wielkość ta zależna jest od wykorzystanego języka programowania. Różne języki pozwalają przechowywać różne rzeczy na stosie. W przypadku języka C/C++ rozsądną liczbą jest 1MB (dla 32-bitowego systemu), w przypadku Javy stos może być mniejszy, bo tutaj większość struktur przechowywana jest na stercie (512KB domyślnie na Oracle JVM na x86-32). Ktoś mógłby się przyczepić, że przecież program w C/C++ może podobnie jak program w Javie przechowywać na stosie tylko referencje i typy proste - jeśli tak jest to można założyć, że Java i C/C++ będą potrzebowały stosu o podobnej wielkości. Ale czy tak to wygląda w rzeczywistości? Jeśli programujesz w C/C++ zapraszam do dodania swojego komentarza :) Wracając do założeń dotyczących wielkości stosu wykonajmy prostą matematykę:

Dla programu w C/C++:
10 wątków - 10MB
1000 wątków - 1GB

Dla programu w Java:
10 wątków      - 5MB
1000 wątków  - 500MB

500MB-1GB to nawet w architekturze 32-bitowej nie stanowi przesadnie wiele. Załóżmy, że obecnie za niewielkie pieniądze można kupić średniej klasy serwer wyposażony w 16GB RAM działający na 64-bitowym systemie operacyjnym. Mnożąc te liczby razy 2 (takie bardzo naiwne założenie, że architektura 64-bitowa pochłania 2 razy więcej RAMu) to w przypadku C/C++ program pochłaniał będzie 2GB RAMu, w przypadku Java 1GB. Myślę, że przy tej konfiguracji sprzętowej wartości te są dopuszczalne - nie powinny stanowić większego problemu. Problem jednak jest jeśli chcemy móc obsługiwać jeszcze większą liczbę żądań równocześnie. Np. 10000 wątków. W tym momencie program będzie wymagał porządnej maszyny, która raczej nie zaliczać się będzie do tych przeciętnej klasy. Pytanie tylko, po co nam aż 10000 wątków? O dziwo są aplikacje, które mają takie wymagania, opiszę je jednak dopiero w następnej części cyklu. Tymczasem wróćmy do rozważań na temat wykorzystania zasobów.

Jeśli chodzi o wykorzystanie pamięci wirtualnej przez procesy to niestety sprawa już nie jest taka prosta. Rozmiar pamięci wirtualnej wykorzystywanej przez proces zmienia się w czasie. Każde wywołanie malloc lub podobnej funkcji powoduje alokację pamięci dla sterty. Stos z kolei zwiększa się wraz z kolejno odkładanymi ramkami i w większości współczesnych systemów operacyjnych raczej pozostaje w tym rozmiarze (tzn. nie zmniejsza się już pomimo, że np. skończyliśmy wywoływać funkcję rekurencyjną). Ciężko jest jednak obliczyć ile proces będzie potrzebował pamięci. Nie tylko rozmiar stosu ma tu znaczenie, ale przede wszystkim rozmiar sterty i kod programu. Jakby tego było mało system operacyjny może optymalizować wykorzystanie pamięci przez proces i np. wybrane strony w pamięci wirtualnej współdzielić między procesy (o ile żaden z procesów ich nie modyfikuje). Ciężko jest więc obliczyć jaki narzut na zużycie pamięci mają procesy. Być może Ty masz jakiś pomysł jak to obliczyć :) ?

Czytając fragment dotyczący wykorzystania wątków można by odnieść wrażenie, że tylko liczba wątków ma znaczenie - im więcej wątków tym większe zużycie pamięci. Jednak w rzeczywistości liczy się tak naprawdę liczba żądań. Nawet wykorzystując jeden wątek do obsługi wszystkich żądań trzeba liczyć się z tym, że każde żądanie przechowuje jakieś dane na stercie (np. wyniki zapytania potrzebne do kolejnego kroku w kodzie obsługującym żądanie). Czy kod czeka na nie czy nie to i tak musi przechowywać jakieś dane na stercie. Dlatego w wielu aplikacjach (o ile nie w większości aplikacji bazujących na jakieś bazie danych) problemem może być zbyt duże wykorzystane pamięci na potrzeby sterty. W takiej sytuacji może się okazać, że bez względu na ilość użytych wątków maksymalna liczba równolegle obsługiwanych żądań jest na tyle mała, że nie ma nawet sensu zastanawiać się czy chcemy wykorzystywać synchroniczny czy asynchroniczny serwer (w jednym z systemów, przy których przyszło mi pracować w przeszłości liczba równoległych wątków dla konkretnego żądania nie mogła być większa od pięciu, bo kończyła się pamięć).

Wykorzystanie CPU


Teraz czas na drugi zasób - procesor. Sposób w jaki liczba wątków wpływa na procesor jest znacznie bardziej skomplikowany aniżeli sposób w jaki wpływa na pamięć. Mogłoby się wydawać, że liczba uruchomionych wątków lub procesów nie wpływa znacząco na procesor. Otóż nie, o ile liczba wątków lub procesów nie jest liczona w wielu tysiącach. Czemu jednak liczba ma znaczenie? Powód jest jeden - jeśli liczba procesów i wątków jest większa od liczby rdzeni/procesorów w serwerze to zadaniem systemu operacyjnego (schedulera) jest zarządzanie przydzielaniem czasu procesora dla danego procesu lub wątku. Oczywiście wskazane jest, żeby system operacyjny robił to w taki sposób, aby użytkownik miał wrażenie, że wszystkie te operacje wykonywane są równocześnie (za pomocą tzw. wywłaszczania). System operacyjny przydziela więc w krótkich odstępach czasowych czas procesora dla wybranych wątków lub procesów, które mają coś do zrobienia. To co spowalnia to tzw. context switch, czyli przełączenie procesora aby wykonywał kod z następnego wątku lub procesu. Operacja polega na skopiowaniu pewnych danych opisujących wątek lub proces z pamięci RAM do procesora i na odwrót. Przykładowo wątek, który aktualnie jest uruchomiony na procesorze wykorzystuje rejestry procesora oraz licznik rozkazów. W momencie przełączenia na inny wątek - trzeba skopiować te rejestry oraz licznik do pamięci, a z pamięci skopiować dane o następnym wątku do procesora. Operacja ta powinna być błyskawiczna - liczona w kilku/kilkunastu mikrosekundach. Jednak jeśli jest wykonywana tysiące razy w ciągu sekundy to jest w stanie spowolnić czas obsługi żądań. Context switch wykonywany jest w kilku przypadkach. Wykonuje go sam scheduler podczas wywłaszczania, wykonuje go program, który w wyniku uruchomienia polecenia systemowego czeka na dane (np. czyta z pliku). Context switch uruchamiany jest także wtedy gdy wykonywana jest funkcja systemowa sleep. Podsumowując im więcej wątków tym większa szansa na context switch. Aplikacje, które nie wykonują obliczeń, a jedynie zbierają wyniki z różnych zewnętrznych zasobów (np. bazy danych) nie korzystają zanadto z czasu procesora - w ich przypadku to właśnie context-switch może najbardziej obciążać procesor. Ile jednak operacji context-switch musi się uruchomić, żeby miało to wpływ na ogólną pracę serwera? Zakładając, że jeden context-switch to 10us to wychodzi, że musiałby być uruchomiony co najmniej 50000 razy w ciągu sekundy (50k switchów trwa około 500ms). Liczba taka jest jak najbardziej osiągalna jeśli napiszemy program wykorzystujący 10000 wątków i wykonujący operację sleep w każdym z nich co 100ms. Przykładowy kod w Java zamiesciłem na GitHub. Polecenie sleep ma emulować czekanie wątku na zasoby. Założyłem, że będzie to 100ms. W rzeczywistości czas oczekiwania może bardzo różny np. dla prostego zapytania w bazie trwać kilkanaście sekund do nawet kilku sekund oczekiwania na odpowiedź zdalnego serwera www. Ciężko mi jednak znaleźć jakąś średnią - wszystko zależy od tworzonej aplikacji. Przykładowy program można uruchomić na swoim komputerze i użyć narzędzia do mierzenia context switch'ów (np. sar pod Linuxem). Przykład powoduje na moim komputerze wykorzystanie 50% czasu procesora na sam context-switch co daje do myślenia i nietrudno się domyśleć, że może mieć wpływ na ostateczną przepustowość serwera. Trzeba jednak pamiętać, że liczba wątków naprawdę musi być duża tzn liczona w dziesiątkach tysięcy (przy pauzach 100ms) lub w tysiącach (jeśli pauza wynosi 10ms).

Opisując czas potrzebny do wykonania context switch skupiłem się na wątkach. Zrobiłem to celowo, ponieważ context switch w przypadku wątków jest znacznie bardziej efektywny od tej samej operacji w przypadku procesu. Dzieję się tak z jednego powodu - pamięci wirtualnej. System operacyjny musi bowiem pamiętać tablicę stron dla procesu - czyli odwzorowania adresów logicznych na adresy fizyczne. Tablice te przechowywane są w pamięci cache procesora, aby czas translacji adresów był bardzo szybki. Wykonanie context switch procesu sprawia, że tablica ta staje się nieaktualna (trzeba tu wgrać nową tablicę). W przypadku wątków tablica stron jest współdzielona, więc context switch jest szybszy. Dlatego decydując się na wykorzystanie procesów dla  zapewniania  równoległej obsługi żądań trzeba liczyć się z tym, że procesor będzie bardziej obciążony (jak bardzo zależy tak naprawdę od samego procesu i wielkości pamięci wirtualnej, którą wykorzystuje).

Myślę, że ten post pozwolił Ci zrozumieć potrzebę wykorzystania asynchronicznych serwerów sterowanych zdarzeniami. Przydają się one przede wszystkim w sytuacjach, gdy serwer ma obsługiwać bardzo dużą liczbę równoległych żądań liczonych w tysiącach, przy założeniu jednak, że ilość zasobów potrzebnych na obsługę żądań jest niewielka (to znaczy każde żądanie nie pochłania dużej ilości pamięci lub czasu procesora). Przydatne są także wtedy gdy każde żądanie trwa długi czas (przez kilka sekund) czekając przez większość czasu na zewnętrzne zasoby (takie jak zewn. serwer czy dysk twardy). W następnej części cyklu opiszę konkretne zastosowania.

Brak komentarzy:

Prześlij komentarz