Como / por que as linguagens funcionais (especificamente Erlang) escalam bem?

92

Tenho observado a crescente visibilidade das linguagens e recursos de programação funcional por um tempo. Eu olhei para eles e não vi o motivo do apelo.

Então, recentemente, participei da apresentação "Basics of Erlang" de Kevin Smith na Codemash .

Gostei da apresentação e aprendi que muitos dos atributos da programação funcional tornam muito mais fácil evitar problemas de threading / simultaneidade. Eu entendo que a falta de estado e mutabilidade torna impossível para vários threads alterar os mesmos dados, mas Kevin disse (se eu entendi corretamente) toda a comunicação ocorre por meio de mensagens e as mensagens são processadas de forma síncrona (novamente evitando problemas de simultaneidade).

Mas eu li que Erlang é usado em aplicativos altamente escaláveis ​​(a razão pela qual a Ericsson o criou em primeiro lugar). Como pode ser eficiente lidar com milhares de solicitações por segundo se tudo é tratado como uma mensagem processada de forma síncrona? Não é por isso que começamos a nos mover para o processamento assíncrono - para que possamos aproveitar a execução de vários threads de operação ao mesmo tempo e obter escalabilidade? Parece que essa arquitetura, embora mais segura, é um retrocesso em termos de escalabilidade. o que estou perdendo?

Eu entendo que os criadores do Erlang intencionalmente evitaram o suporte a threading para evitar problemas de simultaneidade, mas pensei que o multi-threading era necessário para alcançar escalabilidade.

Como as linguagens de programação funcional podem ser inerentemente thread-safe, mas ainda assim escalar?

Jim Anderson
fonte
1
[Não mencionado]: A VM de Erlangs leva a assíncrona a outro nível. Por magia voodoo (asm), ele permite operações de sincronização como socket: read to block sem interromper uma thread do sistema operacional. Isso permite que você escreva código síncrono quando outras linguagens forçarem você a fazer ninhos de retorno de chamada assíncrona. É muito mais fácil escrever um aplicativo de dimensionamento com a imagem mental de micro-serviços de thread único VS mantendo a visão geral em mente toda vez que você adicionar algo à base de código.
Vans S de
@Vans S Interessante.
Jim Anderson de

Respostas:

97

Uma linguagem funcional não depende (em geral) da mutação de uma variável. Por isso, não precisamos proteger o "estado compartilhado" de uma variável, pois o valor é fixo. Isso, por sua vez, evita a maior parte dos obstáculos que as linguagens tradicionais precisam enfrentar para implementar um algoritmo em processadores ou máquinas.

Erlang vai além das linguagens funcionais tradicionais ao assentar em um sistema de passagem de mensagens que permite que tudo opere em um sistema baseado em eventos, onde um pedaço de código só se preocupa em receber mensagens e enviar mensagens, não se preocupando com uma imagem maior.

O que isso significa é que o programador não está (nominalmente) preocupado se a mensagem será tratada em outro processador ou máquina: simplesmente enviar a mensagem é bom o suficiente para que ela continue. Se ele se preocupa com uma resposta, irá esperar por ela como outra mensagem .

O resultado final disso é que cada snippet é independente de todos os outros snippet. Nenhum código compartilhado, nenhum estado compartilhado e todas as interações provenientes de um sistema de mensagens que podem ser distribuídas entre várias peças de hardware (ou não).

Compare isso com um sistema tradicional: temos que colocar mutexes e semáforos em torno de variáveis ​​"protegidas" e execução de código. Temos uma ligação forte em uma chamada de função por meio da pilha (esperando o retorno ocorrer). Tudo isso cria gargalos que são menos problemáticos em um sistema de nada compartilhado como o Erlang.

EDIT: Devo também salientar que Erlang é assíncrono. Você envia sua mensagem e talvez / algum dia outra mensagem chegue de volta. Ou não.

O argumento de Spencer sobre a execução fora de ordem também é importante e bem respondido.

Godeke
fonte
Eu entendo isso, mas não vejo como o modelo de mensagem é eficiente. Eu acho que é o oposto. Este é um verdadeiro abrir de olhos para mim. Não é à toa que as linguagens de programação funcionais estão recebendo tanta atenção.
Jim Anderson
3
Você ganha muito potencial de simultaneidade em um sistema sem compartilhamento. Uma implementação ruim (sobrecarga de passagem de mensagem alta, por exemplo) poderia torpedear isso, mas Erlang parece acertar e manter tudo leve.
Godeke
É importante notar que embora Erlang tenha uma semântica de passagem de mensagem, ele tem uma implementação de memória compartilhada, portanto, ele tem a semântica descrita, mas não copia as coisas para todos os lugares se não for necessário.
Aaron Maenpaa
1
@Godeke: "Erlang (como a maioria das linguagens funcionais) mantém uma única instância de todos os dados quando possível". AFAIK, Erlang realmente copia profundamente tudo o que se passa entre seus processos leves devido à falta de GC concorrente.
JD de
1
@JonHarrop está quase certo: quando um processo envia uma mensagem para outro processo, a mensagem é copiada; exceto para binários grandes, que são passados ​​por referência. Consulte, por exemplo, jlouisramblings.blogspot.hu/2013/10/embrace-copying.html para saber por que isso é bom.
hcs42
73

O sistema de fila de mensagens é legal porque produz efetivamente um efeito "dispare e espere pelo resultado", que é a parte síncrona sobre a qual você está lendo. O que torna isso incrivelmente incrível é que significa que as linhas não precisam ser executadas sequencialmente. Considere o seguinte código:

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

Considere por um momento que o métodoWithALotOfDiskProcessing () leva cerca de 2 segundos para ser concluído e que o métodoWithALotOfNetworkProcessing () leva cerca de 1 segundo para ser concluído. Em uma linguagem procedural, esse código levaria cerca de 3 segundos para ser executado porque as linhas seriam executadas sequencialmente. Estamos perdendo tempo esperando a conclusão de um método que poderia ser executado simultaneamente com o outro sem competir por um único recurso. Em uma linguagem funcional, as linhas de código não determinam quando o processador as tentará. Uma linguagem funcional tentaria algo como o seguinte:

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.

Quão legal é isso? Prosseguindo com o código e apenas esperando quando necessário, reduzimos o tempo de espera para dois segundos automaticamente! : D Então, sim, embora o código seja síncrono, ele tende a ter um significado diferente do que nas linguagens procedurais.

EDITAR:

Depois de compreender esse conceito em conjunto com a postagem de Godeke, é fácil imaginar como se torna simples tirar proveito de vários processadores, farms de servidores, armazenamentos de dados redundantes e quem sabe o que mais.

Spencer Ruport
fonte
Legal! Eu entendi totalmente mal como as mensagens estavam sendo tratadas. Obrigado, sua postagem ajuda.
Jim Anderson
"Uma linguagem funcional tentaria algo como o seguinte" - não tenho certeza sobre outras linguagens funcionais, mas em Erlang o exemplo funcionaria exatamente como no caso de linguagens procedurais. Você pode executar essas duas tarefas em paralelo gerando processos, permitindo que eles executem as duas tarefas de forma assíncrona e obtendo seus resultados no final, mas não é como "enquanto o código é síncrono, tende a ter um significado diferente do que nas linguagens procedurais. " Veja também a resposta de Chris.
hcs42
16

É provável que você esteja misturando síncrono com sequencial .

O corpo de uma função em erlang está sendo processado sequencialmente. Então, o que Spencer disse sobre esse "efeito auto-mágico" não é verdade para erlang. Você poderia modelar esse comportamento com erlang.

Por exemplo, você pode gerar um processo que calcula o número de palavras em uma linha. Como temos várias linhas, geramos um desses processos para cada linha e recebemos as respostas para calcular uma soma a partir dele.

Dessa forma, geramos processos que fazem os cálculos "pesados" (utilizando núcleos adicionais, se disponíveis) e depois coletamos os 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.

E isso é o que parece, quando executamos isso no 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
fonte
13

O principal fator que permite que Erlang seja escalonado está relacionado à simultaneidade.

Um sistema operacional fornece simultaneidade por dois mecanismos:

  • processos do sistema operacional
  • threads do sistema operacional

Os processos não compartilham o estado - um processo não pode travar outro por design.

Threads compartilham estado - um thread pode travar outro por design - esse é o seu problema.

Com Erlang - um processo de sistema operacional é usado pela máquina virtual e a VM fornece simultaneidade para o programa Erlang não usando threads de sistema operacional, mas fornecendo processos Erlang - isto é, Erlang implementa seu próprio timeslicer.

Esses processos Erlang se comunicam enviando mensagens (manipuladas pela VM Erlang, não pelo sistema operacional). Os processos Erlang se dirigem uns aos outros usando um ID de processo (PID) que tem um endereço de três partes <<N3.N2.N1>>:

  • processo não N1 em
  • VM N2 ligado
  • máquina física N3

Dois processos na mesma VM, em VMs diferentes na mesma máquina ou duas máquinas se comunicam da mesma maneira - seu dimensionamento é, portanto, independente do número de máquinas físicas nas quais você implanta seu aplicativo (na primeira aproximação).

Erlang é threadsafe apenas em um sentido trivial - ele não tem threads. (A linguagem, isto é, a VM SMP / multi-core usa um thread de sistema operacional por núcleo).

Gordon Guthrie
fonte
7

Você pode ter um mal-entendido de como funciona Erlang. O tempo de execução Erlang minimiza a alternância de contexto em uma CPU, mas se houver várias CPUs disponíveis, todas serão usadas para processar mensagens. Você não tem "threads" no sentido de que tem em outros idiomas, mas pode ter muitas mensagens sendo processadas simultaneamente.

Kristopher Johnson
fonte
4

As mensagens Erlang são puramente assíncronas; se você quiser uma resposta síncrona à sua mensagem, precisará codificar explicitamente para isso. O que possivelmente foi dito é que as mensagens em uma caixa de mensagens de processo são processadas sequencialmente. Qualquer mensagem enviada para um processo vai para essa caixa de mensagem de processo, e o processo pega uma mensagem dessa caixa, processa-a e então segue para a próxima, na ordem que achar adequada. Este é um ato muito sequencial e o bloco de recepção faz exatamente isso.

Parece que você misturou síncrono e sequencial, como Chris mencionou.

Jebu
fonte
-2

Em uma linguagem puramente funcional, a ordem de avaliação não importa - em um aplicativo de função fn (arg1, .. argn), os n argumentos podem ser avaliados em paralelo. Isso garante um alto nível de paralelismo (automático).

Erlang usa um modelo de processo em que um processo pode ser executado na mesma máquina virtual ou em um processador diferente - não há como saber. Isso só é possível porque as mensagens são copiadas entre processos, não há estado compartilhado (mutável). O paralelismo de multiprocessador vai muito além do multi-threading, uma vez que os threads dependem da memória compartilhada, pode haver apenas 8 threads rodando em paralelo em uma CPU de 8 núcleos, enquanto o multiprocessamento pode escalar para milhares de processos paralelos.

mfx
fonte