¿Cómo / por qué los lenguajes funcionales (específicamente Erlang) escalan bien?

92

He estado observando la creciente visibilidad de los lenguajes de programación funcionales y las características durante un tiempo. Los miré y no vi el motivo de la apelación.

Luego, recientemente asistí a la presentación "Basics of Erlang" de Kevin Smith en Codemash .

Disfruté de la presentación y aprendí que muchos de los atributos de la programación funcional hacen que sea mucho más fácil evitar problemas de subprocesos / concurrencia. Entiendo que la falta de estado y mutabilidad hace que sea imposible que varios subprocesos alteren los mismos datos, pero Kevin dijo (si entendí correctamente) que toda la comunicación se realiza a través de mensajes y los mensajes se procesan sincrónicamente (evitando nuevamente problemas de concurrencia).

Pero he leído que Erlang se usa en aplicaciones altamente escalables (la razón por la que Ericsson lo creó en primer lugar). ¿Cómo puede ser eficiente manejar miles de solicitudes por segundo si todo se maneja como un mensaje procesado sincrónicamente? ¿No es por eso que comenzamos a avanzar hacia el procesamiento asincrónico, para que podamos aprovechar la ejecución de múltiples subprocesos de operación al mismo tiempo y lograr escalabilidad? Parece que esta arquitectura, aunque más segura, es un paso atrás en términos de escalabilidad. ¿Qué me estoy perdiendo?

Entiendo que los creadores de Erlang evitaron intencionalmente el soporte de subprocesos para evitar problemas de concurrencia, pero pensé que el subproceso múltiple era necesario para lograr la escalabilidad.

¿Cómo pueden los lenguajes de programación funcionales ser intrínsecamente seguros para subprocesos, pero aún así escalar?

Jim Anderson
fuente
1
[No mencionado]: La máquina virtual de Erlangs lleva la asincronía a otro nivel. Mediante magia vudú (asm) permite operaciones de sincronización como socket: leer para bloquear sin detener un hilo del sistema operativo. Esto le permite escribir código sincrónico cuando otros lenguajes le obligarían a entrar en nidos de devolución de llamada asíncrona. Es mucho más fácil escribir una aplicación de escalado con la imagen mental de microservicios de un solo subproceso que tener en cuenta el panorama general cada vez que agrega algo al código base.
Vans S
@Vans S Interesante.
Jim Anderson

Respuestas:

99

Un lenguaje funcional no se basa (en general) en la mutación de una variable. Debido a esto, no tenemos que proteger el "estado compartido" de una variable, porque el valor es fijo. Esto, a su vez, evita la mayoría de los saltos por los que tienen que pasar los lenguajes tradicionales para implementar un algoritmo en procesadores o máquinas.

Erlang lo lleva más allá de los lenguajes funcionales tradicionales al crear un sistema de paso de mensajes que permite que todo funcione en un sistema basado en eventos donde un fragmento de código solo se preocupa por recibir mensajes y enviar mensajes, sin preocuparse por una imagen más grande.

Lo que esto significa es que al programador (nominalmente) no le preocupa que el mensaje sea manejado en otro procesador o máquina: simplemente enviar el mensaje es lo suficientemente bueno para que continúe. Si le importa una respuesta, la esperará como otro mensaje .

El resultado final de esto es que cada fragmento es independiente de todos los demás fragmentos. Sin código compartido, sin estado compartido y todas las interacciones provenientes de un sistema de mensajes que se puede distribuir entre muchas piezas de hardware (o no).

Compare esto con un sistema tradicional: tenemos que colocar mutex y semáforos alrededor de las variables "protegidas" y la ejecución del código. Tenemos un enlace estricto en una llamada de función a través de la pila (esperando que ocurra el retorno). Todo esto crea cuellos de botella que son un problema menor en un sistema de nada compartido como Erlang.

EDITAR: También debo señalar que Erlang es asincrónico. Envías tu mensaje y tal vez / algún día llegue otro mensaje. O no.

El punto de Spencer sobre la ejecución fuera de orden también es importante y está bien respondido.

Godeke
fuente
Entiendo esto, pero no veo cómo el modelo de mensaje es eficiente. Yo diría lo contrario. Esto es una verdadera revelación para mí. No es de extrañar que los lenguajes de programación funcionales estén recibiendo tanta atención.
Jim Anderson
3
Obtiene un gran potencial de concurrencia en un sistema de nada compartido. Una mala implementación (alto mensaje que pasa por encima de la cabeza, por ejemplo) podría torpedear esto, pero Erlang parece hacerlo bien y mantener todo liviano.
Godeke
Es importante tener en cuenta que si bien Erlang tiene semántica de paso de mensajes, tiene una implementación de memoria compartida, por lo tanto, tiene la semántica descrita, pero no copia cosas por todas partes si no es necesario.
Aaron Maenpaa
1
@Godeke: "Erlang (como la mayoría de los lenguajes funcionales) mantiene una sola instancia de cualquier dato cuando es posible". AFAIK, Erlang en realidad copia en profundidad todo lo que pasó entre sus procesos ligeros debido a la falta de GC concurrente.
JD
1
@JonHarrop tiene casi razón: cuando un proceso envía un mensaje a otro proceso, el mensaje se copia; excepto los binarios grandes, que se pasan por referencia. Consulte, por ejemplo, jlouisramblings.blogspot.hu/2013/10/embrace-copying.html para saber por qué esto es algo bueno.
hcs42
74

El sistema de cola de mensajes es genial porque produce efectivamente un efecto de "disparar y esperar el resultado", que es la parte sincrónica sobre la que está leyendo. Lo que hace que esto sea increíblemente asombroso es que significa que las líneas no necesitan ejecutarse secuencialmente. Considere el siguiente código:

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

Considere por un momento que methodWithALotOfDiskProcessing () tarda aproximadamente 2 segundos en completarse y que methodWithALotOfNetworkProcessing () tarda aproximadamente 1 segundo en completarse. En un lenguaje de procedimiento, este código tardaría unos 3 segundos en ejecutarse porque las líneas se ejecutarían secuencialmente. Estamos perdiendo el tiempo esperando a que se complete un método que pueda ejecutarse al mismo tiempo que el otro sin competir por un solo recurso. En un lenguaje funcional, las líneas de código no dictan cuándo las intentará el procesador. Un lenguaje funcional probaría algo como lo siguiente:

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.

¿Cuan genial es eso? Al seguir adelante con el código y esperar solo cuando sea necesario, ¡hemos reducido el tiempo de espera a dos segundos automáticamente! : D Así que sí, aunque el código es síncrono, tiende a tener un significado diferente al de los lenguajes de procedimiento.

EDITAR:

Una vez que comprende este concepto junto con la publicación de Godeke, es fácil imaginar lo simple que se vuelve aprovechar múltiples procesadores, granjas de servidores, almacenes de datos redundantes y quién sabe qué más.

Spencer Ruport
fuente
¡Frio! Entendí totalmente mal cómo se manejaban los mensajes. Gracias, tu publicación ayuda.
Jim Anderson
"Un lenguaje funcional probaría algo como lo siguiente" - No estoy seguro de otros lenguajes funcionales, pero en Erlang el ejemplo funcionaría exactamente como en el caso de los lenguajes de procedimiento. Usted puede realizar esas dos tareas en paralelo por los procesos de desove, haciéndoles ejecutar las dos tareas de forma asíncrona, y la obtención de sus resultados al final, pero no es igual ", mientras que el código es sincrónico que tiende a tener un significado diferente que en las lenguas de procedimiento. " Vea también la respuesta de Chris.
hcs42
16

Es probable que esté mezclando sincrónico con secuencial .

El cuerpo de una función en erlang se procesa secuencialmente. Entonces, lo que dijo Spencer sobre este "efecto automágico" no es válido para erlang. Sin embargo, puedes modelar este comportamiento con erlang.

Por ejemplo, podría generar un proceso que calcule el número de palabras en una línea. Como tenemos varias líneas, generamos uno de esos procesos para cada línea y recibimos las respuestas para calcular una suma a partir de él.

De esa manera, generamos procesos que hacen los cálculos "pesados" (utilizando núcleos adicionales si están disponibles) y luego recopilamos los resultados.

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

Y así es como se ve, cuando ejecutamos esto en el 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
fuente
13

La clave que permite escalar a Erlang está relacionada con la concurrencia.

Un sistema operativo proporciona simultaneidad mediante dos mecanismos:

  • procesos del sistema operativo
  • subprocesos del sistema operativo

Los procesos no comparten el estado: un proceso no puede bloquear otro por diseño.

Los subprocesos comparten el estado: un subproceso puede bloquear otro por diseño, ese es su problema.

Con Erlang, la máquina virtual utiliza un proceso del sistema operativo y la VM proporciona simultaneidad al programa Erlang no mediante el uso de subprocesos del sistema operativo, sino al proporcionar procesos Erlang, es decir, Erlang implementa su propio selector de tiempo.

Estos procesos de Erlang se comunican entre sí mediante el envío de mensajes (manejados por Erlang VM, no por el sistema operativo). Los procesos de Erlang se dirigen entre sí mediante un ID de proceso (PID) que tiene una dirección de tres partes <<N3.N2.N1>>:

  • proceso no N1 en
  • VM N2 en
  • máquina física N3

Dos procesos en la misma VM, en diferentes VM en la misma máquina o dos máquinas se comunican de la misma manera; por lo tanto, su escalado es independiente de la cantidad de máquinas físicas en las que implementa su aplicación (en la primera aproximación).

Erlang solo es seguro para subprocesos en un sentido trivial: no tiene subprocesos. (El lenguaje, es decir, la máquina virtual SMP / multi-core usa un subproceso del sistema operativo por núcleo).

Gordon Guthrie
fuente
7

Es posible que no haya entendido bien cómo funciona Erlang. El tiempo de ejecución de Erlang minimiza el cambio de contexto en una CPU, pero si hay varias CPU disponibles, todas se utilizan para procesar mensajes. No tiene "hilos" en el sentido en que los tiene en otros idiomas, pero puede tener muchos mensajes que se procesan al mismo tiempo.

Kristopher Johnson
fuente
4

Los mensajes de Erlang son puramente asincrónicos, si desea una respuesta sincrónica a su mensaje, debe codificarlo explícitamente. Lo que posiblemente se dijo fue que los mensajes en un cuadro de mensaje de proceso se procesan secuencialmente. Cualquier mensaje enviado a un proceso se coloca en ese cuadro de mensaje de proceso, y el proceso puede elegir un mensaje de ese cuadro, procesarlo y luego pasar al siguiente, en el orden que crea conveniente. Este es un acto muy secuencial y el bloque de recepción hace exactamente eso.

Parece que has mezclado sincrónico y secuencial como mencionó Chris.

Jebu
fuente
-2

En un lenguaje puramente funcional, el orden de evaluación no importa: en una aplicación de función fn (arg1, .. argn), los n argumentos se pueden evaluar en paralelo. Eso garantiza un alto nivel de paralelismo (automático).

Erlang usa un modelo de proceso en el que un proceso puede ejecutarse en la misma máquina virtual o en un procesador diferente; no hay forma de saberlo. Eso solo es posible porque los mensajes se copian entre procesos, no hay un estado compartido (mutable). El paralelismo multiprocesador va mucho más allá que el multiproceso, ya que los subprocesos dependen de la memoria compartida, esto solo puede haber 8 subprocesos ejecutándose en paralelo en una CPU de 8 núcleos, mientras que el multiprocesamiento puede escalar a miles de procesos paralelos.

mfx
fuente