Jak / dlaczego języki funkcjonalne (szczególnie Erlang) dobrze się skalują?

92

Od jakiegoś czasu obserwuję rosnącą widoczność funkcjonalnych języków programowania i funkcji. Zajrzałem do nich i nie widziałem powodu odwołania.

Niedawno byłem na prezentacji Kevina Smitha „Basics of Erlang” w Codemash .

Podobała mi się prezentacja i dowiedziałem się, że wiele atrybutów programowania funkcjonalnego znacznie ułatwia unikanie problemów z wątkami / współbieżnością. Rozumiem, że brak stanu i zmienności uniemożliwia wielu wątkom zmianę tych samych danych, ale Kevin powiedział (jeśli dobrze zrozumiałem) cała komunikacja odbywa się za pośrednictwem wiadomości, a wiadomości są przetwarzane synchronicznie (ponownie unikając problemów z współbieżnością).

Ale przeczytałem, że Erlang jest używany w wysoce skalowalnych aplikacjach (przez cały powód, dla którego Ericsson go stworzył). Jak efektywnie obsługiwać tysiące żądań na sekundę, jeśli wszystko jest obsługiwane jako synchronicznie przetwarzana wiadomość? Czy to nie jest powód, dla którego zaczęliśmy przechodzić w kierunku przetwarzania asynchronicznego - abyśmy mogli skorzystać z wielu wątków operacji w tym samym czasie i osiągnąć skalowalność? Wygląda na to, że ta architektura, choć bezpieczniejsza, jest krokiem wstecz pod względem skalowalności. czego mi brakuje?

Rozumiem, że twórcy Erlanga celowo unikali obsługi wątków, aby uniknąć problemów ze współbieżnością, ale uważałem, że wielowątkowość jest niezbędna do osiągnięcia skalowalności.

W jaki sposób funkcjonalne języki programowania mogą być z natury bezpieczne dla wątków, a jednocześnie nadal skalować?

Jim Anderson
źródło
1
[Nie wspomniano]: VM Erlangsa przenosi asynchroniczność na inny poziom. Dzięki voodoo magic (asm) umożliwia operacje synchronizacji, takie jak socket: read to block bez zatrzymywania wątku systemu operacyjnego. Pozwala to na pisanie kodu synchronicznego, gdy inne języki zmusiłyby Cię do zagnieżdżenia asynchronicznego wywołania zwrotnego. Znacznie łatwiej jest napisać aplikację skalującą z myślą o pojedynczym wątku mikro-usług VS mając na uwadze ogólny obraz za każdym razem, gdy dodajesz coś do bazy kodu.
Vans S
@Vans S Ciekawe.
Jim Anderson

Odpowiedzi:

99

Język funkcjonalny nie polega (na ogół) na mutowaniu zmiennej. Z tego powodu nie musimy chronić „stanu wspólnego” zmiennej, ponieważ wartość jest stała. To z kolei pozwala uniknąć większości przeskoków, przez które muszą przejść tradycyjne języki, aby zaimplementować algorytm między procesorami lub maszynami.

Erlang idzie dalej niż tradycyjne języki funkcjonalne, wypiekając w systemie przekazywania komunikatów, który pozwala wszystkim działać w systemie opartym na zdarzeniach, w którym fragment kodu martwi się tylko o odbieranie wiadomości i wysyłanie wiadomości, nie martwiąc się o większy obraz.

Oznacza to, że programista (nominalnie) nie przejmuje się tym, że wiadomość zostanie obsłużona na innym procesorze lub maszynie: po prostu wysłanie wiadomości jest wystarczające, aby kontynuować. Jeśli zależy mu na odpowiedzi, będzie na nią czekał jako kolejna wiadomość .

Końcowym rezultatem tego jest to, że każdy fragment jest niezależny od każdego innego fragmentu. Brak współdzielonego kodu, brak współdzielonego stanu i wszystkie interakcje pochodzące z systemu wiadomości, który może być dystrybuowany na wiele elementów sprzętu (lub nie).

Porównaj to z tradycyjnym systemem: musimy umieścić muteksy i semafory wokół „chronionych” zmiennych i wykonywania kodu. Mamy ścisłe powiązanie w wywołaniu funkcji przez stos (oczekiwanie na powrót). Wszystko to tworzy wąskie gardła, które są mniejszym problemem we wspólnym systemie niczego, takim jak Erlang.

EDYCJA: Powinienem również zaznaczyć, że Erlang jest asynchroniczny. Wysyłasz wiadomość i być może / kiedyś wróci kolejna wiadomość. Albo nie.

Ważna jest również uwaga Spencera dotycząca wykonania poza kolejnością.

Godeke
źródło
Rozumiem to, ale nie wiem, jak model wiadomości jest skuteczny. Myślę, że jest odwrotnie. To jest dla mnie prawdziwa wiadomość. Nic dziwnego, że funkcjonalne języki programowania cieszą się tak dużym zainteresowaniem.
Jim Anderson
3
Zyskujesz duży potencjał współbieżności w systemie „nic wspólnego”. Zła implementacja (na przykład wysoka transmisja wiadomości nad głową) mogłaby to storpedować, ale Erlang wydaje się robić to dobrze i utrzymywać wszystko jako lekkie.
Godeke
Ważne jest, aby pamiętać, że chociaż Erlang ma semantykę przekazywania komunikatów, ma implementację pamięci współdzielonej, a zatem ma opisaną semantykę, ale nie kopiuje wszystkiego w każdym miejscu, jeśli nie musi.
Aaron Maenpaa
1
@Godeke: „Erlang (podobnie jak większość języków funkcjonalnych) przechowuje pojedyncze wystąpienie dowolnych danych, jeśli to możliwe”. AFAIK, Erlang w rzeczywistości głęboko kopiuje wszystko, co zostało przekazane między jego lekkimi procesami z powodu braku równoczesnego GC.
JD
1
@JonHarrop ma prawie rację: kiedy proces wysyła wiadomość do innego procesu, wiadomość jest kopiowana; z wyjątkiem dużych plików binarnych, które są przekazywane przez odniesienie. Zobacz np. Jlouisramblings.blogspot.hu/2013/10/embrace-copying.html, aby dowiedzieć się, dlaczego jest to dobra rzecz.
hcs42
74

System kolejkowania wiadomości jest fajny, ponieważ skutecznie daje efekt „odpal i poczekaj na wynik”, czyli synchroniczną część, o której czytasz. To, co czyni to niesamowicie niesamowitym, to fakt, że oznacza to, że linie nie muszą być wykonywane sekwencyjnie. Rozważ następujący kod:

r = methodWithALotOfDiskProcessing();
x = r + 1;
y = methodWithALotOfNetworkProcessing();
w = x * y

Rozważ przez chwilę, że wykonanie metody methodWithALotOfDiskProcessing () zajmuje około 2 sekund, a wykonanie metody methodWithALotOfNetworkProcessing () zajmuje około 1 sekundy. W języku proceduralnym wykonanie tego kodu zajęłoby około 3 sekund, ponieważ wiersze byłyby wykonywane sekwencyjnie. Marnujemy czas, czekając na ukończenie jednej metody, która mogłaby działać jednocześnie z drugą bez konkurowania o jeden zasób. W funkcjonalnym języku linie kodu nie narzucają, kiedy procesor będzie je próbował. Język funkcjonalny mógłby spróbować czegoś takiego:

Execute line 1 ... wait.
Execute line 2 ... wait for r value.
Execute line 3 ... wait.
Execute line 4 ... wait for x and y value.
Line 3 returned ... y value set, message line 4.
Line 1 returned ... r value set, message line 2.
Line 2 returned ... x value set, message line 4.
Line 4 returned ... done.

Jakie to jest świetne? Kontynuując kod i czekając tylko tam, gdzie jest to konieczne, automagicznie zredukowaliśmy czas oczekiwania do dwóch sekund! : D Więc tak, chociaż kod jest synchroniczny, ma zwykle inne znaczenie niż w językach proceduralnych.

EDYTOWAĆ:

Gdy zrozumiesz tę koncepcję w połączeniu z postem Godeke, łatwo wyobrazić sobie, jak łatwo jest skorzystać z wielu procesorów, farm serwerów, nadmiarowych magazynów danych i kto wie, co jeszcze.

Spencer Ruport
źródło
Fajne! Całkowicie źle zrozumiałem, jak obsługiwane są wiadomości. Dzięki, twój post pomaga.
Jim Anderson
„Język funkcjonalny mógłby spróbować czegoś takiego jak poniżej” - nie jestem pewien co do innych języków funkcyjnych, ale w Erlangu przykład działałby dokładnie tak, jak w przypadku języków proceduralnych. Państwo może wykonać te dwa zadania równolegle procesy składania ikry, pozwalając im wykonać dwa zadania asynchronicznie, a uzyskanie ich wyników na końcu, ale to nie tak „, natomiast kod jest synchroniczna ma tendencję, aby mieć inne znaczenie niż w językach proceduralnych. " Zobacz także odpowiedź Chrisa.
hcs42
16

Jest prawdopodobne, że mieszasz synchronicznie z sekwencyjnym .

Treść funkcji w erlang jest przetwarzana sekwencyjnie. Więc to, co Spencer powiedział o tym „automagicznym efekcie”, nie odnosi się do erlanga. Możesz jednak modelować to zachowanie za pomocą erlang.

Na przykład możesz stworzyć proces, który oblicza liczbę słów w linii. Ponieważ mamy kilka wierszy, tworzymy jeden taki proces dla każdego wiersza i otrzymujemy odpowiedzi, aby obliczyć z niego sumę.

W ten sposób tworzymy procesy, które wykonują „ciężkie” obliczenia (wykorzystując dodatkowe rdzenie, jeśli są dostępne), a później zbieramy wyniki.

-module(countwords).
-export([count_words_in_lines/1]).

count_words_in_lines(Lines) ->
    % For each line in lines run spawn_summarizer with the process id (pid)
    % and a line to work on as arguments.
    % This is a list comprehension and spawn_summarizer will return the pid
    % of the process that was created. So the variable Pids will hold a list
    % of process ids.
    Pids = [spawn_summarizer(self(), Line) || Line <- Lines], 
    % For each pid receive the answer. This will happen in the same order in
    % which the processes were created, because we saved [pid1, pid2, ...] in
    % the variable Pids and now we consume this list.
    Results = [receive_result(Pid) || Pid <- Pids],
    % Sum up the results.
    WordCount = lists:sum(Results),
    io:format("We've got ~p words, Sir!~n", [WordCount]).

spawn_summarizer(S, Line) ->
    % Create a anonymous function and save it in the variable F.
    F = fun() ->
        % Split line into words.
        ListOfWords = string:tokens(Line, " "),
        Length = length(ListOfWords),
        io:format("process ~p calculated ~p words~n", [self(), Length]),
        % Send a tuple containing our pid and Length to S.
        S ! {self(), Length}
    end,
    % There is no return in erlang, instead the last value in a function is
    % returned implicitly.
    % Spawn the anonymous function and return the pid of the new process.
    spawn(F).

% The Variable Pid gets bound in the function head.
% In erlang, you can only assign to a variable once.
receive_result(Pid) ->
    receive
        % Pattern-matching: the block behind "->" will execute only if we receive
        % a tuple that matches the one below. The variable Pid is already bound,
        % so we are waiting here for the answer of a specific process.
        % N is unbound so we accept any value.
        {Pid, N} ->
            io:format("Received \"~p\" from process ~p~n", [N, Pid]),
            N
    end.

A tak to wygląda, gdy uruchomimy to w powłoce:

Eshell V5.6.5  (abort with ^G)
1> Lines = ["This is a string of text", "and this is another", "and yet another", "it's getting boring now"].
["This is a string of text","and this is another",
 "and yet another","it's getting boring now"]
2> c(countwords).
{ok,countwords}
3> countwords:count_words_in_lines(Lines).
process <0.39.0> calculated 6 words
process <0.40.0> calculated 4 words
process <0.41.0> calculated 3 words
process <0.42.0> calculated 4 words
Received "6" from process <0.39.0>
Received "4" from process <0.40.0>
Received "3" from process <0.41.0>
Received "4" from process <0.42.0>
We've got 17 words, Sir!
ok
4> 
Chris Czura
źródło
13

Kluczową rzeczą, która umożliwia Erlangowi skalowanie, jest współbieżność.

System operacyjny zapewnia współbieżność za pomocą dwóch mechanizmów:

  • procesy systemu operacyjnego
  • wątki systemu operacyjnego

Procesy nie mają wspólnego stanu - jeden proces nie może ulec awarii z powodu innego projektu.

Wątki udostępniają stan - jeden wątek może ulec awarii zgodnie z projektem - to twój problem.

Z Erlangiem - jeden proces systemu operacyjnego jest używany przez maszynę wirtualną, a maszyna wirtualna zapewnia współbieżność programowi Erlang nie za pomocą wątków systemu operacyjnego, ale przez udostępnianie procesów Erlang - to znaczy, że Erlang implementuje własny licznik czasu.

Te procesy Erlang komunikują się ze sobą, wysyłając komunikaty (obsługiwane przez maszynę wirtualną Erlang, a nie system operacyjny). Procesy Erlang zwracają się do siebie za pomocą identyfikatora procesu (PID), który ma trzyczęściowy adres <<N3.N2.N1>>:

  • proces nr N1 włączony
  • VM N2 włączony
  • maszyna fizyczna N3

Dwa procesy na tej samej maszynie wirtualnej, na różnych maszynach wirtualnych na tej samej maszynie lub na dwóch maszynach komunikują się w ten sam sposób - dlatego skalowanie jest niezależne od liczby maszyn fizycznych, na których wdrażasz aplikację (w pierwszym przybliżeniu).

Erlang jest bezpieczny dla wątków tylko w trywialnym sensie - nie ma nici. (Język, to znaczy SMP / wielordzeniowa maszyna wirtualna używa jednego wątku systemu operacyjnego na rdzeń).

Gordon Guthrie
źródło
7

Możesz nie rozumieć, jak działa Erlang. Środowisko wykonawcze Erlang minimalizuje przełączanie kontekstu na procesorze, ale jeśli dostępnych jest wiele procesorów, wszystkie są używane do przetwarzania komunikatów. Nie masz „wątków” w takim sensie, jak w innych językach, ale jednocześnie możesz przetwarzać wiele wiadomości.

Kristopher Johnson
źródło
4

Wiadomości Erlang są czysto asynchroniczne, jeśli chcesz otrzymać synchroniczną odpowiedź na swoją wiadomość, musisz jawnie zakodować. Prawdopodobnie powiedziano, że komunikaty w oknie komunikatu o procesie są przetwarzane sekwencyjnie. Każda wiadomość wysłana do procesu trafia do tego okna komunikatu procesu, a proces może wybrać jedną wiadomość z tego pola, przetworzyć ją, a następnie przejść do następnej, w kolejności, którą uważa za stosowną. Jest to bardzo sekwencyjna czynność i właśnie to robi blok odbioru.

Wygląda na to, że pomyliłeś synchroniczne i sekwencyjne, jak wspomniał Chris.

Jebu
źródło
-2

W języku czysto funkcjonalnym kolejność obliczeń nie ma znaczenia - w aplikacji funkcji fn (arg1, .. argn) n argumentów może być ocenianych równolegle. Gwarantuje to wysoki poziom (automatycznego) równoległości.

Erlang używa modelu procesu, w którym proces może działać na tej samej maszynie wirtualnej lub na innym procesorze - nie ma sposobu, aby to stwierdzić. Jest to możliwe tylko dlatego, że wiadomości są kopiowane między procesami, nie ma stanu współdzielonego (zmiennego). Równoległość wieloprocesorowa sięga znacznie dalej niż wielowątkowość, ponieważ wątki zależą od pamięci współdzielonej, może to być tylko 8 wątków działających równolegle na 8-rdzeniowym procesorze, podczas gdy przetwarzanie wieloprocesowe może być skalowane do tysięcy równoległych procesów.

mfx
źródło