Lezione 6 di 7 · 25 min di lettura
Lascia che si schianti
Sulla BEAM gli errori imprevisti non si inseguono: si lascia schiantare il processo, e un supervisore lo rimette in piedi. I link, i supervisori di gleam_otp, i nomi dei processi, e il limite ai riavvii.
Una filosofia diversa
In molti linguaggi si impara a programmare in difesa: controllare ogni valore, circondare ogni operazione rischiosa di try/catch, prevedere ogni guasto possibile. Il risultato è codice in cui la gestione degli errori è più lunga del lavoro vero, e che comunque, prima o poi, incontra il guasto che nessuno aveva previsto.
Erlang ha scelto un’altra strada, riassunta in un motto famoso: let it crash, lascia che si schianti. Gli errori previsti (un input sbagliato, un file che non c’è) si gestiscono, e in Gleam lo facciamo con Result, come nel modulo 5. Ma per quelli imprevisti, invece di provare a tenere in piedi un processo in uno stato che nessuno capisce più, lo si lascia morire. E un altro processo, che non fa nient’altro che sorvegliare, lo fa ripartire da uno stato pulito.
Perché questo funzioni servono due cose: che la morte di un processo si sappia, e qualcuno pronto a reagire. Sono i link e i supervisori.
I link
Nella lezione sui messaggi un processo si era schiantato, e si era portato dietro anche main. Non era un caso: process.spawn crea un processo collegato (linked) a chi lo avvia. Due processi collegati condividono il destino: se uno si schianta, muore anche l’altro.
import gleam/erlang/process
import gleam/io
pub fn main() -> Nil {
process.spawn(fn() {
process.sleep(100)
panic as "something went wrong"
})
process.sleep(500)
io.println("Main done")
}Lancialo, e al posto di Main done vedrai l’errore del processo figlio, seguito da un lungo rapporto di Erlang che qui accorcio:
runtime error: panic
something went wrong
stacktrace:
linked.-main/0-anonymous-0- src/linked.gleam:7
proc_lib.init_p proc_lib.erl:313
=CRASH REPORT==== 27-Sep-2026::21:07:00.577054 ===
crasher:
initial call: linked:'-main/0-anonymous-0-'/0
pid: <0.84.0>
...Il processo figlio è morto per il panic, e il collegamento ha fatto morire main mentre dormiva: Main done non viene mai stampato, e il programma esce con codice 1. Il CRASH REPORT è il rapporto che la BEAM scrive quando un processo avviato con spawn muore male: chi era, cosa stava facendo, con chi era collegato. È lungo, ma quando qualcosa si rompe in un programma vero è prezioso.
Se non vuoi il collegamento, c’è process.spawn_unlinked. Con quella, nello stesso programma, il figlio muore da solo: vedi il suo CRASH REPORT, e poi Main done, e il programma esce con codice 0.
Dettagli nerd Cosa succede davvero tra due processi collegati? (i segnali di uscita)
Quando un processo termina, la BEAM manda un segnale di uscita (exit signal) a tutti i processi collegati con lui, con il motivo della morte. Se il motivo è “normale” (la funzione del processo è finita), il segnale viene ignorato. Se il motivo è un errore, chi riceve il segnale muore a sua volta, con lo stesso motivo, e manda il segnale ai suoi collegati: la morte si propaga lungo i link.
Un processo però può chiedere di intrappolare le uscite (trap exits, in Gleam process.trap_exits(True)): da quel momento, i segnali di uscita non lo uccidono più, ma arrivano nella sua mailbox come messaggi normali, da leggere con calma. È esattamente quello che fa un supervisore: è collegato a tutti i processi che sorveglia, intrappola le uscite, e quando riceve il messaggio “il figlio è morto” decide cosa fare.
Il supervisore
Un supervisore è un processo che avvia altri processi, i suoi figli, e li fa ripartire quando muoiono. In gleam_otp il più semplice è gleam/otp/static_supervisor: “statico” perché i figli si decidono una volta, all’avvio.
C’è però un problema da risolvere prima. Quando un attore viene riavviato, il processo nuovo ha un Pid nuovo e un Subject nuovo; e chi aveva in mano il Subject vecchio continuerebbe a scrivere a un processo morto. Serve un indirizzo che resti valido anche dopo un riavvio: un nome.
process.new_name("counter")crea un nome nuovo, di tipoName(Message): anche il nome sa che tipo di messaggi accetta.actor.named(name)fa sì che l’attore, all’avvio, si registri con quel nome.process.named_subject(name)dà unSubjectche manda al processo che in quel momento è registrato con il nome, chiunque sia.
Mettiamo tutto insieme: un contatore sotto supervisione, con un messaggio Crash che lo fa schiantare apposta.
import gleam/erlang/process.{type Name, type Subject}
import gleam/int
import gleam/io
import gleam/otp/actor
import gleam/otp/static_supervisor as supervisor
import gleam/otp/supervision
pub type Message {
Increment
Crash
Get(reply_to: Subject(Int))
}
pub fn main() -> Nil {
let name = process.new_name("counter")
let assert Ok(_) =
supervisor.new(supervisor.OneForOne)
|> supervisor.add(supervision.worker(fn() { start(name) }))
|> supervisor.start
let counter = process.named_subject(name)
process.send(counter, Increment)
process.send(counter, Increment)
show(counter)
process.send(counter, Crash)
process.sleep(100)
show(counter)
process.send(counter, Increment)
show(counter)
}
fn start(name: Name(Message)) -> actor.StartResult(Subject(Message)) {
io.println("Starting the counter")
actor.new(0)
|> actor.named(name)
|> actor.on_message(handle_message)
|> actor.start
}
fn show(counter: Subject(Message)) -> Nil {
let value = process.call(counter, waiting: 100, sending: Get)
io.println("Counter: " <> int.to_string(value))
}
fn handle_message(count: Int, message: Message) -> actor.Next(Int, Message) {
case message {
Increment -> actor.continue(count + 1)
Crash -> panic as "the counter crashed"
Get(reply_to) -> {
process.send(reply_to, count)
actor.continue(count)
}
}
}Le righe stampate dal programma sono queste:
Starting the counter
Counter: 2
Starting the counter
Counter: 0
Counter: 1In mezzo, su stderr, vedrai due rapporti: il CRASH REPORT dell’attore, con il messaggio the counter crashed, e un SUPERVISOR REPORT, in cui il supervisore annota che un figlio è terminato (child_terminated) e che lo sta riavviando. Se vuoi vedere solo l’output del programma, puoi mandare stderr nel nulla, come nella lezione 1.6: gleam run -m supervised_counter 2> /dev/null.
Cosa è successo:
supervisor.new(supervisor.OneForOne)prepara un supervisore;supervisor.addgli aggiunge un figlio;supervisor.startlo avvia, e il supervisore avvia i figli (il primoStarting the counter).supervision.worker(fn() { start(name) })è la specifica del figlio: la funzione che il supervisore chiama per avviarlo, la prima volta e a ogni riavvio. Deve restituire proprio quello che restituisceactor.start(il tipoactor.StartResult).- Il messaggio
Crashfa schiantare l’attore. Il supervisore se ne accorge e chiama di nuovostart(il secondoStarting the counter); il nuovo attore si registra con lo stesso nome. - Il
Subjectdimainè legato al nome, non al processo: funziona anche con l’attore nuovo, senza chemainse ne accorga.
Il process.sleep(100) dopo Crash dà al supervisore il tempo di riavviare l’attore: un messaggio mandato a un nome mentre nessun processo è registrato farebbe schiantare chi lo manda.
Lo stato si perde
Guarda il terzo Counter: 0, non 2. L’attore nuovo riparte dallo stato iniziale, e i due incrementi di prima sono persi insieme al processo vecchio. È il prezzo del “lascia che si schianti”, ed è anche il suo senso: lo stato del processo morto poteva essere proprio la causa del guasto, e ripartire da uno stato pulito e conosciuto è la cosa più sicura.
Quello che non si può perdere, i soldi sul conto, gli ordini dei clienti, non si tiene solo nella memoria di un processo: si salva in un database, o in un processo più semplice e più stabile, che i processi più fragili interrogano. Una buona regola: più un processo è importante, meno cose deve fare.
Quiz
Un attore supervisionato con OneForOne si schianta mentre il suo stato vale 10. Cosa succede?
Le strategie
Un supervisore può avere tanti figli, e la strategia dice cosa fare con gli altri quando uno muore:
| Strategia | Quando un figlio muore… |
|---|---|
OneForOne | …si riavvia solo lui. La scelta più comune: i figli sono indipendenti. |
OneForAll | …si fermano e si riavviano tutti. Per figli che non hanno senso l’uno senza l’altro. |
RestForOne | …si riavviano lui e quelli aggiunti dopo di lui. Per figli che dipendono da quelli avviati prima. |
Quando riavviare non basta
E se l’attore si schianta di nuovo, e di nuovo? Riavviarlo all’infinito non servirebbe a niente. Per questo un supervisore ha un limite di riavvii: di serie, se deve riavviare più di 2 volte in 5 secondi, si arrende. Termina tutti i suoi figli, e poi termina sé stesso.
Prova a mandare Crash tre volte di fila nel programma di prima, con il solito sleep dopo ognuno: al terzo, nel rapporto del supervisore compare reached_max_restart_intensity, e siccome il supervisore è collegato a main, il programma termina con:
runtime error: Erlang exit
An error occurred outside of Gleam.
exit reason:
ShutdownNei programmi veri questo non è la fine: il supervisore è a sua volta figlio di un altro supervisore, che riavvierà lui e tutti i suoi figli, da capo. I supervisori si mettono uno dentro l’altro, formando un albero di supervisione: gli errori piccoli si risolvono in basso, riavviando un attore; quelli che persistono salgono verso l’alto, riavviando pezzi sempre più grandi del programma. I limiti si cambiano con supervisor.restart_tolerance(intensity: 5, period: 10), e un supervisore si aggiunge come figlio di un altro con supervisor.supervised.
Esercizio · sul tuo computer
Il libro degli ospiti
Crea src/guest_book.gleam: un attore che tiene la lista degli ospiti che hanno firmato, sotto un supervisore OneForOne, registrato con un nome. I messaggi:
Sign(guest: String): un ospite firma. Se il nome è vuoto, l’attore si schianta conpanic as "a guest without a name"(è un errore imprevisto: non dovrebbe mai succedere);Guests(reply_to: Subject(List(String))): la lista degli ospiti, nell’ordine in cui hanno firmato.
In main: firmano Ada e Joe, stampi la lista; poi arriva una firma vuota; aspetti 100 millisecondi, firma Lucy, e stampi di nuovo la lista. L’output del programma (senza i rapporti su stderr):
Guests: Ada, Joe
Guests: LucyMostra una soluzione (prima prova da solo!)
import gleam/erlang/process.{type Name, type Subject}
import gleam/io
import gleam/list
import gleam/otp/actor
import gleam/otp/static_supervisor as supervisor
import gleam/otp/supervision
import gleam/string
pub type Message {
Sign(guest: String)
Guests(reply_to: Subject(List(String)))
}
pub fn main() -> Nil {
let name = process.new_name("guest_book")
let assert Ok(_) =
supervisor.new(supervisor.OneForOne)
|> supervisor.add(supervision.worker(fn() { start(name) }))
|> supervisor.start
let book = process.named_subject(name)
process.send(book, Sign("Ada"))
process.send(book, Sign("Joe"))
show(book)
process.send(book, Sign(""))
process.sleep(100)
process.send(book, Sign("Lucy"))
show(book)
}
fn start(name: Name(Message)) -> actor.StartResult(Subject(Message)) {
actor.new([])
|> actor.named(name)
|> actor.on_message(handle_message)
|> actor.start
}
fn show(book: Subject(Message)) -> Nil {
let guests = process.call(book, waiting: 100, sending: Guests)
io.println("Guests: " <> string.join(guests, with: ", "))
}
fn handle_message(
guests: List(String),
message: Message,
) -> actor.Next(List(String), Message) {
case message {
Sign("") -> panic as "a guest without a name"
Sign(guest) -> actor.continue([guest, ..guests])
Guests(reply_to) -> {
process.send(reply_to, list.reverse(guests))
actor.continue(guests)
}
}
}La firma vuota fa schiantare l’attore, e con lui se ne vanno Ada e Joe: il libro nuovo parte vuoto, e Lucy è la prima a firmarlo. Se in un caso vero gli ospiti fossero importanti, il nome vuoto andrebbe rifiutato con un Result, prima di arrivare all’attore: è un errore previsto.
Ricapitolando
- Let it crash: gli errori previsti si gestiscono con
Result; per quelli imprevisti si lascia morire il processo, e lo si riavvia da uno stato pulito. process.spawncollega il processo nuovo a chi lo avvia: se uno si schianta, muore anche l’altro.process.spawn_unlinkednon collega.- Un supervisore (
gleam/otp/static_supervisor) avvia i suoi figli e li riavvia quando muoiono:supervisor.new(strategia) |> supervisor.add(supervision.worker(avvio)) |> supervisor.start. - Un nome (
process.new_name,actor.named,process.named_subject) è un indirizzo che resta valido anche dopo un riavvio. - Un processo riavviato riparte dallo stato iniziale.
- Le strategie:
OneForOne,OneForAll,RestForOne. Oltre 2 riavvii in 5 secondi il supervisore si arrende, e l’errore sale nell’albero di supervisione.
Nella prossima lezione, la sfida finale del modulo: una biglietteria presa d’assalto da tanti clienti contemporaneamente.