Processi isolati che si scrivono lettere
In Elixir ogni pezzo di codice gira dentro un processo, e i processi non possono toccarsi: ciascuno ha la sua memoria e comunica con gli altri soltanto inviando messaggi. La guida ufficiale di Elixir lo dice in una riga: i processi sono isolati l'uno dall'altro, girano in concorrenza e comunicano via scambio di messaggi. La conseguenza pratica è che non hai bisogno di lock per proteggere la memoria, perché non c'è memoria condivisa da proteggere.
Un paragone che regge: un ufficio dove ogni impiegato ha la propria scrivania e una cassetta della posta. Nessuno entra nella scrivania di un altro; se ti serve qualcosa, scrivi un biglietto e lo lasci nella sua cassetta. Lui lo legge quando ha tempo, e può risponderti con un altro biglietto. La scrivania è la memoria privata del processo, la cassetta è la sua mailbox.
Attenzione a una promessa che si legge spesso: «le race condition non sono possibili». Sono impossibili le gare sulla memoria, perché non c'è memoria condivisa. Restano possibili le gare di logica: se due clienti chiedono a un processo contatore il valore, ciascuno calcola «valore più uno» e poi glielo rimanda come nuovo valore assoluto, un incremento si perde, anche se ogni singola operazione è perfettamente isolata. Il rimedio è far fare l'operazione al processo che possiede lo stato, come vedrai nel contatore più sotto.
Processo BEAM e thread del sistema operativo
Un processo BEAM non è un thread del sistema operativo: lo schedula la macchina virtuale, che usa tipicamente un certo numero di thread di sistema e ci distribuisce sopra tutti i processi. La documentazione di Erlang definisce i processi «leggeri», con impronta di memoria piccola, creazione e terminazione rapide e basso costo di scheduling. Non riporto confronti numerici con i thread, né tempi di cambio di contesto, perché non li ho misurati in questa sessione e la documentazione non li dà come cifre.
Un dato che si legge sulla documentazione è il limite: nella versione corrente di OTP (29.1.2, la
pagina «System Limits» consultata il 2026-11-12) il numero massimo di processi vivi in contemporanea
è per default 1.048.576, configurabile all'avvio con l'opzione +P. Il valore può essere
diverso nella tua versione: lo leggi con :erlang.system_info(:process_limit) in
iex, mentre :erlang.system_info(:process_count) ti dice quanti ne ha adesso la
tua istanza.
Milioni di processi sono possibili, ma non sono gratis: ogni processo ha una memoria e una mailbox, e un milione di processi fermi occupa comunque memoria.
spawn: creare un processo
spawn/1 avvia una funzione in un nuovo processo e restituisce subito il suo
PID, l'identificatore con cui lo raggiungi. Il processo padre non aspetta.
# Salva come processi.exs ed esegui con: elixir processi.exs
pid =
spawn(fn ->
IO.puts("figlio #{inspect(self())} avviato")
:timer.sleep(500)
IO.puts("figlio finito")
end)
IO.puts("padre #{inspect(self())} ha creato #{inspect(pid)}")
IO.puts("figlio vivo subito dopo spawn? #{Process.alive?(pid)}")
:timer.sleep(700)
IO.puts("figlio vivo dopo 700 ms? #{Process.alive?(pid)}")
Le righe che contano sono la prima e l'ultima. spawn(fn -> ... end) ritorna un PID prima
che il figlio abbia stampato qualcosa; Process.alive?(pid) subito dopo dovrebbe quindi
dare true e dopo 700 ms false, perché il figlio dorme 500 ms e poi la sua
funzione termina, e un processo termina quando la sua funzione ritorna.
L'ordine tra la riga del figlio («avviato») e quella del padre («ha creato») non è garantito: sono due processi concorrenti. Non ho potuto eseguire questo programma, quindi sono previsioni dedotte dalla documentazione, non uscite osservate.
Oltre a spawn/1 esiste la forma con modulo, funzione e argomenti,
spawn(Modulo, :funzione, [args]), utile quando la funzione è già un nome pubblico di un
modulo. Il lato negativo di spawn puro è che non c'è nessun legame fra padre e figlio: se
il figlio muore, il padre non lo sa. Ci torniamo nella sezione sui link.
send e receive: i biglietti nella cassetta
send(pid, messaggio) mette un messaggio nella mailbox del destinatario e ritorna subito:
chi invia non si blocca, come precisa la guida di Elixir. receive guarda i messaggi in
arrivo e ne prende il primo che corrisponde a uno dei pattern; se nessuno corrisponde, il processo
aspetta. Per non aspettare all'infinito si aggiunge una clausola after con un timeout.
defmodule PingPong do
def start do
parent = self()
child = spawn(fn -> child_loop(parent) end)
send(child, {:ping, "ciao"})
receive do
{:pong, message} -> IO.puts("padre ha ricevuto: #{message}")
after
5_000 -> IO.puts("timeout: nessuna risposta in 5 secondi")
end
send(child, :stop)
end
defp child_loop(parent_pid) do
receive do
{:ping, message} ->
IO.puts("figlio ha ricevuto: #{message}")
send(parent_pid, {:pong, "mondo"})
child_loop(parent_pid)
:stop ->
IO.puts("figlio si ferma")
end
end
end
PingPong.start()
:timer.sleep(100)
Il padre crea il figlio passandogli il proprio PID (parent = self(): dentro la funzione
anonima self() sarebbe il PID del figlio, quindi va catturato fuori), invia
{:ping, "ciao"} e aspetta la risposta: la clausola
after 5_000 scatta dopo cinque secondi se non arriva. Il figlio gira in
child_loop, che alla fine di ogni messaggio chiama se stessa: è così che un processo
resta in vita e risponde a più messaggi.
L'uscita attesa è «figlio ha ricevuto: ciao» seguita da
«padre ha ricevuto: mondo», in quest'ordine garantito dal codice stesso (il figlio stampa prima di
rispondere); poi «figlio si ferma» dopo il messaggio :stop. È dedotta, non osservata.
Una conseguenza da conoscere: i messaggi che non corrispondono a nessun pattern restano nella
mailbox, e occupano memoria finché il processo non muore. Un processo che riceve messaggi che non sa
gestire li accumula, e receive deve scorrerli ogni volta. Il rimedio è una clausola
finale che raccoglie «tutto il resto». Un'altra garanzia utile, dalla documentazione di Erlang: se un
processo invia più messaggi allo stesso destinatario, l'ordine si mantiene. Fra mittenti diversi
non c'è nessuna garanzia.
Un processo che custodisce uno stato
Da qui nasce il pattern più importante: un processo come proprietario di un dato. Lo stato non vive in una variabile modificabile ma è l'argomento della funzione ricorsiva del processo: ogni messaggio produce un nuovo valore e la funzione si richiama con quello. Un contatore è l'esempio più piccolo.
defmodule Counter do
def start(initial_value \\ 0) do
spawn(fn -> loop(initial_value) end)
end
def add(pid, n), do: send(pid, {:add, n})
def stop(pid), do: send(pid, :stop)
def get(pid) do
send(pid, {:get, self()})
receive do
{:value, n} -> n
after
5_000 -> {:error, :timeout}
end
end
# Lo stato è l'argomento della funzione: ogni messaggio ricorre con il valore nuovo
defp loop(count) do
receive do
{:add, n} ->
loop(count + n)
{:get, caller} ->
send(caller, {:value, count})
loop(count)
:stop ->
IO.puts("contatore fermo a #{count}")
end
end
end
counter = Counter.start()
Counter.add(counter, 1)
Counter.add(counter, 1)
Counter.add(counter, 10)
IO.puts(Counter.get(counter))
Counter.stop(counter)
:timer.sleep(100)
loop(count) è il cuore. Su {:add, n} si richiama con
count + n: lo stato è cambiato, senza assegnare nulla. Su {:get, caller}
risponde al mittente e si richiama con lo stesso valore. Su :stop non si richiama, la
funzione ritorna e il processo muore. Il client non tocca mai il valore: manda messaggi, e
get li chiude con una receive in attesa della risposta.
Il programma somma
1+1+10 e dovrebbe stampare 12, poi «contatore fermo a 12». Il risultato è sicuro per via della
garanzia d'ordine citata sopra: i tre add e il get partono dallo stesso
processo verso lo stesso destinatario, e arrivano in quell'ordine (esito dedotto, non osservato).
Il processo gestisce un messaggio alla volta, ed è proprio questo che elimina le gare: due client che incrementano insieme vengono serializzati dalla mailbox. Il rovescio è un collo di bottiglia: se tutto il traffico passa da un solo processo, la sua velocità limita quella dell'insieme, e se un messaggio impiega molto tempo gli altri aspettano.
Uno stato letto da molti
e scritto di rado di solito non vuole un processo solo. In pratica non scrivi i loop a mano: usi
GenServer, che incapsula esattamente questo schema, come vedrai nel
prossimo articolo della serie.
Quando un processo muore: link, monitor e trap_exit
Un processo può morire per un errore, e gli altri vogliono saperlo. Per default non lo sanno. Ci sono tre strumenti, che rispondono a domande diverse.
- Nessun legame (
spawn): il figlio crasha, il padre prosegue ignaro. Giusto quando il lavoro è davvero indipendente. - Link (
spawn_link): i due processi sono legati in modo bidirezionale, e se uno muore per un errore il segnale di uscita colpisce anche l'altro. È la base del «let it crash» di OTP. - Monitor (
Process.monitor): ricevi un messaggio{:DOWN, ...}quando l'altro muore, senza rischiare di morire a tua volta. Unidirezionale.
L'ordine delle voci non cambia il senso, ma il criterio sì: vuoi che il destino dei due sia condiviso (link), che uno osservi l'altro (monitor), o nessun legame. Il link è lo strumento giusto quando un processo non ha senso senza l'altro (il task e chi aspetta il suo risultato); il monitor quando il tuo processo deve decidere cosa fare, per esempio riprovare. In quasi tutti i programmi reali non gestisci questi segnali a mano, ma lasci che sia un supervisor a farlo.
defmodule CrashDemo do
# Il figlio crasha: il padre non se ne accorge, a parte il log dell'errore
def senza_link do
child =
spawn(fn ->
:timer.sleep(100)
raise "crash nel figlio"
end)
:timer.sleep(500)
IO.puts("padre vivo: #{Process.alive?(self())}, figlio vivo: #{Process.alive?(child)}")
end
# Con link e trap_exit la morte del figlio arriva come messaggio
def con_trap_exit do
Process.flag(:trap_exit, true)
child =
spawn_link(fn ->
:timer.sleep(100)
raise "crash nel figlio"
end)
receive do
{:EXIT, ^child, reason} ->
IO.puts("il padre ha ricevuto un exit, motivo: #{inspect(reason)}")
after
1_000 -> IO.puts("nessun exit ricevuto")
end
end
end
CrashDemo.senza_link()
CrashDemo.con_trap_exit()
Il blocco mostra i due comportamenti estremi senza far morire il programma. In
senza_link il figlio dorme 100 ms e solleva un errore; il padre aspetta 500 ms e
stampa che lui è vivo e il figlio no (previsione dedotta: l'errore compare nel log dell'esecuzione
ma non tocca il padre, come dimostra il corrispondente esempio della guida ufficiale). In
con_trap_exit il padre imposta Process.flag(:trap_exit, true) e così, anziché
morire insieme al figlio, riceve un messaggio {:EXIT, pid, motivo}.
Il motivo di un
errore sollevato è, per la documentazione di Erlang, una tupla con l'eccezione e lo stack. Il pin
^child in receive forza il confronto con quel PID preciso invece di
associare un nome nuovo. Se togliessi trap_exit, l'errore nel figlio ucciderebbe il
padre: non l'ho provato qui, ma è esattamente per questo che il link esiste.
Monitor: guardare senza essere trascinati
Con Process.monitor(pid) ottieni un riferimento e, quando quel processo termina, un
messaggio {:DOWN, ref, :process, pid, motivo}. Se il processo era già morto, il
messaggio arriva comunque con motivo :noproc, come mostra la documentazione di
Process.
worker =
spawn(fn ->
:timer.sleep(300)
IO.puts("worker finito")
end)
ref = Process.monitor(worker)
receive do
{:DOWN, ^ref, :process, ^worker, reason} ->
IO.puts("worker terminato, motivo: #{inspect(reason)}")
after
5_000 ->
Process.demonitor(ref)
IO.puts("worker ancora vivo dopo 5 secondi")
end
Il pattern di receive usa due volte il simbolo ^ per fissare il riferimento
e il PID attesi, in modo da non confondere questa notifica con quella di un altro monitor. Si
prevedono due righe, nell'ordine «worker finito» e poi «worker terminato, motivo: :normal»: il
secondo evento può accadere solo dopo il primo (dedotto, non osservato).
La clausola
after con Process.demonitor(ref) serve a ripulire il monitor se smetti di
aspettare. Un limite: se il processo morto lascia un lavoro a metà, nessuno lo ripara per te; il
monitor ti avvisa, la decisione resta tua.
Task: parallelismo senza scrivere receive
Per il caso «lancia N lavori, aspetta i risultati» il modulo Task evita di scrivere
send e receive a mano. Task.async crea un processo che
esegue la funzione; Task.await ritira la risposta.
defmodule ParallelFetcher do
def fetch_all(urls) do
urls
|> Enum.map(fn url -> Task.async(fn -> fetch(url) end) end)
|> Enum.map(fn task -> Task.await(task, 5_000) end)
end
# Finta chiamata di rete: dura tra 100 e 500 ms
defp fetch(url) do
:timer.sleep(Enum.random(100..500))
{:ok, "risposta da #{url}"}
end
end
urls = ["https://a.example", "https://b.example", "https://c.example"]
{micros, results} = :timer.tc(fn -> ParallelFetcher.fetch_all(urls) end)
IO.inspect(results)
IO.puts("tempo totale: #{div(micros, 1000)} ms")
Il punto cruciale è la forma del codice: prima tutti i Task.async (nel primo
Enum.map), poi tutti i Task.await. Se avessi alternato async e await, i
lavori sarebbero partiti uno per volta. Con tre attese casuali tra 100 e 500 ms, il tempo totale
atteso è vicino alla più lunga delle tre, non alla somma (la stampa «tempo totale» misura questo; non
l'ho osservata, quindi non scrivo un numero).
La documentazione di Task precisa che un
task creato con async è collegato e monitorato dal chiamante: se il chiamante muore, muore
anche il task, e viceversa, e che devi sempre fare await perché la risposta arriva
comunque nella tua mailbox.
Quando i lavori sono tanti, lanciarli tutti insieme è sbagliato: saturi la CPU, la rete o il database.
Task.async_stream li esegue con una concorrenza massima.
defmodule Batch do
def run(items, max_concurrency \\ 4) do
items
|> Task.async_stream(&slow_square/1,
max_concurrency: max_concurrency,
timeout: 1_000,
on_timeout: :kill_task
)
|> Enum.map(fn
{:ok, value} -> value
{:exit, reason} -> {:failed, reason}
end)
end
defp slow_square(n) do
:timer.sleep(100)
n * n
end
end
IO.inspect(Batch.run(1..8))
max_concurrency: 4 significa al più quattro processi insieme; timeout:
1_000 è il tempo massimo per elemento, e on_timeout: :kill_task dice di
uccidere il task scaduto e proseguire, invece di far crollare tutta l'operazione. Gli elementi che
falliscono tornano come {:exit, motivo} e il codice li trasforma in {:failed,
motivo}. L'uscita attesa per 1..8 è [1, 4, 9, 16, 25, 36, 49, 64]: per
default i risultati escono nell'ordine di ingresso (la documentazione elenca l'opzione
ordered).
Per un lavoro che non produce un risultato esiste Task.start:
parte e non aspetti nulla; la guida consiglia, per i task che contano, di inserirli in un
supervisor per vederli e controllarli.
Dare un nome a un processo
Passare il PID dappertutto è scomodo, e un PID cambia se il processo viene riavviato. Con
Process.register/2 assegni al processo un nome (un atom) e puoi inviargli
messaggi con il nome.
# Appendi in fondo allo script del contatore
pid = Counter.start(0)
Process.register(pid, :main_counter)
send(:main_counter, {:add, 5})
IO.puts(Counter.get(Process.whereis(:main_counter)))
IO.inspect(Process.whereis(:senza_nome))
Questo blocco riusa il modulo Counter del listato precedente. Dopo la registrazione,
send(:main_counter, ...) funziona senza conoscere il PID, e
Process.whereis/1 lo recupera dal nome (restituisce nil se il nome non esiste,
come l'ultima riga). Ci si aspetta di vedere 5 e poi nil, esito dedotto. La documentazione specifica due vincoli: il nome vale sul nodo locale, non su tutto un
cluster, e register/2 fallisce con ArgumentError se il nome è già in uso, se
il processo non esiste o se è già registrato con un altro nome.
Gli atom, poi, hanno un limite per default (1.048.576 secondo la pagina «System Limits» di OTP 29.1.2): non generare nomi da dati dell'utente.
Quando NON creare un processo
Un processo non è un modo di organizzare il codice: per quello ci sono i moduli e le funzioni. Crea un processo quando serve una di tre cose: eseguire qualcosa in concorrenza, custodire uno stato che più parti devono usare, o isolare un guasto in modo che non trascini gli altri.
Una funzione che calcola un valore non ha bisogno di un processo, e trasformarla in uno aggiunge messaggi, timeout e un nuovo modo di fallire. Nel dubbio parti con una chiamata di funzione normale, e passa a un processo quando misuri o hai bisogno di una di quelle tre cose.
Che cosa sai fare adesso
Sai creare un processo con spawn, far comunicare due processi con
send/receive evitando la mailbox che si riempie, tenere uno stato dentro
un loop ricorsivo, scegliere fra link, monitor e trap_exit secondo chi deve sapere cosa,
lanciare lavori in parallelo con Task e limitarne la concorrenza. Ricorda che le uscite
sopra sono dedotte, non osservate: il compilatore Elixir non era disponibile dove ho scritto
l'articolo, quindi esegui i listati prima di fidarti.
Per la sintassi e il pattern matching che qui si dà per noto, c'è il primo articolo della serie; il passo successivo, OTP e GenServer, trasforma il contatore ricorsivo in un componente standard con stato, chiamate sincrone e riavvio automatico. Per il confronto con altri modelli di concorrenza (thread, event loop, goroutine) leggi il primo articolo della serie Concurrency.
Fonti
- Elixir - Processes (guida) - isolamento dei processi, send non bloccante, receive e mailbox, link e Task, consultata il 2026-11-12
- Erlang Reference Manual - Processes - processi leggeri, garanzia d'ordine dei segnali fra due processi, motivo di uscita {Reason, Stack}, consultata il 2026-11-12
- Erlang System Limits - limite di processi per default (1.048.576 nella documentazione di OTP 29.1.2), opzione +P, consultata il 2026-11-12
- Elixir - Task - async/await collegato e monitorato, async_stream e le sue opzioni, consultata il 2026-11-12
- Elixir - Process - register/2 sul nodo locale e i casi di ArgumentError, whereis/1, monitor con :noproc, consultata il 2026-11-12
Sono tutte documentazioni ufficiali di Elixir ed Erlang/OTP, consultate nei passaggi indicati. Nessun programma dell'articolo è stato eseguito, e le uscite sono dedotte da queste pagine.

Commenti
Caricamento commenti...
Accedi per commentare