Wie / warum skalieren funktionale Sprachen (speziell Erlang) gut?


92

Ich habe eine Weile die wachsende Sichtbarkeit funktionaler Programmiersprachen und -funktionen beobachtet. Ich habe sie untersucht und den Grund für die Berufung nicht gesehen.

Dann habe ich kürzlich Kevin Smiths "Basics of Erlang" -Präsentation bei Codemash besucht .

Ich habe die Präsentation genossen und festgestellt, dass viele Attribute der funktionalen Programmierung es viel einfacher machen, Threading- / Parallelitätsprobleme zu vermeiden. Ich verstehe, dass der Mangel an Status und Veränderlichkeit es mehreren Threads unmöglich macht, dieselben Daten zu ändern, aber Kevin sagte (wenn ich es richtig verstanden habe), dass die gesamte Kommunikation über Nachrichten erfolgt und die Nachrichten synchron verarbeitet werden (wiederum um Parallelitätsprobleme zu vermeiden).

Aber ich habe gelesen, dass Erlang in hoch skalierbaren Anwendungen verwendet wird (der ganze Grund, warum Ericsson es überhaupt erstellt hat). Wie kann es effizient sein, Tausende von Anfragen pro Sekunde zu bearbeiten, wenn alles als synchron verarbeitete Nachricht behandelt wird? Ist das nicht der Grund, warum wir uns der asynchronen Verarbeitung zugewandt haben, damit wir mehrere Betriebsthreads gleichzeitig ausführen und Skalierbarkeit erreichen können? Diese Architektur scheint zwar sicherer zu sein, ist jedoch in Bezug auf die Skalierbarkeit ein Rückschritt. Was vermisse ich?

Ich verstehe, dass die Entwickler von Erlang absichtlich die Unterstützung von Threading vermieden haben, um Parallelitätsprobleme zu vermeiden, aber ich dachte, dass Multithreading notwendig ist, um Skalierbarkeit zu erreichen.

Wie können funktionale Programmiersprachen von Natur aus threadsicher und dennoch skalierbar sein?


1
[Nicht erwähnt]: Erlangs 'VM bringt die Asynchronität auf eine andere Ebene. Mit Voodoo Magic (ASM) können Synchronisierungsvorgänge wie Socket: Read zum Blockieren ausgeführt werden, ohne einen Betriebssystem-Thread anzuhalten. Auf diese Weise können Sie synchronen Code schreiben, wenn andere Sprachen Sie zu asynchronen Rückrufnestern zwingen würden. Es ist viel einfacher, eine Skalierungs-App mit dem Mind-Image von Single-Threaded-Micro-Services VS zu schreiben, wobei Sie jedes Mal, wenn Sie etwas auf die Codebasis setzen, das Gesamtbild im Auge behalten.
Vans S

@Vans S Interessant.
Jim Anderson

Antworten:


97

Eine funktionale Sprache beruht (im Allgemeinen) nicht auf der Mutation einer Variablen. Aus diesem Grund müssen wir den "gemeinsamen Status" einer Variablen nicht schützen, da der Wert fest ist. Dies vermeidet wiederum den Großteil des Reifenspringens, den traditionelle Sprachen durchlaufen müssen, um einen Algorithmus prozessor- oder maschinenübergreifend zu implementieren.

Erlang geht noch weiter als herkömmliche funktionale Sprachen, indem es ein Nachrichtenübermittlungssystem einbaut, mit dem alles auf einem ereignisbasierten System ausgeführt werden kann, bei dem sich ein Code nur um das Empfangen und Senden von Nachrichten kümmert und sich nicht um ein größeres Bild kümmert.

Dies bedeutet, dass der Programmierer (nominell) nicht besorgt darüber ist, dass die Nachricht auf einem anderen Prozessor oder Computer verarbeitet wird: Das einfache Senden der Nachricht reicht aus, um fortzufahren. Wenn es um eine Antwort geht, wartet es als weitere Nachricht darauf .

Das Endergebnis davon ist, dass jedes Snippet unabhängig von jedem anderen Snippet ist. Kein gemeinsam genutzter Code, kein gemeinsam genutzter Status und alle Interaktionen, die von einem Nachrichtensystem stammen, das auf viele Hardwareteile verteilt werden kann (oder nicht).

Vergleichen Sie dies mit einem herkömmlichen System: Wir müssen Mutexe und Semaphoren um "geschützte" Variablen und die Codeausführung platzieren. Wir haben eine enge Bindung in einem Funktionsaufruf über den Stapel (warten auf die Rückkehr). All dies führt zu Engpässen, die in einem Shared-Nothing-System wie Erlang weniger problematisch sind.

EDIT: Ich sollte auch darauf hinweisen, dass Erlang asynchron ist. Sie senden Ihre Nachricht und vielleicht / eines Tages kommt eine andere Nachricht zurück. Oder nicht.

Spencers Argument bezüglich der Ausführung außerhalb der Reihenfolge ist ebenfalls wichtig und gut beantwortet.


Ich verstehe das, sehe aber nicht, wie effizient das Nachrichtenmodell ist. Ich würde das Gegenteil erraten. Dies ist ein echter Augenöffner für mich. Kein Wunder, dass funktionale Programmiersprachen so viel Aufmerksamkeit erhalten.
Jim Anderson

3
In einem Shared-Nothing-System gewinnen Sie viel Potenzial für Parallelität . Eine schlechte Implementierung (z. B. hoher Nachrichtenaufwand) könnte dies torpedieren, aber Erlang scheint es richtig zu machen und alles leicht zu halten.
Godeke

Es ist wichtig zu beachten, dass Erlang zwar über eine Semantik für die Nachrichtenübermittlung verfügt, jedoch über eine Shared-Memory-Implementierung verfügt. Daher hat es die beschriebene Semantik, kopiert jedoch nicht beide Inhalte überall, wenn dies nicht erforderlich ist.
Aaron Maenpaa

1
@Godeke: "Erlang (wie die meisten funktionalen Sprachen) speichert nach Möglichkeit eine einzelne Instanz aller Daten". AFAIK, Erlang kopiert tatsächlich alles tief, was zwischen seinen leichten Prozessen aufgrund des Fehlens einer gleichzeitigen GC passiert ist.
JD

1
@ JonHarrop ist fast richtig: Wenn ein Prozess eine Nachricht an einen anderen Prozess sendet, wird die Nachricht kopiert. mit Ausnahme großer Binärdateien, die als Referenz übergeben werden. Siehe z. B. jlouisramblings.blogspot.hu/2013/10/embrace-copying.html, warum dies eine gute Sache ist.
hcs42

73

Das Nachrichtenwarteschlangensystem ist cool, weil es effektiv einen "Feuer-und-Warten-auf-Ergebnis" -Effekt erzeugt, der der synchrone Teil ist, über den Sie lesen. Was dies unglaublich beeindruckend macht, ist, dass es bedeutet, dass Zeilen nicht nacheinander ausgeführt werden müssen. Betrachten Sie den folgenden Code:

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

Stellen Sie sich für einen Moment vor, dass die Fertigstellung von methodWithALotOfDiskProcessing () ungefähr 2 Sekunden und die Fertigstellung von methodWithALotOfNetworkProcessing () ungefähr 1 Sekunde dauert. In einer prozeduralen Sprache würde die Ausführung dieses Codes etwa 3 Sekunden dauern, da die Zeilen nacheinander ausgeführt würden. Wir verschwenden Zeit damit, darauf zu warten, dass eine Methode abgeschlossen wird, die gleichzeitig mit der anderen ausgeführt werden kann, ohne um eine einzelne Ressource zu konkurrieren. In einer funktionalen Sprache geben Codezeilen nicht vor, wann der Prozessor sie versuchen wird. Eine funktionale Sprache würde Folgendes versuchen:

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.

Wie cool ist das? Indem wir mit dem Code fortfahren und nur bei Bedarf warten, haben wir die Wartezeit automatisch auf zwei Sekunden reduziert! : D Ja, obwohl der Code synchron ist, hat er tendenziell eine andere Bedeutung als in prozeduralen Sprachen.

BEARBEITEN:

Wenn Sie dieses Konzept in Verbindung mit Godekes Beitrag verstanden haben, können Sie sich leicht vorstellen, wie einfach es wird, mehrere Prozessoren, Serverfarmen, redundante Datenspeicher zu nutzen und wer weiß was noch.


Cool! Ich habe völlig falsch verstanden, wie Nachrichten behandelt wurden. Danke, dein Beitrag hilft.
Jim Anderson

"Eine funktionale Sprache würde so etwas wie das Folgende versuchen" - Ich bin mir bei anderen funktionalen Sprachen nicht sicher, aber in Erlang würde das Beispiel genauso funktionieren wie bei prozeduralen Sprachen. Sie können diese beiden Aufgaben parallel ausführen, indem Sie Prozesse erzeugen , die beiden Aufgaben asynchron ausführen und am Ende ihre Ergebnisse erhalten. Es ist jedoch nicht so, dass der Code zwar synchron ist, aber tendenziell eine andere Bedeutung hat als in prozeduralen Sprachen. "" Siehe auch Chris 'Antwort.
hcs42

16

Es ist wahrscheinlich, dass Sie synchron mit sequentiell verwechseln .

Der Körper einer Funktion in erlang wird sequentiell verarbeitet. Was Spencer über diesen "automagischen Effekt" sagte, gilt also nicht für erlang. Sie können dieses Verhalten jedoch mit erlang modellieren.

Sie können beispielsweise einen Prozess erzeugen, der die Anzahl der Wörter in einer Zeile berechnet. Da wir mehrere Zeilen haben, erzeugen wir für jede Zeile einen solchen Prozess und erhalten die Antworten, um daraus eine Summe zu berechnen.

Auf diese Weise erzeugen wir Prozesse, die die "schweren" Berechnungen durchführen (wobei zusätzliche Kerne verwendet werden, falls verfügbar), und sammeln später die Ergebnisse.

-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.

Und so sieht es aus, wenn wir dies in der Shell ausführen:

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> 

13

Der Schlüssel zur Skalierung von Erlang hängt mit der Parallelität zusammen.

Ein Betriebssystem bietet Parallelität durch zwei Mechanismen:

  • Betriebssystemprozesse
  • Betriebssystem-Threads

Prozesse teilen sich nicht den Status - ein Prozess kann einen anderen nicht beabsichtigt zum Absturz bringen.

Threads teilen sich den Status - ein Thread kann einen anderen von Natur aus zum Absturz bringen - das ist Ihr Problem.

Mit Erlang - ein Betriebssystemprozess wird von der virtuellen Maschine verwendet und die VM stellt dem Erlang-Programm Parallelität zur Verfügung, nicht durch Verwendung von Betriebssystem-Threads, sondern durch Bereitstellung von Erlang-Prozessen - das heißt, Erlang implementiert seinen eigenen Zeitschneider.

Diese Erlang-Prozesse kommunizieren miteinander, indem sie Nachrichten senden (die von der Erlang-VM und nicht vom Betriebssystem verarbeitet werden). Die Erlang-Prozesse adressieren sich gegenseitig mit einer Prozess-ID (PID), die eine dreiteilige Adresse hat <<N3.N2.N1>>:

  • Prozess Nr. N1 ein
  • VM N2 ein
  • physische Maschine N3

Zwei Prozesse auf derselben VM, auf verschiedenen VMs auf demselben Computer oder auf zwei Computern kommunizieren auf dieselbe Weise. Ihre Skalierung ist daher unabhängig von der Anzahl der physischen Computer, auf denen Sie Ihre Anwendung bereitstellen (in erster Näherung).

Erlang ist nur im trivialen Sinne threadsicher - es gibt keine Threads. (Die Sprache, dh die SMP / Multi-Core-VM, verwendet einen Betriebssystem-Thread pro Core.)


7

Möglicherweise haben Sie ein Missverständnis darüber, wie Erlang funktioniert. Die Erlang-Laufzeit minimiert das Kontextwechsel auf einer CPU. Wenn jedoch mehrere CPUs verfügbar sind, werden alle zur Verarbeitung von Nachrichten verwendet. Sie haben keine "Threads" in dem Sinne, wie Sie es in anderen Sprachen tun, aber Sie können viele Nachrichten gleichzeitig verarbeiten lassen.


4

Erlang-Nachrichten sind rein asynchron. Wenn Sie eine synchrone Antwort auf Ihre Nachricht wünschen, müssen Sie dies explizit codieren. Möglicherweise wurde gesagt, dass Nachrichten in einem Prozessnachrichtenfeld nacheinander verarbeitet werden. Jede an einen Prozess gesendete Nachricht befindet sich in diesem Prozessnachrichtenfeld, und der Prozess kann eine Nachricht aus diesem Feld auswählen und dann in der Reihenfolge, in der er dies für richtig hält, zur nächsten übergehen. Dies ist eine sehr sequentielle Handlung, und der Empfangsblock macht genau das.

Sieht so aus, als hätten Sie synchron und sequentiell verwechselt, wie Chris erwähnt hat.



-2

In einer rein funktionalen Sprache spielt die Reihenfolge der Auswertung keine Rolle - in einer Funktionsanwendung fn (arg1, .. argn) können die n Argumente parallel ausgewertet werden. Das garantiert ein hohes Maß an (automatischer) Parallelität.

Erlang verwendet ein Prozessmodell, bei dem ein Prozess in derselben virtuellen Maschine oder auf einem anderen Prozessor ausgeführt werden kann - es gibt keine Möglichkeit, dies festzustellen. Dies ist nur möglich, weil Nachrichten zwischen Prozessen kopiert werden und es keinen gemeinsamen (veränderlichen) Status gibt. Multiprozessor-Paralellismus geht viel weiter als Multithreading, da Threads vom gemeinsam genutzten Speicher abhängen. Auf einer 8-Kern-CPU können nur 8 Threads parallel ausgeführt werden, während Multi-Prozessor auf Tausende paralleler Prozesse skaliert werden kann.

Durch die Nutzung unserer Website bestätigen Sie, dass Sie unsere Cookie-Richtlinie und Datenschutzrichtlinie gelesen und verstanden haben.
Licensed under cc by-sa 3.0 with attribution required.