Salta al contenuto principale

02 - BEAM e Processi Elixir: Concorrenza Massiva senza Thread Condivisi

La BEAM può eseguire milioni di processi leggeri isolati: ogni processo ha il proprio heap, comunica solo via messaggi e non condivide stato.

Cosa ne pensi di questo articolo?

02 - BEAM e Processi Elixir: Concorrenza Massiva senza Thread Condivisi
ElixirFoto:Brett SaylesPexels

In Breve

La BEAM può eseguire milioni di processi leggeri isolati: ogni processo ha il proprio heap, comunica solo via messaggi e non condivide stato.

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

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti