Come / perché i linguaggi funzionali (in particolare Erlang) si adattano bene?


92

Ho osservato per un po 'la crescente visibilità dei linguaggi e delle funzionalità di programmazione funzionali. Li ho esaminati e non ho visto il motivo dell'appello.

Poi, di recente, ho assistito alla presentazione "Basics of Erlang" di Kevin Smith a Codemash .

Mi è piaciuta la presentazione e ho imparato che molti degli attributi della programmazione funzionale rendono molto più facile evitare problemi di threading / concorrenza. Comprendo che la mancanza di stato e la mutabilità rende impossibile per più thread alterare gli stessi dati, ma Kevin ha detto (se ho capito bene) che tutte le comunicazioni avvengono tramite messaggi e che i messaggi vengono elaborati in modo sincrono (ancora una volta evitando problemi di concorrenza).

Ma ho letto che Erlang è utilizzato in applicazioni altamente scalabili (il motivo principale per cui Ericsson lo ha creato in primo luogo). Come può essere efficiente gestire migliaia di richieste al secondo se tutto viene gestito come un messaggio elaborato in modo sincrono? Non è per questo che abbiamo iniziato a passare all'elaborazione asincrona, in modo da poter trarre vantaggio dall'esecuzione di più thread operativi contemporaneamente e ottenere la scalabilità? Sembra che questa architettura, sebbene più sicura, sia un passo indietro in termini di scalabilità. Cosa mi sto perdendo?

Capisco che i creatori di Erlang abbiano intenzionalmente evitato di supportare il threading per evitare problemi di concorrenza, ma ho pensato che il multi-threading fosse necessario per ottenere la scalabilità.

In che modo i linguaggi di programmazione funzionale possono essere intrinsecamente thread-safe, ma comunque scalabili?


1
[Non menzionato]: la VM di Erlangs porta l'asincronia a un altro livello. Con voodoo magic (asm) consente operazioni di sincronizzazione come socket: read to block senza interrompere un thread del sistema operativo. Ciò ti consente di scrivere codice sincrono quando altri linguaggi ti costringono a nidi di callback asincroni. È molto più facile scrivere un'app di ridimensionamento con l'immagine mentale di micro-servizi a thread singolo VS tenendo a mente il quadro generale ogni volta che aggiungi qualcosa alla base di codice.
Vans S

@Vans S interessante.
Jim Anderson,

Risposte:


97

Un linguaggio funzionale non si basa (in generale) sulla mutazione di una variabile. Per questo motivo, non dobbiamo proteggere lo "stato condiviso" di una variabile, perché il valore è fisso. Questo a sua volta evita la maggior parte dei salti mortali che i linguaggi tradizionali devono attraversare per implementare un algoritmo su processori o macchine.

Erlang si spinge oltre i linguaggi funzionali tradizionali inserendo un sistema di trasmissione di messaggi che consente a tutto di funzionare su un sistema basato su eventi in cui un pezzo di codice si preoccupa solo di ricevere messaggi e inviare messaggi, senza preoccuparsi di un'immagine più grande.

Ciò significa che il programmatore è (nominalmente) indifferente al fatto che il messaggio verrà gestito su un altro processore o macchina: il semplice invio del messaggio è sufficiente per continuare. Se ha a cuore una risposta, la aspetterà come un altro messaggio .

Il risultato finale di questo è che ogni snippet è indipendente da ogni altro snippet. Nessun codice condiviso, nessuno stato condiviso e tutte le interazioni provenienti da un sistema di messaggi che può essere distribuito tra molti pezzi di hardware (o meno).

Confrontalo con un sistema tradizionale: dobbiamo posizionare mutex e semafori attorno alle variabili "protette" e all'esecuzione del codice. Abbiamo un legame stretto in una chiamata di funzione tramite lo stack (in attesa che si verifichi il ritorno). Tutto ciò crea colli di bottiglia che sono meno problematici in un sistema non condiviso come Erlang.

EDIT: dovrei anche sottolineare che Erlang è asincrono. Invia il tuo messaggio e forse / un giorno arriva un altro messaggio. O no.

Anche il punto di Spencer sull'esecuzione fuori servizio è importante e ben risolto.


Lo capisco, ma non vedo come il modello di messaggio sia efficiente. Direi il contrario. Questa è una vera rivelazione per me. Non c'è da stupirsi che i linguaggi di programmazione funzionali stiano ricevendo così tanta attenzione.
Jim Anderson,

3
Ottieni molto potenziale di concorrenza in un sistema che non condivide nulla. Una cattiva implementazione (ad esempio un messaggio elevato che passa sopra la testa) potrebbe silenziarlo, ma Erlang sembra aver capito bene e mantenere tutto leggero.
Godeke

È importante notare che mentre Erlang ha la semantica del passaggio di messaggi, ha un'implementazione della memoria condivisa, quindi ha la semantica descritta ma non copia le cose dappertutto se non è necessario.
Aaron Maenpaa,

1
@ Godeke: "Erlang (come la maggior parte dei linguaggi funzionali) mantiene una singola istanza di qualsiasi dato quando possibile". AFAIK, Erlang in realtà copia in profondità tutto ciò che è passato tra i suoi processi leggeri a causa della mancanza di GC simultanei.
JD

1
@JonHarrop ha quasi ragione: quando un processo invia un messaggio a un altro processo, il messaggio viene copiato; ad eccezione dei binari di grandi dimensioni, che vengono passati per riferimento. Vedi ad esempio jlouisramblings.blogspot.hu/2013/10/embrace-copying.html per sapere perché questa è una buona cosa.
hcs42

73

Il sistema di coda dei messaggi è interessante perché produce effettivamente un effetto "spara e attendi il risultato" che è la parte sincrona di cui stai leggendo. Ciò che lo rende incredibilmente fantastico è che significa che le linee non devono essere eseguite in sequenza. Considera il codice seguente:

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

Considera per un momento che methodWithALotOfDiskProcessing () impiega circa 2 secondi per essere completato e che methodWithALotOfNetworkProcessing () impiega circa 1 secondo per essere completato. In un linguaggio procedurale, l'esecuzione di questo codice richiederebbe circa 3 secondi perché le righe verrebbero eseguite in sequenza. Stiamo perdendo tempo aspettando il completamento di un metodo che potrebbe essere eseguito contemporaneamente all'altro senza competere per una singola risorsa. In un linguaggio funzionale le righe di codice non determinano quando il processore le tenterà. Un linguaggio funzionale proverebbe qualcosa di simile al seguente:

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.

Quant'è fico? Andando avanti con il codice e aspettando solo dove necessario, abbiamo ridotto automaticamente il tempo di attesa a due secondi! : D Quindi sì, mentre il codice è sincrono tende ad avere un significato diverso rispetto ai linguaggi procedurali.

MODIFICARE:

Una volta compreso questo concetto insieme al post di Godeke, è facile immaginare quanto sia semplice sfruttare più processori, server farm, archivi dati ridondanti e chissà cos'altro.


Freddo! Ho completamente frainteso come venivano gestiti i messaggi. Grazie, il tuo post aiuta.
Jim Anderson,

"Un linguaggio funzionale proverebbe qualcosa di simile al seguente" - Non sono sicuro di altri linguaggi funzionali, ma in Erlang l'esempio funzionerebbe esattamente come nel caso dei linguaggi procedurali. È possibile eseguire queste due attività in parallelo generando processi, consentendo loro di eseguire le due attività in modo asincrono e ottenere i loro risultati alla fine, ma non è come "mentre il codice è sincrono tende ad avere un significato diverso rispetto ai linguaggi procedurali. " Vedi anche la risposta di Chris.
hcs42

16

È probabile che tu stia confondendo sincrono con sequenziale .

Il corpo di una funzione in erlang viene elaborato in modo sequenziale. Quindi quello che ha detto Spencer su questo "effetto automagico" non è vero per erlang. Tuttavia, potresti modellare questo comportamento con erlang.

Ad esempio, potresti generare un processo che calcola il numero di parole in una riga. Dato che abbiamo diverse righe, generiamo un processo simile per ogni riga e riceviamo le risposte per calcolare una somma da esso.

In questo modo, generiamo processi che eseguono calcoli "pesanti" (utilizzando core aggiuntivi se disponibili) e successivamente raccogliamo i risultati.

-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 questo è come appare, quando lo eseguiamo nella 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> 

13

La cosa fondamentale che consente a Erlang di scalare è correlata alla concorrenza.

Un sistema operativo fornisce la concorrenza tramite due meccanismi:

  • processi del sistema operativo
  • thread del sistema operativo

I processi non condividono lo stato: un processo non può bloccarne un altro per progettazione.

I thread condividono lo stato: un thread può bloccarne un altro in base alla progettazione: questo è il tuo problema.

Con Erlang - un processo del sistema operativo viene utilizzato dalla macchina virtuale e la VM fornisce la concorrenza al programma Erlang non utilizzando thread del sistema operativo ma fornendo processi Erlang - ovvero Erlang implementa il proprio timeslicer.

Questi processi Erlang parlano tra loro inviando messaggi (gestiti dalla VM Erlang non dal sistema operativo). I processi Erlang si indirizzano a vicenda utilizzando un ID processo (PID) che ha un indirizzo in tre parti <<N3.N2.N1>>:

  • processo no N1 attivo
  • VM N2 acceso
  • macchina fisica N3

Due processi sulla stessa VM, su VM diverse sulla stessa macchina o due macchine comunicano nello stesso modo: il ridimensionamento è quindi indipendente dal numero di macchine fisiche su cui distribuisci l'applicazione (in prima approssimazione).

Erlang è sicuro per i thread solo in un senso banale: non ha thread. (Il linguaggio ovvero la VM SMP / multi-core utilizza un thread del sistema operativo per core).


7

Potresti avere un malinteso su come funziona Erlang. Il runtime di Erlang riduce al minimo il cambio di contesto su una CPU, ma se sono disponibili più CPU, vengono utilizzate tutte per elaborare i messaggi. Non hai "thread" nel senso che hai in altre lingue, ma puoi avere molti messaggi che vengono elaborati contemporaneamente.


4

I messaggi Erlang sono puramente asincroni, se vuoi una risposta sincrona al tuo messaggio devi codificarlo esplicitamente. Probabilmente è stato detto che i messaggi in una finestra di messaggio di processo vengono elaborati in sequenza. Qualsiasi messaggio inviato a un processo va a trovarsi in quella finestra di messaggio del processo, e il processo prende un messaggio da quella casella, lo elabora e poi passa a quello successivo, nell'ordine che ritiene opportuno. Questo è un atto molto sequenziale e il blocco di ricezione fa esattamente questo.

Sembra che tu abbia confuso sincrono e sequenziale come ha detto Chris.



-2

In un linguaggio puramente funzionale, l'ordine di valutazione non ha importanza: in un'applicazione di funzione fn (arg1, .. argn), gli n argomenti possono essere valutati in parallelo. Ciò garantisce un alto livello di parallelismo (automatico).

Erlang utilizza un modello di processo in cui un processo può essere eseguito nella stessa macchina virtuale o su un processore diverso: non c'è modo di saperlo. Ciò è possibile solo perché i messaggi vengono copiati tra i processi, non esiste uno stato condiviso (modificabile). Il parallelismo multiprocessore va molto più in là del multi-threading, poiché i thread dipendono dalla memoria condivisa, possono esserci solo 8 thread in esecuzione in parallelo su una CPU a 8 core, mentre la multi-elaborazione può scalare a migliaia di processi paralleli.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.