Salta al contenuto principale

03 - OTP e GenServer: Gestione dello Stato Distribuito con Fault Tolerance Integrata

Un GenServer tiene lo stato senza lock ma serve i messaggi in fila: call contro cast, let it crash, Registry e il timeout di 5 secondi che non hai scelto.

Cosa ne pensi di questo articolo?

03 - OTP e GenServer: Gestione dello Stato Distribuito con Fault Tolerance Integrata
ElixirFoto:Ruben BoekelooPexels

In Breve

Un GenServer tiene lo stato senza lock ma serve i messaggi in fila: call contro cast, let it crash, Registry e il timeout di 5 secondi che non hai scelto.

Un GenServer e' un processo che tiene uno stato e risponde a messaggi, uno per volta. Da questa frase derivano due conseguenze che valgono piu' di tutta la teoria: lo stato dentro un GenServer non ha bisogno di lock, perche' un solo processo lo tocca, e il GenServer e' un collo di bottiglia, perche' i messaggi vengono serviti in fila. Chi progetta bene con OTP sfrutta la prima e tiene d'occhio la seconda.

Questo articolo scrive un contatore, poi un rate limiter con stato strutturato, poi una sessione per utente registrata a nome, e in ciascun caso dice che cosa fa la riga che conta e che cosa succede se la togli. Al centro c'e' «let it crash», che non e' un atteggiamento ma una divisione del lavoro fra il tuo codice e il supervisore.

Il codice di questo articolo non e' stato eseguito

La macchina su cui l'articolo e' stato scritto non ha un ambiente Elixir installato, quindi gli output nei commenti sono il comportamento documentato dei callback, non la cattura di una sessione iex. Le firme, i valori di ritorno ammessi e i timeout citati sono stati verificati sulla documentazione di GenServer linkata in fondo. Il modo giusto di leggere i blocchi e' incollarli in iex -S mix.

Che cos'e' OTP, detto senza il nome

OTP sta per Open Telecom Platform, ed e' un nome che oggi inganna: non c'entra nulla con la telefonia e non e' un framework web. E' un insieme di behaviour - interfacce che il tuo modulo implementa - piu' le regole su come comporli in un albero di processi che si sorvegliano a vicenda.

Un behaviour risolve un problema molto concreto. Scrivere a mano un processo che tiene uno stato significa scrivere un ciclo ricorsivo con un receive dentro, e in quel ciclo devi ricordarti di gestire i messaggi di sistema, le richieste sincrone con il mittente giusto, l'arresto ordinato. GenServer e' quel ciclo, scritto una volta e collaudato: tu fornisci soltanto le funzioni che decidono cosa fare con ogni messaggio.

I callback che userai, e in quale ordine incontrarli

  • init/1 - costruisce lo stato iniziale, dentro il processo appena nato
  • handle_call/3 - richiesta sincrona: il chiamante aspetta la risposta
  • handle_cast/2 - richiesta asincrona: il chiamante non aspetta nulla
  • handle_info/2 - messaggi che non passano da call o cast: timer, monitor
  • handle_continue/2 - lavoro da fare subito dopo init/1, senza bloccare chi avvia
  • terminate/2 - pulizia prima dell'arresto, e non sempre viene chiamata

Solo init/1 e' obbligatorio: gli altri hanno un'implementazione di riserva che fallisce o ignora, quindi un GenServer minimo compila con una sola funzione. handle_continue/2 e' l'unico che si scopre tardi, e serve quando init/1 dovrebbe fare qualcosa di lento: restituendo {:ok, stato, {:continue, :carica}} il server risulta avviato subito e fa il lavoro pesante nel callback successivo, invece di tenere bloccato il supervisore che lo sta avviando. Quello che decide la qualita' del tuo design e' la scelta fra i due di mezzo, e il criterio e' una domanda sola: il chiamante ha bisogno della risposta per proseguire? La voce da leggere con piu' sospetto e' l'ultima, perche' il nome terminate promette una garanzia che non c'e'.

Un GenServer completo, riga per riga

Il contatore e' l'esempio minimo che contiene tutto: due operazioni sincrone, due asincrone, un messaggio esterno. La convenzione da notare prima del codice e' la divisione in due meta': le funzioni della client API girano nel processo di chi chiama, i callback girano nel processo del server. Sono due mondi diversi che vivono nello stesso file.

lib/my_app/counter.ex
defmodule MyApp.Counter do
  use GenServer

  # --- Client API: eseguita nel processo CHIAMANTE ---

  def start_link(initial_value \\ 0) do
    GenServer.start_link(__MODULE__, initial_value, name: :my_counter)
  end

  def increment(n \\ 1), do: GenServer.call(:my_counter, {:increment, n})

  def get_value, do: GenServer.call(:my_counter, :get_value)

  def decrement_async(n \\ 1), do: GenServer.cast(:my_counter, {:decrement, n})

  def reset, do: GenServer.cast(:my_counter, :reset)

  # --- Callback: eseguiti nel processo DEL SERVER ---

  @impl true
  def init(initial_value) do
    {:ok, initial_value}
  end

  @impl true
  def handle_call({:increment, n}, _from, state) do
    new_state = state + n
    {:reply, new_state, new_state}   # {:reply, cosa_rispondo, nuovo_stato}
  end

  def handle_call(:get_value, _from, state) do
    {:reply, state, state}           # rispondo e lascio lo stato com'era
  end

  @impl true
  def handle_cast({:decrement, n}, state) do
    {:noreply, state - n}            # nessuna risposta, solo nuovo stato
  end

  def handle_cast(:reset, _state), do: {:noreply, 0}

  @impl true
  def handle_info(:log_state, state) do
    IO.puts("[Counter] valore: #{state}")
    {:noreply, state}
  end

  # Rete di sicurezza: un messaggio ignoto non deve far cadere il server
  def handle_info(msg, state) do
    IO.puts("messaggio inatteso: #{inspect(msg)}")
    {:noreply, state}
  end
end

La forma dei valori di ritorno e' la parte che non si puo' improvvisare. handle_call/3 restituisce {:reply, risposta, nuovo_stato}: tre elementi, perche' deve dire sia cosa mandare al chiamante sia come resta lo stato. In {:increment, n} i due coincidono - rispondo col nuovo valore - ma non e' una regola: in :get_value rispondo con lo stato e lo stato non cambia. handle_cast/2 restituisce invece {:noreply, nuovo_stato}, due elementi, perche' non c'e' nessuno in attesa.

Il secondo parametro di handle_call, qui scritto _from, e' la coppia che identifica il chiamante. L'underscore dice che non la usi, e nella maggioranza dei casi e' corretto: serve quando vuoi rispondere dopo, restituendo {:noreply, stato} e chiamando GenServer.reply(from, risposta) piu' tardi. E' il modo di non bloccare la coda mentre aspetti qualcosa di lento.

La seconda clausola di handle_info/2, quella che accetta qualunque messaggio, e' l'unica riga difensiva che vale la pena scrivere in un GenServer. Senza di essa, un messaggio non previsto - la risposta in ritardo di una richiesta scaduta, un :DOWN di un monitor che non aspettavi - non combacia con nessuna clausola e il processo muore con FunctionClauseError. Con essa, lo stesso messaggio viene registrato e il server continua.

Attenzione invece a terminate/2, che nell'originale di questo articolo c'era e qui e' stata tolta di proposito: viene invocata solo se il processo sta intercettando le uscite oppure se si arresta ordinatamente. Un Process.exit(pid, :kill), o un crash, la salta. Mettere in terminate/2 la scrittura su disco dello stato significa perdere quella scrittura esattamente nei casi per cui l'avevi messa.

Come si usa, e cosa si osserva
{:ok, _pid} = MyApp.Counter.start_link(0)

MyApp.Counter.increment()         # => 1   (call: aspetta e ottiene il valore)
MyApp.Counter.increment(5)        # => 6
MyApp.Counter.decrement_async(2)  # => :ok (cast: ritorna subito, 2 non ancora sottratto)
MyApp.Counter.get_value()         # => 4   (la call si mette in coda DOPO il cast)

# Un messaggio spedito a mano arriva in handle_info
send(:my_counter, :log_state)
# [Counter] valore: 4

La riga da guardare e' la terza. decrement_async/1 restituisce :ok immediatamente, e in quel momento la sottrazione non e' ancora avvenuta: hai solo messo un messaggio nella casella del server. Eppure la get_value() subito dopo legge 4, cioe' il valore corretto. Non e' fortuna: il GenServer serve la casella in ordine di arrivo, quindi il cast viene elaborato prima della call che l'ha seguito.

Questa garanzia di ordinamento vale fra due messaggi dello stesso mittente. Se il cast partisse da un altro processo, nessuno ti assicura quale dei due arrivi prima, ed e' da qui che nascono i test che passano nove volte su dieci.

La versione originale di questo esempio metteva un :timer.sleep(10) fra le due righe per «aspettare il cast»: l'ho rimosso perche' insegna la tecnica sbagliata - una pausa non e' una sincronizzazione, e' una scommessa sul fatto che dieci millisecondi bastino.

Il modo corretto di attendere un cast, se proprio devi, e' far seguire una call qualunque allo stesso processo: quando la call risponde, tutti i messaggi che hai spedito prima sono stati elaborati. E' esattamente cio' che fa la riga get_value() qui sopra, e per questo funziona senza pause.

Il timeout di GenServer.call e' cinque secondi, e non lo hai scelto tu

GenServer.call/2 ha un timeout predefinito di 5000 millisecondi: se il server non risponde entro quel tempo, il chiamante esce con :timeout. E' il dettaglio che trasforma il collo di bottiglia da nota teorica a incidente: se il tuo GenServer elabora una richiesta in 200 ms e ne arrivano cinquanta in un colpo, le ultime della fila superano i cinque secondi e i loro chiamanti muoiono, pur essendo il server perfettamente sano.

Alzare il timeout con GenServer.call(server, msg, 30_000) sposta il problema, non lo risolve. Le due vere risposte sono spostare il lavoro lento fuori dal callback - rispondendo in differita con GenServer.reply/2 - oppure smettere di avere un processo unico, cosa che la sezione sul Registry rende possibile.

Stato strutturato: un rate limiter

Un intero come stato va bene per un contatore. Appena lo stato ha piu' di un campo conviene una struct, per due motivi pratici: i campi hanno un nome, e un errore di battitura su un nome diventa un errore di compilazione invece di una chiave nuova aggiunta in silenzio.

lib/my_app/rate_limiter.ex - finestra scorrevole per utente
defmodule MyApp.RateLimiter do
  use GenServer

  defstruct window_ms: 60_000, max_requests: 100, requests: %{}

  def start_link(opts \\ []) do
    GenServer.start_link(__MODULE__, opts, name: __MODULE__)
  end

  @doc "Ritorna {:ok, rimanenti} oppure {:error, :rate_limited, riprova_fra_ms}"
  def check(user_id), do: GenServer.call(__MODULE__, {:check, user_id})

  @impl true
  def init(opts) do
    # Pulizia periodica: manda :cleanup a questo stesso processo ogni 30 s
    :timer.send_interval(30_000, :cleanup)

    {:ok, %__MODULE__{
      window_ms: Keyword.get(opts, :window_ms, 60_000),
      max_requests: Keyword.get(opts, :max_requests, 100)
    }}
  end

  @impl true
  def handle_call({:check, user_id}, _from, state) do
    now = System.monotonic_time(:millisecond)
    window_start = now - state.window_ms

    # Tengo solo i timestamp dentro la finestra
    recenti =
      state.requests
      |> Map.get(user_id, [])
      |> Enum.filter(&(&1 > window_start))

    if length(recenti) >= state.max_requests do
      riprova_fra = List.last(recenti) + state.window_ms - now
      {:reply, {:error, :rate_limited, riprova_fra}, state}
    else
      nuovo_stato = put_in(state.requests[user_id], [now | recenti])
      {:reply, {:ok, state.max_requests - length(recenti) - 1}, nuovo_stato}
    end
  end

  @impl true
  def handle_info(:cleanup, state) do
    window_start = System.monotonic_time(:millisecond) - state.window_ms

    vivi =
      state.requests
      |> Enum.reject(fn {_user, ts} -> Enum.all?(ts, &(&1 <= window_start)) end)
      |> Map.new()

    {:noreply, %{state | requests: vivi}}
  end
end

Il cuore e' System.monotonic_time/1, e la scelta non e' indifferente: l'orologio monotono non torna indietro, mentre System.system_time/1 segue l'ora di sistema e puo' saltare quando NTP corregge la macchina. Con l'ora di sistema, una correzione all'indietro di un minuto sbloccherebbe tutti gli utenti limitati in quel momento. Per misurare intervalli si usa sempre il tempo monotono.

La riga Enum.filter(&(&1 > window_start)) e' la finestra scorrevole: invece di azzerare un contatore ogni minuto, si tengono i singoli istanti e si contano quelli recenti. La differenza si vede al confine: con il contatore azzerato a intervalli fissi, un utente puo' fare 100 richieste a fine minuto e altre 100 all'inizio del successivo, cioe' 200 in due secondi. Con la finestra scorrevole no.

handle_info(:cleanup, ...) esiste perche' handle_call filtra i timestamp solo degli utenti che tornano. Un utente che fa dieci richieste e poi sparisce lascia la sua chiave nella map per sempre: senza la pulizia, state.requests e' una perdita di memoria lenta, proporzionale al numero di utenti distinti che hai mai visto. :timer.send_interval/2 chiamato dentro init/1 e' cio' che la innesca, e manda il messaggio al processo che lo ha chiamato.

Due correzioni rispetto alla versione precedente di questo esempio, perche' erano sbagliate e non vale la pena nasconderlo.

Il retry_after era calcolato con hd(recenti), la testa della lista, che e' il timestamp piu' recente dato che si inserisce in testa: diceva al client di riprovare dopo una finestra intera invece che alla scadenza del piu' vecchio, ora e' List.last/1. E i rimanenti erano calcolati su una lista a cui era gia' stato aggiunto l'istante corrente, quindi sovrastimavano di uno.

Resta un limite che va detto, perche' e' architetturale e non si corregge con una riga: questo rate limiter e' un processo unico con nome __MODULE__, quindi ogni controllo di ogni utente passa da quella coda. Finche' il callback costa microsecondi va bene; sotto carico vero si divide per utente, o si sposta il conteggio in ETS, che e' leggibile in parallelo.

Un confronto fra i modi di gestire un limite di richieste in un servizio HTTP e' in rate limiting su API.

«Let it crash»: chi gestisce cosa

Il principio e' frainteso perche' il nome suona come una rinuncia. Dice invece una cosa precisa: gestisci gli errori che fanno parte del dominio, e lascia morire il processo per tutto il resto, perche' un processo morto viene sostituito da uno sano e un processo che prosegue con uno stato incoerente no.

La forma difensiva, e quella che si usa
# Difensivo: ogni chiamata incartata in un case, e un rescue finale
def process_order_difensivo(order) do
  try do
    case validate_order(order) do
      {:ok, valido} ->
        case save_to_db(valido) do
          {:ok, salvato} ->
            case send_confirmation(salvato) do
              {:ok, _} -> {:ok, salvato}
              {:error, e} -> handle_email_error(e)
            end
          {:error, e} -> handle_db_error(e)
        end
      {:error, e} -> handle_validation_error(e)
    end
  rescue
    e -> handle_unexpected_error(e)
  end
end

# Con `with`: il percorso felice e' in piano, e si gestisce solo l'atteso
def process_order(order) do
  with {:ok, valido}  <- validate_order(order),
       {:ok, salvato} <- save_to_db(valido),
       {:ok, _}       <- send_confirmation(salvato) do
    {:ok, salvato}
  else
    {:error, :invalid_data} = errore ->
      Logger.warning("ordine non valido: #{inspect(order)}")
      errore                      # errore di dominio: lo restituisco

    {:error, :duplicate_order} ->
      Logger.info("ordine duplicato ignorato: #{order.id}")
      {:ok, :duplicate}           # caso atteso: non e' un errore
  end
end

with e' la costruzione che rende praticabile il principio. Ogni riga con <- e' un match: se combacia, la variabile viene legata e si passa alla riga successiva; se non combacia, l'intero with salta al blocco else con quel valore. Il risultato e' che le tre operazioni in sequenza si leggono come una sequenza, invece di annidarsi di tre livelli come nella prima versione.

La differenza sostanziale fra i due blocchi non e' l'indentazione: e' che il primo ha un rescue finale, e il secondo no. Quel rescue cattura tutto, compresi gli errori che segnalano un bug - un MatchError, una clausola di funzione mancante - e li trasforma in un valore di ritorno. Il processo sopravvive con uno stato di cui non sai piu' nulla, e il difetto che lo ha causato non lascia traccia.

E' il compromesso che conta: il rescue compra disponibilita' immediata e paga in diagnosticabilita'. Un processo che sopravvive a un bug e' un processo che lo ripetera', in silenzio, finche' qualcuno non noti i dati sbagliati.

Nel secondo blocco l'else elenca solo due casi, ed e' voluto: sono i due che il dominio prevede. Se save_to_db restituisce {:error, :connection_refused}, nessuna clausola combacia e with solleva un WithClauseError: il processo muore, il supervisore lo riavvia con lo stato pulito, e nel frattempo la riga finisce nei log. Questa e' la delega, e funziona solo se sopra c'e' davvero un supervisore - senza albero di supervisione, «let it crash» e' solo «crash».

Nota il caso :duplicate_order, che restituisce {:ok, :duplicate}: un ordine duplicato non e' un errore, e' un esito previsto di una rete che ritenta. Distinguere le due cose e' la parte difficile del mestiere, piu' della sintassi. Il tema ha un nome, l'idempotenza, e un trattamento a parte in idempotenza dei consumer.

La linea di divisione, in pratica, cade cosi':

  • Dato in ingresso non valido: gestisci, viene da fuori e accadra'
  • Duplicato, risorsa non trovata, permesso negato: gestisci, sono esiti del dominio
  • Database irraggiungibile, servizio esterno giu': lascia propagare, non e' il tuo processo a poter rimediare
  • MatchError, FunctionClauseError, ArithmeticError: lascia propagare, sono bug e vanno visti
  • «Non dovrebbe mai accadere»: lascia propagare, soprattutto in questo caso

Il criterio unico sotto le cinque righe e' questo: gestisci cio' su cui puoi decidere qualcosa di sensato, lascia morire il resto. Le prime due hanno una risposta da dare al chiamante; le ultime tre no, e fingere di averla significa restituire un valore inventato.

La riga piu' importante e' l'ultima, perche' e' quella che si e' piu' tentati di coprire con un rescue: se una situazione non dovrebbe mai accadere e accade, il tuo modello della realta' e' sbagliato, e la cosa peggiore e' continuare come se nulla fosse.

Molte istanze dello stesso server: Registry

Finora i server avevano un nome fisso, un atom, e quindi esistevano in un'unica copia. Va bene per un rate limiter globale, non per qualcosa che esiste per utente. Il nome non puo' essere un atom - fra l'altro gli atom non vengono liberati dal garbage collector, quindi generarli da dati utente e' un modo lento di esaurire la memoria - e serve un registro.

Un GenServer per utente, con {:via, Registry, ...}
defmodule MyApp.UserSession do
  use GenServer

  def start_link(user_id) do
    GenServer.start_link(__MODULE__, %{user_id: user_id}, name: via(user_id))
  end

  # La chiave e' un termine qualunque: nessun atom creato a runtime
  defp via(user_id), do: {:via, Registry, {MyApp.SessionRegistry, {:session, user_id}}}

  def get_session(user_id), do: GenServer.call(via(user_id), :get)
  def update(user_id, campi), do: GenServer.cast(via(user_id), {:update, campi})

  @impl true
  def init(state), do: {:ok, state}

  @impl true
  def handle_call(:get, _from, state), do: {:reply, state, state}

  @impl true
  def handle_cast({:update, campi}, state), do: {:noreply, Map.merge(state, campi)}
end

{:via, Registry, {NomeRegistro, chiave}} sostituisce l'atom come nome del processo. Da quel momento GenServer.call accetta quella tupla al posto di un PID, e il Registry fa da rubrica: traduce la chiave nel PID attuale. Il guadagno non e' solo poter avere molte istanze, e' che il chiamante non deve conservare il PID - che cambia a ogni riavvio del processo - ma solo la chiave, che non cambia.

La funzione privata via/1 esiste per non ripetere quella tupla in quattro punti. Se la scrivessi a mano in ogni funzione della client API, un giorno cambieresti il nome del registro in tre posti su quattro, e il quarto cercherebbe in una rubrica che non esiste. La chiave qui e' la tupla {:session, user_id} e non una stringa interpolata: evita di collidere con altre famiglie di processi che usano lo stesso registro.

Registry e DynamicSupervisor nell'albero dell'applicazione
defmodule MyApp.Application do
  use Application

  @impl true
  def start(_type, _args) do
    children = [
      # keys: :unique -> una sola istanza per chiave
      {Registry, keys: :unique, name: MyApp.SessionRegistry},

      # I figli non sono noti all'avvio: si creano quando un utente entra
      {DynamicSupervisor, name: MyApp.SessionSupervisor, strategy: :one_for_one}
    ]

    Supervisor.start_link(children, strategy: :one_for_one, name: MyApp.Supervisor)
  end
end

defmodule MyApp.SessionManager do
  def start_session(user_id) do
    DynamicSupervisor.start_child(MyApp.SessionSupervisor, {MyApp.UserSession, user_id})
  end

  def stop_session(user_id) do
    case Registry.lookup(MyApp.SessionRegistry, {:session, user_id}) do
      [{pid, _}] -> DynamicSupervisor.terminate_child(MyApp.SessionSupervisor, pid)
      [] -> {:error, :not_found}
    end
  end

  def active_sessions, do: DynamicSupervisor.which_children(MyApp.SessionSupervisor) |> length()
end

L'ordine dei children e' significativo: il Registry viene prima del DynamicSupervisor perche' un UserSession che parte deve trovare il registro gia' in piedi per registrarsi. Supervisor avvia i figli nell'ordine dichiarato e, con strategy: :one_for_one, riavvia solo quello che cade.

Invertendo i due, una sessione che parte nel primo istante troverebbe MyApp.SessionRegistry non ancora avviato e morirebbe sul Registry.register.

DynamicSupervisor e' la variante per i figli che non conosci all'avvio. Un Supervisor normale riceve l'elenco dei figli e li avvia tutti; qui le sessioni nascono quando un utente entra, quindi l'elenco e' vuoto all'inizio e cresce con start_child/2. keys: :unique nel Registry e' cio' che garantisce una sola sessione per utente: con keys: :duplicate la stessa chiave accetterebbe piu' processi e GenServer.call sulla tupla :via non saprebbe a chi parlare.

In stop_session/1, Registry.lookup/2 restituisce una lista, non un valore singolo: la lista vuota e' il caso «sessione non attiva», ed e' quello che il case gestisce. Dare per scontato che ci sia sempre un elemento - scrivendo [{pid, _}] = Registry.lookup(...) - produrrebbe un MatchError ogni volta che qualcuno chiude una sessione gia' chiusa, cioe' di continuo.

Testare un GenServer senza dormire

Un GenServer e' un processo, quindi i suoi test hanno un problema in piu' dei test di una funzione pura: lo stato sopravvive fra un test e l'altro. La soluzione e' non dargli un nome fisso nei test e avviarne uno nuovo per ciascuno.

test/my_app/counter_test.exs
defmodule MyApp.CounterTest do
  use ExUnit.Case, async: true

  setup do
    # Nessun `name:`: il server e' raggiungibile solo per PID,
    # quindi i test in parallelo non si disturbano
    {:ok, pid} = GenServer.start_link(MyApp.Counter, 0)
    {:ok, counter: pid}
  end

  test "parte da zero", %{counter: pid} do
    assert GenServer.call(pid, :get_value) == 0
  end

  test "somma gli incrementi", %{counter: pid} do
    GenServer.call(pid, {:increment, 5})
    GenServer.call(pid, {:increment, 3})
    assert GenServer.call(pid, :get_value) == 8
  end

  test "il reset asincrono e' visibile alla call successiva", %{counter: pid} do
    GenServer.call(pid, {:increment, 10})
    GenServer.cast(pid, :reset)
    # Nessuno sleep: la call si accoda DOPO il cast dello stesso processo
    assert GenServer.call(pid, :get_value) == 0
  end

  test "cento incrementi concorrenti non si perdono", %{counter: pid} do
    1..100
    |> Enum.map(fn _ -> Task.async(fn -> GenServer.call(pid, {:increment, 1}) end) end)
    |> Enum.each(&Task.await/1)

    assert GenServer.call(pid, :get_value) == 100
  end
end

L'assenza di name: nel setup e' cio' che rende lecito async: true. Con un nome registrato, due test in parallelo proverebbero a registrare lo stesso atom e il secondo fallirebbe con :already_started; senza nome, ogni test ha il suo processo e il suo stato. E' la ragione tecnica per cui conviene che il nome sia un parametro di start_link, non una costante scritta nel modulo.

Il terzo test e' quello che vale la pena leggere due volte. Nella versione precedente aveva un :timer.sleep(10) fra il cast e l'assert, e passava; qui il sonno non c'e' e passa comunque, perche' la call finale si accoda dietro al cast partito dallo stesso processo di test.

Un test con uno sleep dentro e' lento quando funziona e intermittente quando la macchina e' carica: la sincronizzazione va ottenuta dal protocollo, non dal cronometro.

L'ultimo test dimostra la proprieta' da cui siamo partiti. Cento processi chiamano insieme {:increment, 1} e il totale e' esattamente 100, senza una riga di codice di sincronizzazione: il GenServer serve una richiesta per volta. In un linguaggio con memoria condivisa lo stesso test richiederebbe un lock o un intero atomico, e scritto male perderebbe incrementi.

Che cosa sai fare adesso

Sai scegliere fra call e cast guardando se il chiamante ha bisogno della risposta, tenere uno stato strutturato con una struct, far nascere un processo per chiave con Registry e DynamicSupervisor, e distinguere gli errori da gestire da quelli da lasciar propagare. Sai anche due cose che costano caro a chi non le sa: la call scade dopo cinque secondi, e terminate/2 non e' garantita.

I fondamentali del linguaggio su cui tutto questo si appoggia - pattern matching, pipe, immutabilita' - sono in Elixir da zero; il modello di processi e messaggi che il GenServer incapsula e' in BEAM e processi. Sul limite che resta aperto, cioe' la coda unica davanti a un processo, il problema generale e il suo vocabolario sono trattati in backpressure e controllo di flusso.

Fonti

  • Elixir - behaviour GenServer - firme e valori di ritorno ammessi dei callback, il timeout predefinito di 5000 ms di call/2, le condizioni in cui terminate/2 non viene invocata, e GenServer.reply/2 per la risposta differita. Consultata il 2026-11-08.
  • Elixir - Registry - la semantica di keys: :unique contro :duplicate, la forma della tupla {:via, Registry, {registro, chiave}} e il fatto che lookup/2 restituisce una lista. Consultata il 2026-11-08.
  • Elixir - DynamicSupervisor - differenza rispetto a Supervisor per i figli avviati a runtime, e start_child/2. Consultata il 2026-11-08.
  • Erlang/OTP - Design Principles - la definizione di behaviour, gli alberi di supervisione e le strategie di riavvio, cioe' il contesto che rende sensato «let it crash». Consultata il 2026-11-08.
  • Joe Armstrong, «Making reliable distributed systems in the presence of software errors» (2003) - l'origine della separazione fra codice di business e codice di recupero, da cui discende il principio. Consultato il 2026-11-08.

Le prime tre sono la documentazione dei moduli usati nel codice, e sono la fonte da consultare per qualunque dubbio su una firma, perche' seguono la versione del linguaggio. Le ultime due servono a una domanda diversa: non «come si scrive» ma «perche' si scrive cosi'». La quarta e' quella da leggere se un giorno ti trovi a dover difendere la scelta di non intercettare un errore, perche' e' il documento che la fonda.

Una precisazione sui numeri di questo articolo: il solo valore quantitativo che compare e' il timeout di 5000 ms, e viene dalla documentazione citata sopra. L'affermazione che i tre callback «coprono il 95% dei casi d'uso», presente nella versione precedente, e' stata rimossa: non ha una misura dietro, e nessuno ha contato quei casi.

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