niedziela, 19 lutego 2012

Nieblokujące serwery sterowane zdarzeniami cz. 5: Problemy

Początkowo część ta miała być tylko podsumowaniem cyklu jednak po rozmowach ze znajomymi stwierdziłem, że do tej pory nie napisałem o problemach, które może sprawiać architektura asynchronicznych serwerów sterowanych zdarzeniami. Postanowiłem więc opisać najważniejsze problemy, gdyż z punktu widzenia architekta lub programisty są bardzo istotne i mogą mieć decydujący wpływ na wybór tej architektury.

Nierzadko przeglądając strony promujące technologie tworzenia aplikacji www można odnieść wrażenie, że jedyne co wystarczy zrobić aby zapewnić dużą przepustowość jest dobór odpowiedniego serwera www i można już zacząć prace nad aplikacją. Cześć osób (niesłusznie) podejmuje decyzje w kategorii produktów a nie architektury. Jeśli serwer wygrywa w benchmarkach to znaczy, że jest najlepszy. Problem w tym, że jeśli serwer jest zdecydowanie wydajniejszy niż inne serwery to coś musi się za tym kryć. Nie można liczyć na to, że programiści danej implementacji potrafią pisać wydajniejszy kod od innych. W większości kod udostępniany jest na zasadach Open Source, dlatego serwery oparte na podobnej architekturze często osiągają podobne wyniki. O wydajności decyduje przede wszystkim architektura.

W przypadku dużej liczby równoległych zapytań liczonych w tysiącach na sekundę serwery nieblokujące sterowane zdarzeniami są w stanie zapewnić większą przepustowość od "klasycznego" podejścia. Nie ma jednak nic za darmo. Posiadają one dwie poważne wady: konieczność wykorzystania dedykowanych API oraz niedogodności związane z funkcjami zwrotnymi (callbacks).

Architektura serwerów asynchronicznych sterowanych zdarzeniami wymusza na programiście pisanie kodu, który nie blokuje wątku w oczekiwaniu na zasoby. Dlatego każda operacja - np. przesłanie zapytania do bazy danych, kolejki, wysłanie żądania HTTP musi być asynchroniczna. Oczywiście w dzisiejszych czasach nikt nie pisze sterowników do bazy danych, kolejek czy klientów HTTP samemu. Wykorzystuje się sprawdzone i dobrze znane biblioteki. Niestety większość z nich ze względu na prostotę obsługi korzysta z podejścia blokującego. Przykładowo wątek zablokowany jest do czasu, gdy program otrzyma odpowiedź z bazy danych. Liczba bibliotek asynchronicznych ciągle się powiększa jednak potrzeba jeszcze sporo czasu aby były one na tyle stabilne co ich blokujące odpowiedniki. Najdobitniejszym tego przykładem są dostępne dziś sterowniki baz danych (nie tylko tych relacyjnych). Przykładowo standard JDBC z założenia jest blokujący, dlatego wszystkie sterowniki baz relacyjnych dla Javy są blokujące. Owszem są próby stworzenia asynchronicznych sterowników (np. ADBCJ), jednak dużo czasu upłynie zanim taki sterownik będzie na tyle stabilny aby można było go użyć na produkcji. Także sterowniki najnowszych baz NoSQL często pisane są w oparciu o model blokujący - np. oficjalny sterownik MongoDB dla Javy jest synchroniczny. Problem nie tylko dotyczy Javy, a praktycznie każdego języka programowania. Dlatego przed zdecydowaniem się czy chcemy wykorzystać model asynchroniczny trzeba sprawdzić czy mamy do tego niezbędne narzędzia,

Teraz będzie trochę o kodzie jaki przyjdzie tworzyć programiście. Obecnie najbardziej spopularyzowanym sposobem pisania programów asynchronicznych jest wykorzystanie tzw. callbacks. Funkcje zwrotne (callbacks) to specjalny rodzaj funkcji, który uruchamiany jest dopiero po jakimś zdarzeniu np. gdy zapytanie w bazie danych zwróci wyniki i zostaną one przesłane do naszego serwera. Funkcje zwrotne są przekazane do API, które np. uruchamia zapytania w bazie danych. W JavaScript można przekazać obiekt funkcji, w Groovy closure, w Javie obiekt klasy (często anonimowej) itp. Aby zilustrować różnice pomiędzy kodem wykonywanym synchronicznie a tym asynchronicznym poniżej zamieszczam 2 przykłady kodu w JavaScript - pierwszy w podejściu blokującym, drugi w nieblokującym:

try {
  var result = connection.query("SELECT * FROM test1");
  for(var i=0; i<result.records.length; i++) {
// rób coś z rekordami przesłanymi z bazy
  };
} catch (e) {
    sys.puts("Error: " + e);
}


connection.query("SELECT * FROM test1",
    function(result) {
        for(var i=0; i<result.records.length; i++) {
        // rób coś z rekordami przesłanymi z bazy
        };
    },
    function(error) {
        sys.puts("Error: " + error);
    });


Przykład z rozwiązaniem blokującym jest bardzo prosty. Uruchamiane jest zapytanie, które blokuje wątek dopóki baza nie odpowie. Następnie wyniki są przetwarzane. W przypadku błędu zostanie wyrzucony wyjątek. Przykład z rozwiązaniem nieblokującym jest już bardziej złożony. Metoda query powoduje przesłanie zapytania do bazy danych. Rejestruje ona chęć odebrania odpowiedzi z gniazda utworzonego z bazą danych w systemie operacyjnym (np. za pomocą biblioteki epoll). Dzięki temu nie blokuje wykonania wątku. Gdy baza danych odpowie i wyśle odpowiedź to zostanie uruchomiona funkcja przekazana w powyższym przykładzie jako drugi argument. Gdy pojawi się jakiś błąd to zostanie uruchomiona funkcja przekazana jako ostatni argument. Co dzieje się z wątkiem, który został użyty do uruchomienia zapytania na bazie? Oczywiście będzie on mógł być wykorzystany do obsługi następnego żądania przesłanego do naszego serwera.

Z wykorzystaniem funkcji zwrotnej wiążą się trzy problemy: czytelność, problemy w znajdowaniu błędów oraz problemy z wyciekami pamięci. Pierwszy z nich uwidacznia się szczególnie gdy w ramach jednego żądania trzeba zadeklarować kilka a nawet kilkanaście takich zagnieżdżonych funkcji. Oto przykład z zaledwie trzema zapytaniami:


connection.query("SELECT * FROM test1",
    function(result) {
        for(var i=0; i<result.records.length; i++) {
         connection.query("SELECT * FROM test2",
            function(result) {
              for(var i=0; i<result.records.length; i++) {
            connection.query("SELECT * FROM test3",
                function(result) {
                  for(var i=0; i<result.records.length; i++) {
         // rób coś z rekordami przesłanymi z bazy
                  };
                 },
                function(error) {
                  sys.puts("Error: " + error);
                });               };
             },
            function(error) {
              sys.puts("Error: " + error);
            });
        };
    },
    function(error) {
        sys.puts("Error: " + error);
    });


Drugi problem to kłopoty z debugowaniem kodu. W synchronicznym podejściu jeśli pojawi się jakiś błąd to z przechwyconego wyjątku można wyciągnąć ślad stosu (czyli po kolei nazwy funkcji, które były wykonywane od momentu przyjścia żądania do serwera). W przypadku architektury asynchronicznej ślad stosu będzie skrócony - tzn. nie będziemy widzieć funkcji, które były uruchamiane wcześniej przez wykonaniem jakieś operacji nieblokującej. Przykładowo zamiast takiego śladu stosu:

WyjątekBazyDanych
Callback.onError : Linia 6
klientBazyDanych.uruchomZapytanie: Linia 232
aplikacja.Akcja.obslużŻądanie: Linia 3
jakiś.Serwer.przekażŻądanieDoAplikacji : Linia 15
jakis.Serwer.obslużŻądanie : Linia 10

Będziemy mieli taki:

WyjątekBazyDanych
Callback.onError : Linia 6
jakis.Serwer.pętlaZdarzeń: Linia 241

O ile kod napisaliśmy w sposób jaki przedstawiłem na przykładzie powyżej (tzn. z wykorzystaniem funkcji anonimowych, za pomocą closure lub za pomocą klas anonimowych) to sprawa jest uproszczona - ze śladu stosu wiemy dokładnie, która linijka powoduje błąd. Sytuacja komplikuje się, gdy np. jedna funkcja zwrotna wykorzystywana jest w wielu miejscach np.:

var callback = function(result) {
        for(var i=0; i<result.records.length; i++) {
         // rób coś z rekordami przesłanymi z bazy
        };
    };
1: connection.query("SELECT * FROM test1", callback);
2: connection.query("SELECT * FROM test2", callback);


W tym przykładzie nie wiemy które zapytanie spowodowało uruchomienie funkcji zwrotnej. Nie trudno się domyślić, że często może przysporzyć to nam problemów w znajdywaniu błędów. Jest to szczególnie uciążliwe gdy na przykład otrzymaliśmy logi z błędami z produkcji i nie wiemy tak naprawdę co generuje błąd.

Ostatni już problem to raczej nie jest problem charakterystyczny dla podejścia nieblokującego - występuje on w obu podejściach jednak wykorzystując serwery sterowane zdarzeniami można o nim łatwo zapomnieć (lub inaczej - ciężko go zrozumieć :)). Chodzi o potencjalne niebezpieczeństwo związane z wyczerpaniem przydzielonej pamięci. W chwili oczekiwania na zasoby (np. odpowiedź z bazy danych) wątek wykorzystywany jest do obsługi innego żądania. Nie oznacza to jednak, że cały fragment pamięci wykorzystywany do tej pory w wątku jest czyszczony. W poprzednich częściach pisałem co nieco o stosie i stercie. Stos dotyczył wątku, sterta całego procesu. W sytuacji gdy wątek może być wykorzystany na obsługę innego żądania to stos nie jest już potrzebny - można go "wyzerować". Co innego sterta - obiekty przechowywane na stercie są czyszczone przez garbage collector dopiero gdy nikt nie ma do nich referencji.  Mogłoby się wydawać, że skoro stos został "wyzerowany" to nie ma już żadnych referencji do obiektów na stercie. Nic bardziej mylnego - klasy anonimowe, closures czy funkcje anonimowe to obiekty, które mogą posiadać referencje do niektórych obiektów ze sterty - wszystko zależy od tego, z których zmiennych korzystamy. Przykładowo:

var tablica = [1, 2, 3, 4, 5, 6];
connection.query("SELECT * FROM test1",
    function(result) {
      var e =  tablica[0];
    });

Powyższy kod wykorzystuje tablicę, więc nie może być ona usunięta przez garbage collector. Niektóre języki programowania (takie jak Java) ze względu na statyczne typowanie są w stanie już na etapie kompilacji wydedukować, które zmienne muszą zostać w pamięci (a które nie). Dzięki temu przechowywane są tylko te rzeczywiście używane. Implementacje innych języków np. JavaScript, Ruby mogą już z tym mieć problemy, przez co często trzeba przechowują wszystkie zmienne ze stosu! W obu przypadkach trzeba pamiętać o zagrożeniu bo w sytuacji gdy aplikacja będzie wykorzystywać bardzo dużą ilość pamięci RAM na stertę to może się okazać, że będziemy w stanie obsłużyć tylko niewielką liczbę równoległych żądań. 

Na tym skończę opisywanie problemów związanych z wykorzystaniem nieblokujących serwerów sterowanych zdarzeniami. W następnej części podsumuję cały cykl i wypunktuję obecnie najpopularniejsze technologie tworzenia aplikacji asynchronicznych. 

Brak komentarzy:

Prześlij komentarz