Co za tydzień, newsletter nazwany 12b i teraz jeszcze post ;) A wszystko po to by nie było 1X tak jak to jest przyjęte w Europie ;) Choć dostałem dzisiaj info, że w chinach nie ma numeru 4, gdyż czyta się go jako “SY”, a to brzmi tak samo jak śmierć. Czego to człowiek się nie uczy na starość :) Ale wracajmy na ziemie, to znaczy, do naszego elixira. Dziś w elixir rozwiniemy to co zaczęliśmy w zeszłym tygodniu. Czyli na bazie spawn, send, receive, zbudujemy sobie mikro serwer działający w innym procesie, do którego będziemy słali ping i oczekiwali pong :) Zaczynajmy

Własny Serwer

Na początku założenia naszego mini serwera. Chcemy wysłać ping do kodu działającego w innym procesie, odszczekać sekundę i uzyskać odpowiedź pong. To odczekanie jest potrzebne by zademonstrować bottleneck o którym wspomniałem w zeszłym tygodniu.

Stwórzmy sobie najpierw plik PingPongServer.exs w jakimś tam katalogu, i odpalmy iex za pomocą komendy:

iex PingPongServer.exs

Teraz, mamy iex i możemy działać, brakuje nam tylko kodu ;) a więc do działa. Co potrzebujemy by zrobić mini serwer?

  • Serwer musimy jakoś wystartować tak by działał on na innym procesie
  • By coś działało na innym procesie wykorzystujemy spawn
  • By móc coś wysłać na ten serwer, potrzebujemy znać jego PID, na szczęście spawn nam go zwraca
  • By serwer działał cały czas w tle, to znaczy, że po otrzymaniu wiadomości, musi na nowo zacząć nasłuchiwać
  • Możemy sobie ułatwić i stworzyć metodę do wysyłania wiadomośći

Ok, to od czego zaczynamy? Od stworzenia części serwerowej, która będzie działała w innym procesie, zwracała nam PID jak i działała w kółko:

defmodule PingPongServer do
    # startujemy server
    def start do
        spawn(fn -> loop end)
    end

    # działamy w kółko
    defp loop do
        loop
    end
end

Metoda PingPongServer.start zwróci nam PID utworzonego procesu, jak i wystartuje pętle loop która będzie działała w kółko (rekurencja ogonowa, nic nam się w elixir nie powiesi).

Teraz, przydałoby się jakoś móc odbierać wiadomości wysłane do tego świeżo utworzonego procesu. W tym celu musimy zmodyfikować loop tak by czekało ono na wiadomość – jak wiemy, czekamy aż do skutku, blokując wykonywanie kodu aplikacji, a więc do loop wystarczy dodać:

defp loop do
    receive do
        msg ->
            IO.puts "Received: #{msg}"
    end
    loop
end

Gdzie czekamy na wiadomość, która będzie zawierała treść przez nas wysłaną.

Możemy to przetestować w iex:

r(PingPongServer)

server = PingPongServer.start
send(server, "Hello1")
send(server, "Hello2")
send(server, "Hello3")

Otrzymamy trzy wiadomości:

Received: Hello1
Received: Hello2
Received: Hello3

Teraz serwer działa tak jak chcieliśmy, w pętli odbiera wiadomość i zaczyna pracę od nowa. Komenda r(PingPongServer), ładuje nam na nowo moduł który został załadowany podczas ładowania iex – więc jak tylko zmodyfikujecie PingPongServer to można ją odpalić.

Dobrze, mamy już większość rzeczy zrobione, możemy teraz dodać czekanie 1 sekundę na odpowiedź serwera. Do odbioru wiadomości msg wystarczy dodać :timer.sleep(1000), co powoduje, że nasz loop wygląda następująco:

defp loop do
    receive do
        msg ->
            :timer.sleep(1000)
            IO.puts "Received: #{msg}"
    end
    loop
end

Jako, że tworzymy serwer PingPong, to znaczy, że na pytanie ping, powinniśmy odpowiedzieć pong. Aktualnie, nie odpowiadamy niczym, jedynie wypisujemy, że otrzymaliśmy wiadomość. By móc odpowiedzieć, musimy znać PID procesu który do nas wysłał wiadomość. Jak się okazuje, PID to tylko identyfikator, więc można go przekazywać jako argument. Możemy tak zmodyfikować naszą pętlę by otrzymywała zarówno informację kto wysłał wiadomość jak i jej treść:

defp loop do
    receive do
        {caller, msg} ->
            :timer.sleep(1000)
            IO.puts "Received: #{msg} from #{inspect(caller)}"
    end
    loop
end

Teraz by wysłać wiadomość, musimy przekazać PID procesu wysyłającego wiadomość. Jest to dość proste, wystarczy podać na przykład self:

r(PingPongServer)

server = PingPongServer.start
send(server, {self, "Hello1"})

Wysyłając wiadomość do procesu serwera, o treści {self, "Hello1"} gdzie, self to PID naszego procesu, zaś "Hello1" to wiadomość, powinniśmy otrzymać coś takiego (numer PID może się różnić):

Received: Hello1 from #PID<0.61.0>

Super, pozostaje nam więc odesłanie ponga – dodajemy wywołanie send:

send(caller, {:pong, "...pong"})

Pozwoliłem sobie jednak trochę zmodyfikować nasz loop by miał on licznik odebranych pingów. Przypominam, że \\ ustawia domyślną wartość argumentu.

defp loop i \\ 0 do
    receive do
        {caller, msg} ->
            :timer.sleep(1000)
            IO.puts "##{i} Received: #{msg} from #{inspect(caller)}"
            send(caller, {:pong, "...pong ##{i}"})
    end
    i = i + 1
    loop i
end

Dzięki czemu, wiemy którą wiadomość otrzymujemy i którą odsyłamy.

Dobrze, mamy prawie wszystko, mamy serwer działający w tle w nieskończoność. Odbiera on wiadomości, wykonuje jakąś długą operację i zwraca pong do procesu który przesłał wiadomość do serwera. Teraz możemy ciutkę sobie ułatwić i napisać mały kod, który przyspieszy nam wysyłanie pingów. Więc zamiast w iex:

send(server, {self, "Hello1"})

Moglibyśmy wywołać funkcję

PingPongServer.ping(server)

W tym celu, musimy utworzyć funkcję ping:

def ping server do
    send(server, {self, "ping"})
end

Jak chcemy, możemy to przetestować, wszystko powinno śmigać. Jednak wciąż nie otrzymujemy naszej wiadomości pong. By ją otrzymać musimy nasłuchiwać na wiadomości. Najlepszym do tego miejscem będzie metoda ping:

def ping server do
    send(server, {self, "ping"})
    receive do
        {:pong, msg} ->
            IO.puts "Caller Received: #{msg}"
            msg
    end
end

Teraz możemy to przetestować, jednak by nie mieć problemu z zakolejkowanymi wiadomościami do tej pory (a trochę mogliśmy ich mieć bawiąc się w jednej sesji iex), ubijmy iex i odpalmy go na nowo. Teraz jak wykonamy nasz ping powinniśmy otrzymać wynik:

#0 Received: ping from #PID<0.61.0>
Caller Received: ...pong #0
"...pong #0"

Ten ostatni pong to jest wartość zwrócona z receive -> ten msg. On nie jest potrzebny, ale może się przydać w przykładzie z bottleneck. Na wszelki wypadek, pełny kod serwera:

defmodule PingPongServer do
    # startujemy server
    def start do
        spawn(fn -> loop end)
    end

    # wysyłamy ping
    def ping server do
        send(server, {self, "ping"})
        receive do
            {:pong, msg} ->
                IO.puts "Caller Received: #{msg}"
                msg
        end
    end

    # działamy w kółko
    defp loop i \\ 0 do
        receive do
            {caller, msg} ->
                :timer.sleep(1000)
                IO.puts "##{i} Received: #{msg} from #{inspect(caller)}"
                send(caller, {:pong, "...pong ##{i}"})
        end
        i = i + 1
        loop i
    end
end

Bottleneck

Mając nasz serwer gotowy i załadowany do iex, wykonajmy sobie następujący kod:

Enum.each(1..100, fn(i) ->
    spawn(fn ->
        IO.puts "Call ##{i}"
        ret = PingPongServer.ping(server)
        # możemy to wypisać: IO.puts "Got #{ret} from call ##{i}"
    end)
end)

Teraz, łatwo zaobserwować, że jak już wykonywanie kodu trwa sekundę, to na ostatnią odpowiedź na ping będziemy musieli trochę poczekać. Lepiej nie czekajmy… ale widać, że łatwo coś co niby trwa mikrosekundy, zamienić w coś co będzie trwać baaardzo długo. Sprawdźcie zresztą sobie sami. Wywalcie timeout z serwera. I zobaczcie jak to będzie zapierniczało :)

Podsumowanie

To co jest najważniejsze, to to byście zobaczyli na jakiej zasadzie działa ten serwer. Byście go zrozumieli, i byli wstanie sami napisać taki bez ściągawki. Dlaczego? Dlatego, że to jest wzorzec najczęściej chyba w elixir wykorzystywany. Wszystkie jego implementacja są podobne. Wiedząc jak to działa, będziecie mogli na swoje potrzeby tworzyć podobne serwery.

Jednak, jest to na tyle popularny wzorzec elixir, że aż jest dostępny moduł powodujący, że pisanie takiego serwera, jest jeszcze prostsze. Myślałem, żę się dziś z tym tematem też uwinię, ale się nie udało. A więc, pozostawiam go do następnego tygodnia :)

PS.: Naprawdę, tego typu serwer każdy kto się uczy lub pracuje z elixir powinien móc napisać samemu. Jest to podstawą! :)
PS2.: Zauważyliście, że właśnie stworzyliście obliczanie równoległe bez konieczności robienia żadnych synców? ;)

2 KOMENTARZE

Comments are closed.