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.

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.

src/linked.gleam
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:

output
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 tipo Name(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à un Subject che 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.

src/supervised_counter.gleam
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:

output
Starting the counter
Counter: 2
Starting the counter
Counter: 0
Counter: 1

In 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:

  1. supervisor.new(supervisor.OneForOne) prepara un supervisore; supervisor.add gli aggiunge un figlio; supervisor.start lo avvia, e il supervisore avvia i figli (il primo Starting the counter).
  2. 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 restituisce actor.start (il tipo actor.StartResult).
  3. Il messaggio Crash fa schiantare l’attore. Il supervisore se ne accorge e chiama di nuovo start (il secondo Starting the counter); il nuovo attore si registra con lo stesso nome.
  4. Il Subject di main è legato al nome, non al processo: funziona anche con l’attore nuovo, senza che main se 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:

StrategiaQuando 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:

output
runtime error: Erlang exit

An error occurred outside of Gleam.

exit reason:
  Shutdown

Nei 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 con panic 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):

output
Guests: Ada, Joe
Guests: Lucy
Mostra una soluzione (prima prova da solo!)
src/guest_book.gleam
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.spawn collega il processo nuovo a chi lo avvia: se uno si schianta, muore anche l’altro. process.spawn_unlinked non 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.