Dall’Actor Model alla Pratica – Giocando con I Processi in Elixir

Il mio viaggio in Elixir prosegue e, devo ammetterlo, è sempre più stimolante. Non soltanto da un punto di vista strettamente “tecnico” ma anche da quello legato alle inclinazioni personali, legate all’apprendimento (ed insegnamento) di qualcosa di nuovo.

Qualche settimana fa ho spiegato, partendo da un ottimo articolo in inglese, cos’è l’actor model. Qualche giorno fa, invece, ho spiegato come installare Elixir e configurare un primo progetto. Oggi mettiamo in pratica tutto quello che abbiamo visto: scriveremo del codice creando una piccola applicazione che fa uso di processi!

Ehm, cosa?

In Elixir, ogni singola riga di codice viene eseguita in un processo. Non parliamo dei processi “classici” a cui siamo abituati: parliamo invece di quelli della BEAM, la VM su cui gira il codice che noi scriviamo.

Ora, Elixir è un linguaggio fortemente orientato alla concorrenza e alla scrittura di applicazioni che fanno più cose contemporaneamente (per davvero). Un processo della BEAM è molto più leggero di quelli a cui siamo abituati, tant’è che su una macchina normalissima è possibile crearne centinaia di migliaia e farli lavorare contemporaneamente senza nessun problema.

Non perdiamoci in chiacchiere, iniziamo da cose semplicissime per poi renderle più interessanti!

Creazione del progetto ed esecuzione

Se abbiamo installato Elixir dovremmo avere a disposizione mix, il tool da linea di comando. Creiamoci subito un progetto con mix new, così:

[code lang=”bash”]$ mix new process_experiment[/code]

e lasciamo a mix l’onere di creare tutti i file necessari. Apriamo quindi il file lib/process_experiment.ex creato da mix. Di default, infatti, un file con lo stesso nome del progetto viene creato nella cartella lib insieme ad un file per i test nella cartella test.

Torniamo al nostro file, che al momento appare così:

[code]defmodule ProcessExperiment do
@moduledoc """
Documentation forProcessExperiment.
"""

@doc """
Hello world.

## Examples

iex> ProcessExperiment.hello
:world

"""
def hello do
:world
end
end
[/code]

Questo file è un modulo. In Elixir il codice viene raggruppato in moduli. In ogni modulo vengono create delle funzioni. In questo caso, ProcessExperiment è un modulo mentre hello è una funzione.

Possiamo eseguire facilmente il codice di questo modulo usando iex, la shell di Elixir. Eseguiamo

[code]$ iex -S mix[/code]

che si occupa di avviare la shell includendo i moduli del progetto su cui stiamo lavorando. Aspettiamo un paio di secondi e ci ritroveremo in iex. Richiamiamo la funzione usando la notazione Modulo.Funzione, così:

[code]iex(1)> ProcessExperiment.hello
:world[/code]

Effettivamente, come il codice in lib/process_experiment.ex suggerisce, il valore ritornato è :hello. Tra parentesi, :hello è un atom. Cosa sia per ora non ci interessa: cambiando quel :world nel codice con un semplice 1, non c’è nessuna differenza. Il meccanismo è quello.

Per i più curiosi, la documentazione ufficiale spiega cos’è un Atom e a cosa serve.

I processi entrano in gioco

Gli “hello world” sono bellissimi perchè poi nella vita si va avanti. Modifichiamo un po’ il codice di lib/process_experiment.ex, precisamente così:

[code]defmodule ProcessExperiment do
def hardWorker do
:timer.sleep(1000)
:rand.uniform(10)
end

def process do
IO.inspect([ProcessExperiment.hardWorker, ProcessExperiment.hardWorker, ProcessExperiment.hardWorker, ProcessExperiment.hardWorker, ProcessExperiment.hardWorker])
end
end[/code]

Ecco cosa succede:

  • nel nostro modulo abbiamo due funzioni: hardWorker process;
  • la prima simula un’operazione complessa, che dura un secondo. Dopodiché ritorna un numero casuale da 1 a 10;
  • la seconda crea un array richiamando cinque volte questa funzione. L’array viene dato in pasto ad IO.inspect, che si occupa di stampare su schermo il risultato;

Abbiamo già un’idea precisa di quanto ci metteremo: cinque chiamate ad una funzione che ci mette un secondo per rispondere sono cinque secondi in totale.

Proviamo ad eseguire il tutto per averne conferma: avviamo nuovamente il progetto con iex -S mix, quindi richiamiamo la funzione usando

[code]iex(1)> ProcessExperiment.process[/code]

… che dopo qualche secondo restituirà un array di numeri casuali.

Ora, dobbiamo sapere che i processi sono già entrati in gioco a tutti gli effetti. In Elixir infatti ogni singola riga di codice gira all’interno di un processo. Lo stesso codice che stiamo eseguendo via iex ha un suo processo dedicato. Per averne la prova, modifichiamo il codice come segue, aggiungendo due chiamate ad IO.inspect(self()).

[code]defmodule ProcessExperiment do
def hardWorker do
IO.inspect(self())
:timer.sleep(1000)
:rand.uniform(10)
end

def process do
IO.inspect([ProcessExperiment.hardWorker, ProcessExperiment.hardWorker, ProcessExperiment.hardWorker, ProcessExperiment.hardWorker, ProcessExperiment.hardWorker])

IO.inspect(self())
end
end[/code]

Eseguiamo nuovamente il codice e in console leggeremo qualcosa simile a

[code]iex(1)> ProcessExperiment.process
#PID<0.110.0>
#PID<0.110.0>
#PID<0.110.0>
#PID<0.110.0>
#PID<0.110.0>
[9, 6, 5, 2, 3][/code]

Bene: quel “#PID<0.110.0>” è l’indirizzo del processo attuale. Da qui il nome della funzione: self(). Ogni chiamata ad hardWorker impiega un secondo a fare il suo lavoro. Di conseguenza, il codice impiegherà circa 5 secondi per completare la sua esecuzione. Inoltre, cosa ancora più importante, il codice viene eseguito TUTTO su un unico processo.

Distribuiamo tutto su più processi

Una delle cose belle di Elixir è che è stato pensato appositamente per l’esecuzione di codice concorrente. Il che significa una cosa sola: che tante procedure per gestire questo genere di “problema” sono state semplificate enormemente.

Per ora abbiamo eseguito tutto su un solo processo: perchè non provare a fare la stessa cosa su più processi?

In questo nostro primo esperimento tutto quello di cui abbiamo bisogno è un modulo standard di Elixir che si chiama Task. Tale modulo, tra le varie cose, offre due funzioni async ed await. Usiamole subito, quindi vediamo cosa è successo.

Modifichiamo il nostro modulo come segue:

[code]defmodule ProcessExperiment do
def hardWorker do
IO.inspect(self())

:timer.sleep(1000)
:rand.uniform(10)
end

def process do
[
Task.async(&ProcessExperiment.hardWorker/0),
Task.async(&ProcessExperiment.hardWorker/0),
Task.async(&ProcessExperiment.hardWorker/0),
Task.async(&ProcessExperiment.hardWorker/0),
Task.async(&ProcessExperiment.hardWorker/0)
]
|> Enum.map(&Task.await/1)
|> IO.inspect
end
end[/code]

Vediamo linea per linea cosa succede:

  • la funzione hardWorker non è stata toccata. Stampa su schermo l’id del processo su cui gira nel momento in cui viene richiamata, attende un secondo e poi ritorna un numero casuale;

In process, invece, sono cambiate un po’ di cose:

  • viene definito un array, che al suo interno contiene cinque chiamate della funzione Task.async. Questa funzione si prende in input un’altra funzione e non fa altro che eseguirla in un altro processo;

La funzione viene chiamata usando una sintassi “speciale”. Non un semplice “ProcessExperiment.hardWorker” ma “&ProcessExperiment.hardWorker/0”.

Sembra ostrogoto ma non è niente di assurdo. Stiamo solo specificando che dovrà essere usata la funzione hardWorker. Passando semplicemente“ProcessExperiment.hardWorker”, infatti, otterremmo errore perché è come se stessimo facendo una chiamata alla funzione stessa durante il passaggio del parametro!

A noi non interessa chiamarla in quel momento, vogliamo solo dire a Task.async di eseguirla in un altro processo. Nella documentazione ufficiale è possibile vedere un ottimo esempio che chiarisce bene il concetto. Formalmente, tutto questo si chiama function capturing.

E quel “/0” finale? Non è altro che l’arity di una funzione: anche qui niente di speciale ma solo il numero di parametri che quella funzione richiede. Anche qui la documentazione spiega tutto adeguatamente.

Il Pipe Operator entra a gamba tesa

Le sorprese, però, sembrano non finire qui.

Dopo aver dichiarato l’array infatti ho aggiunto, subito a capo

[code]…
Task.async(&ProcessExperiment.hardWorker/0)
]
|> Enum.map(&Task.await/1)
|> IO.inspect[/code]

Cosa diamine è quel |> ? A cosa serve?

Si tratta di un’altra meraviglia di Elixir: il pipe operator. Il suo compito è semplicissimo: passa ciò che viene prima di esso come primo parametro della funzione chiamata dopo, rendendo non più necessario il passaggio del primo parametro, visto che ora  è implicito. Indovinate? La documentazione lo spiega benissimo.

Nel caso del nostro esempio, ecco cosa succede:

  • definiamo l’array di chiamate a Task.async;
  • passiamo l’array ad Enum.map usando il pipe operator. Enum.map, di default, prende due argomenti in input. Un array ed una callback da eseguire su ogni elemento di quell’array. Qui c’è la magia: non abbiamo bisogno di specificare entrambi gli argomenti perché il primo, l’array, è stato passato dal pipe operator;
  • il risultato di Enum.map è un array contenente i risultati delle cinque chiamate asincrone che abbiamo lanciato. Questo grazie a Task.await, il cui scopo è leggere il risultato della chiamata asincrona lanciata precedentemente. Se ci si fa caso, l’array da cui siamo partiti conta cinque chiamate a Task.async. Passando questo array, tramite il pipe operator, come primo parametro Enum.map, stiamo effettuando anche cinque chiamate a Task.await. Il risultato, di conseguenza, sarà un array di cinque numeri casuali;

Infine, sempre grazie al pipe operator, passiamo questo array risultante come primo (ed unico) parametro di IO.inspect che si occupa di stamparlo in console.

Non rimane che effettuare la prova finale: eseguire il codice che abbiamo scritto per assicurarci che, a differenza di prima, venga eseguito in contemporanea si più processi.

[code]ProcessExperiment.process
#PID<0.112.0>
#PID<0.113.0>
#PID<0.114.0>
#PID<0.115.0>
#PID<0.116.0>
[7, 1, 7, 3, 4][/code]

Sì! Ci siamo. Adesso abbiamo la certezza che il nostro codice sia stato eseguito su processi diversi:

  • PID riportati sono diversi e non più uno solo. Significa che le cinque chiamate ad hardWorker sono state effettivamente distribuite su cinque processi diversi. La chiamata alla funzione IO.inspect nella funzione serviva esattamente a conoscere questa informazione;
  • il codice è stato eseguito molto più velocemente! Solo un secondo e non più cinque;

Insomma…

Stavolta c’è tanta carne al fuoco, lo so. Il fatto è che non avevo voglia di scrivere altri articoli troppo teorici. Volevo buttarmi su qualcosa di più pratico, sporcarmi un attimo le mani e capire qualcosa di più sui processi. Elixir li gestisce alla grande e, molto presto, spero di farci altro di più divertente.

Chiaramente, riporterò il tutto qui fedelmente.