Comment / pourquoi les langages fonctionnels (en particulier Erlang) évoluent-ils bien?

92

J'observe la visibilité croissante des langages de programmation fonctionnels et des fonctionnalités depuis un certain temps. Je les ai examinés et je n'ai pas vu la raison de l'appel.

Puis, récemment, j'ai assisté à la présentation "Basics of Erlang" de Kevin Smith à Codemash .

J'ai apprécié la présentation et j'ai appris que de nombreux attributs de la programmation fonctionnelle permettent d'éviter les problèmes de thread / concurrence. Je comprends que le manque d'état et la mutabilité empêchent plusieurs threads de modifier les mêmes données, mais Kevin a déclaré (si j'ai bien compris) que toutes les communications se font par le biais de messages et que les messages sont traités de manière synchrone (évitant encore une fois les problèmes de concurrence).

Mais j'ai lu qu'Erlang est utilisé dans des applications hautement évolutives (la raison pour laquelle Ericsson l'a créé en premier lieu). Comment gérer efficacement des milliers de requêtes par seconde si tout est traité comme un message traité de manière synchrone? N'est-ce pas la raison pour laquelle nous avons commencé à évoluer vers le traitement asynchrone - afin que nous puissions profiter de l'exécution de plusieurs threads d'opération en même temps et atteindre l'évolutivité? Il semble que cette architecture, bien que plus sûre, soit un pas en arrière en termes d'évolutivité. Qu'est-ce que je rate?

Je comprends que les créateurs d'Erlang ont délibérément évité de prendre en charge le threading pour éviter les problèmes de concurrence, mais je pensais que le multi-threading était nécessaire pour atteindre l'évolutivité.

Comment les langages de programmation fonctionnels peuvent-ils être intrinsèquement thread-safe, tout en restant évolutifs?

Jim Anderson
la source
1
[Non mentionné]: La VM d'Erlangs prend l'asynchronisme à un autre niveau. Par voodoo magic (asm), il permet des opérations de synchronisation comme socket: read to block sans arrêter un thread OS. Cela vous permet d'écrire du code synchrone lorsque d'autres langages vous forceraient à entrer dans des nids de rappel asynchrone. Il est beaucoup plus facile d'écrire une application de mise à l'échelle avec l'image d'esprit de micro-services à thread unique VS en gardant une vue d'ensemble à l'esprit chaque fois que vous insérez quelque chose sur la base de code.
Vans S
@Vans S Intéressant.
Jim Anderson

Réponses:

98

Un langage fonctionnel ne repose pas (en général) sur la mutation d' une variable. Pour cette raison, nous n'avons pas à protéger «l'état partagé» d'une variable, car la valeur est fixe. Cela évite à son tour la majorité des sauts en cerceau que les langages traditionnels doivent traverser pour implémenter un algorithme sur des processeurs ou des machines.

Erlang va plus loin que les langages fonctionnels traditionnels en intégrant un système de passage de messages qui permet à tout de fonctionner sur un système basé sur des événements où un morceau de code ne se soucie que de recevoir des messages et d'envoyer des messages, sans se soucier d'une image plus grande.

Cela signifie que le programmeur ne se soucie (nominalement) pas que le message soit traité sur un autre processeur ou une autre machine: le simple fait d'envoyer le message suffit pour qu'il continue. S'il se soucie d'une réponse, il l'attendra comme un autre message .

Le résultat final est que chaque extrait de code est indépendant de tous les autres extraits. Pas de code partagé, pas d'état partagé et toutes les interactions provenant d'un système de messagerie pouvant être réparti entre de nombreux matériels (ou non).

Comparez cela avec un système traditionnel: nous devons placer des mutex et des sémaphores autour des variables «protégées» et de l'exécution du code. Nous avons une liaison serrée dans un appel de fonction via la pile (en attente du retour). Tout cela crée des goulots d'étranglement qui sont moins problématiques dans un système de partage du rien comme Erlang.

EDIT: Je dois également souligner qu'Erlang est asynchrone. Vous envoyez votre message et peut-être qu'un jour un autre message revient. Ou pas.

Le point de Spencer sur l'exécution dans le désordre est également important et bien répondu.

Godeke
la source
Je comprends cela, mais je ne vois pas en quoi le modèle de message est efficace. J'imagine le contraire. C'est une véritable révélation pour moi. Il n'est pas étonnant que les langages de programmation fonctionnels reçoivent autant d'attention.
Jim Anderson
3
Vous gagnez beaucoup de potentiel de concurrence dans un système de partage rien. Une mauvaise implémentation (une surcharge de passage de message élevée, par exemple) pourrait torpiller cela, mais Erlang semble bien faire les choses et garder tout léger.
Godeke
Il est important de noter que bien qu'Erlang ait une sémantique de passage de message, il a une implémentation de mémoire partagée, ainsi, il a la sémantique décrite mais il ne copie pas à la fois des choses partout si ce n'est pas nécessaire.
Aaron Maenpaa
1
@Godeke: "Erlang (comme la plupart des langages fonctionnels) conserve une seule instance de toutes les données lorsque cela est possible". AFAIK, Erlang copie en profondeur tout ce qui s'est passé entre ses processus légers en raison du manque de GC simultané.
JD
1
@JonHarrop a presque raison: lorsqu'un processus envoie un message à un autre processus, le message est copié; sauf pour les grands binaires, qui sont passés par référence. Voir par exemple jlouisramblings.blogspot.hu/2013/10/embrace-copying.html pour savoir pourquoi c'est une bonne chose.
hcs42
74

Le système de file d'attente de messages est cool car il produit effectivement un effet "feu et attente de résultat" qui est la partie synchrone que vous lisez. Ce qui rend cela incroyablement génial, c'est que cela signifie que les lignes n'ont pas besoin d'être exécutées séquentiellement. Considérez le code suivant:

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

Considérez un instant que methodWithALotOfDiskProcessing () prend environ 2 secondes pour se terminer et que methodWithALotOfNetworkProcessing () prend environ 1 seconde pour se terminer. Dans un langage procédural, ce code prendrait environ 3 secondes à s'exécuter car les lignes seraient exécutées séquentiellement. Nous perdons du temps à attendre la fin d'une méthode qui pourrait s'exécuter simultanément avec l'autre sans rivaliser pour une seule ressource. Dans un langage fonctionnel, les lignes de code ne dictent pas quand le processeur les tentera. Un langage fonctionnel essaierait quelque chose comme ce qui suit:

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.

À quel point cela est cool? En continuant avec le code et en n'attendant que si nécessaire, nous avons réduit le temps d'attente à deux secondes automatiquement! : D Donc oui, bien que le code soit synchrone, il a tendance à avoir une signification différente de celle des langages procéduraux.

ÉDITER:

Une fois que vous avez compris ce concept en conjonction avec l'article de Godeke, il est facile d'imaginer à quel point il devient simple de tirer parti de plusieurs processeurs, de fermes de serveurs, de magasins de données redondants et qui sait quoi d'autre.

Spencer Ruport
la source
Cool! J'ai totalement mal compris comment les messages étaient traités. Merci, votre message aide.
Jim Anderson
"Un langage fonctionnel essaierait quelque chose comme ce qui suit" - Je ne suis pas sûr des autres langages fonctionnels, mais dans Erlang, l'exemple fonctionnerait exactement comme dans le cas des langages procéduraux. Vous pouvez effectuer ces deux tâches en parallèle en engendrant des processus, en les laissant exécuter les deux tâches de manière asynchrone et en obtenant leurs résultats à la fin, mais ce n'est pas comme si le code est synchrone, il a tendance à avoir une signification différente de celle des langages procéduraux. " Voir aussi la réponse de Chris.
hcs42
16

Il est probable que vous mélangiez synchrone avec séquentiel .

Le corps d'une fonction dans erlang est traité séquentiellement. Donc ce que Spencer a dit à propos de cet "effet automagique" ne vaut pas pour erlang. Vous pouvez cependant modéliser ce comportement avec erlang.

Par exemple, vous pouvez générer un processus qui calcule le nombre de mots dans une ligne. Comme nous avons plusieurs lignes, nous engendrons un tel processus pour chaque ligne et recevons les réponses pour en calculer une somme.

De cette façon, nous engendrons des processus qui effectuent les calculs «lourds» (en utilisant des cœurs supplémentaires si disponibles) et plus tard nous collectons les résultats.

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

Et voici à quoi cela ressemble, lorsque nous exécutons ceci dans le shell:

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
la source
13

L'élément clé qui permet à Erlang d'évoluer est lié à la concurrence.

Un système d'exploitation fournit la concurrence par deux mécanismes:

  • processus du système d'exploitation
  • threads du système d'exploitation

Les processus ne partagent pas l'état - un processus ne peut pas planter un autre par conception.

L'état de partage des threads - un thread peut en planter un autre par conception - c'est votre problème.

Avec Erlang - un processus du système d'exploitation est utilisé par la machine virtuelle et la VM fournit la concurrence au programme Erlang non pas en utilisant les threads du système d'exploitation mais en fournissant des processus Erlang - c'est qu'Erlang implémente son propre timelicer.

Ces processus Erlang se parlent en envoyant des messages (gérés par la VM Erlang et non par le système d'exploitation). Les processus Erlang s'adressent les uns aux autres à l'aide d'un ID de processus (PID) qui a une adresse en trois parties <<N3.N2.N1>>:

  • processus no N1 sur
  • VM N2 sur
  • machine physique N3

Deux processus sur la même VM, sur des VM différentes sur la même machine ou deux machines communiquent de la même manière - votre mise à l'échelle est donc indépendante du nombre de machines physiques sur lesquelles vous déployez votre application (en première approximation).

Erlang n'est threadsafe que dans un sens trivial - il n'a pas de threads. (Le langage dans lequel la machine virtuelle SMP / multicœur utilise un thread de système d'exploitation par cœur).

Gordon Guthrie
la source
7

Vous avez peut-être un malentendu sur le fonctionnement d'Erlang. Le runtime Erlang minimise le changement de contexte sur un CPU, mais s'il y a plusieurs CPU disponibles, alors tous sont utilisés pour traiter les messages. Vous n'avez pas de "threads" dans le sens où vous le faites dans d'autres langues, mais vous pouvez avoir un grand nombre de messages traités simultanément.

Kristopher Johnson
la source
4

Les messages Erlang sont purement asynchrones, si vous voulez une réponse synchrone à votre message, vous devez coder explicitement pour cela. Ce qui a peut-être été dit, c'est que les messages dans une boîte de message de processus sont traités séquentiellement. Tout message envoyé à un processus se trouve dans cette boîte de message de processus, et le processus peut choisir un message dans cette boîte, le traiter, puis passer au suivant, dans l'ordre qu'il juge approprié. C'est un acte très séquentiel et le bloc de réception fait exactement cela.

On dirait que vous avez mélangé synchrone et séquentiel, comme Chris l'a mentionné.

Jebu
la source
-2

Dans un langage purement fonctionnel, l'ordre d'évaluation n'a pas d'importance - dans une application de fonction fn (arg1, .. argn), les n arguments peuvent être évalués en parallèle. Cela garantit un haut niveau de parallélisme (automatique).

Erlang utilise un modèle de processus dans lequel un processus peut s'exécuter dans la même machine virtuelle ou sur un processeur différent - il n'y a aucun moyen de le dire. Cela n'est possible que parce que les messages sont copiés entre les processus, il n'y a pas d'état partagé (mutable). Le paralellisme multiprocesseur va beaucoup plus loin que le multi-threading, puisque les threads dépendent de la mémoire partagée, il ne peut y avoir que 8 threads fonctionnant en parallèle sur un processeur à 8 cœurs, tandis que le multi-traitement peut évoluer vers des milliers de processus parallèles.

mfx
la source