Behaviors.withTimers, startSingleTimer, startTimerAtFixedRateLa lezione di oggi inizia con un argomento pratico: i timer in Akka. Nella lezione precedente avevamo introdotto il modello actor e l'API base di Akka Typed, ma non avevamo fatto in tempo a parlare di come un attore possa compiere azioni in modo proattivo, non solo reagendo a messaggi ricevuti. L'attore puramente reattivo resta in attesa di un messaggio; se nessuno gli scrive, rimane bloccato. I timer risolvono questo problema: permettono a un attore di "svegliarsi" periodicamente o dopo un certo intervallo per compiere un'azione.
Gli attori sono entita puramente reattive: processano messaggi quando arrivano. I timer sono il meccanismo per introdurre comportamenti proattivi, ovvero azioni avviate dall'attore stesso in base allo scorrere del tempo.
Il costruttore fondamentale e Behaviors.withTimers. Questo factory method prende una funzione che riceve due parametri: il context dell'attore (come abbiamo visto nelle lezioni precedenti) e un oggetto timers di tipo TimerScheduler. Quest'ultimo e l'interfaccia attraverso cui programmiamo l'invio di messaggi futuri.
Ecco il codice mostrato a lezione: un attore che, una volta avviato, si autoschedula per ricevere un messaggio TIC ogni secondo.
La trascrizione mostra il codice in modo frammentario. Il frammento qui sopra ricostruisce il pattern standard di Akka Typed con Behaviors.withTimers e un messaggio TIC schedulato periodicamente. Il professore ha enfatizzato che l'import di scala.concurrent.duration._ e necessario per la sintassi 1.second.
L'idea centrale e che timerScheduler.startTimerAtFixedRate fa si che l'attore riceva automaticamente il messaggio TIC ogni intervallo specificato (nel caso del professore, 1 secondo). Il timer continua all'infinito fino a quando non viene cancellato esplicitamente o l'attore viene fermato.
Questo pattern e estremamente comune in molte applicazioni: heartbeats, polling periodico, timeout per richieste, refresh di cache, scheduling di job ricorrenti. Nel contesto dell'assignment del corso, il professore ha sottolineato che i timer saranno molto probabilmente necessari.
Il professore ha esplicitamente detto: "questa roba dei timer vi servira nell'assignment". Ricordate i tre metodi principali: startSingleTimer (un solo scatto), startTimerAtFixedRate (periodico, si riprogramma da solo), e la possibilita di cancellare un timer con timers.cancel(key).
Il TimerScheduler offre diverse varianti del metodo di scheduling, ciascuna pensata per un caso d'uso specifico:
| Metodo | Comportamento | Quando usarlo |
|---|---|---|
startSingleTimer(key, msg, delay) |
Invia msg una sola volta dopo delay |
Timeout, azioni dilazionate, retry |
startTimerAtFixedRate(key, msg, interval) |
Invia msg ripetutamente ogni interval |
Heartbeat, polling, tick periodici |
startTimerWithFixedDelay(key, msg, delay) |
Come fixed rate ma aspetta che il messaggio precedente sia processato | Task che non devono sovrapporsi |
Un aspetto importante emerso dalla lezione e il parametro key. La key e un Any: puo essere qualsiasi oggetto, e serve a disambiguare timer diversi. Se avete lo stesso identico messaggio da mandare ma volete due timer indipendenti, usate due chiavi diverse. In questo modo l'attore sa gestire piu timer concorrenti, ciascuno con la propria periodicita o scadenza. La key permette anche di cancellare selettivamente un timer: timers.cancel(chiave).
Se chiamate startSingleTimer con una chiave gia attiva, il timer precedente viene cancellato e sostituito dal nuovo. Questo comportamento e utile per implementare timeout estendibili (es. "ricevo un messaggio, riprogrammo il timeout").
flowchart LR
subgraph Attore
SM[TimerScheduler]
MB[Message Queue]
BH[Behavior]
end
SM -->|"startSingleTimer(k,msg,d)"| T1[Timer k: scatta dopo d]
SM -->|"startTimerAtFixedRate(k,msg,i)"| T2[Timer k: tic ogni i]
T1 -->|scaduto| MB
T2 -->|scaduto| MB
MB --> BH
Il professore ha dedicato un passaggio importante alla combinazione di stash e timer. Lo stash, introdotto nella lezione precedente, permette di accodare messaggi ricevuti mentre l'attore si trova in uno stato transitorio (ad esempio in attesa di una risposta), per poi riprocessarli in seguito con unstashAll.
La combinazione con i timer e particolarmente utile per gestire timeout nelle conversazioni. Supponiamo che un attore A invii una richiesta a un attore B e si metta in attesa della risposta. Durante l'attesa, A usa lo stash per accumulare gli altri messaggi. Con un timer singolo, A puo anche gestire il caso in cui B non risponda entro un certo tempo: allo scadere del timer, A puo decidere se riprovare, fallire, o tornare allo stato normale e processare i messaggi accumulati.
Usare stash + timer in combinazione risolve elegantemente due problemi contemporaneamente: (1) non perdere messaggi durante uno stato di attesa, (2) non rimanere bloccati in attesa per sempre grazie al timeout.
// Pattern: attore che attende una risposta con timeout e stash
Behaviors.withTimers { (ctx, timers) =>
Behaviors.withStash(100) { stash =>
Behaviors.receiveMessage {
case Richiesta(destinatario, payload) =>
destinatario ! Esegui(payload, ctx.self)
timers.startSingleTimer("timeout", TimeoutScattato, 5.second)
Behaviors.receiveMessage {
case RisultatoOk(data) =>
timers.cancel("timeout")
stash.unstashAll(comportamentoNormale(data))
case TimeoutScattato =>
stash.unstashAll(comportamentoFallito)
case altro =>
stash.stash(altro)
Behaviors.same
}
}
}
}
Nel pattern qui sopra, l'attore cambia behavior in attesa della risposta. Tutti i messaggi che arrivano durante l'attesa vengono stashati. Se arriva RisultatoOk, il timer viene cancellato e i messaggi accumulati vengono riprocessati. Se scatta il timeout, si passa a un behavior di fallimento e anch'esso processa i messaggi accumulati.
Dopo la parte introduttiva sui timer, il professore ha dedicato il nucleo centrale della lezione agli aspetti distribuiti di Akka. Passare da un singolo actor system locale a un cluster di piu nodi introduce complessita sostanziali: i nodi possono unirsi o lasciare il cluster, possono fallire senza preavviso, la rete puo partizionarsi. Akka gestisce tutto questo con un meccanismo di membership basato su gossip protocol.
Quando un nuovo nodo vuole unirsi al cluster, deve contattare uno o piu seed nodes. I seed sono nodi noti (tipicamente configurati tramite DNS o file di configurazione) che fungono da punti di contatto iniziali. Il professore ha mostrato due modalita:
Join al seed node, che lo inserisce nel cluster.I seed node sono consapevoli l'uno dell'altro: quando un seed si avvia, fa join del cluster da solo (oppure si mette in ascolto). In un setup Docker Compose (come quello preparato dal professore), i seed vengono referenziati tramite nome del servizio DNS interno.
Il professore ha raccomandato di non usare mai 127.0.0.1 negli indirizzi dei seed, perche in un contesto distribuito (anche simulato con Docker) ogni nodo vede se stesso su IP diversi. Usate i nomi simbolici dei servizi: docker-compose mette a disposizione un DNS interno.
Una volta che il cluster e formato, i nodi comunicano tra loro usando un gossip protocol per scambiarsi informazioni sullo stato del cluster: quali nodi sono vivi, quali sono morti, qual e lo stato corrente della membership. Il gossip e un protocollo epidemico: ogni nodo periodicamente condivide con un sottoinsieme di altri nodi tutto cio che sa. Con il tempo, l'informazione si propaga a tutto il cluster. Questo meccanismo e alla base del failure detection: se un nodo smette di fare gossip, gli altri lo marcano come unreachable.
flowchart TD
subgraph Cluster Akka
N1[Nodo 1] --- N2[Nodo 2]
N2 --- N3[Nodo 3]
N3 --- N4[Nodo 4]
N1 -.- N4
end
S1[Seed 1:2552] --> N1
S2[Seed 2:2553] --> N2
SN[Nuovo Nodo] -->|join| S1
SN -->|gossip| N2
SN -->|gossip| N3
Uno dei problemi piu insidiosi nei cluster distribuiti e lo split-brain: quando un nodo o un gruppo di nodi non riesce piu a comunicare con un'altra parte del cluster a causa di un guasto di rete, si creano due (o piu) partizioni che non si parlano. Dal punto di vista di ciascuna partizione, l'altra sembra "morta".
Se entrambe le partizioni continuano a operare indipendentemente, e poi la rete viene ripristinata, ci si trova con due stati divergenti dello stesso sistema. Questo e particolarmente grave se ci sono attori persistenti con stato: quale dei due stati va mantenuto? Non e banale, anzi a volte non si saprebbe neppure come scegliere.
La strategia di default in Akka Cluster e down una delle due partizioni (graceful degradation) piuttosto che tenerle entrambe vive con stati divergenti.
Akka implementa la strategia della partizione piu numerosa: il sottocluster con il maggior numero di nodi sopravvive, mentre l'altro viene spento. Ma cosa succede in caso di parita (es. 2-2 su 4 nodi)? Il professore ha spiegato che esiste un meccanismo di tie-breaking che rompe la simmetria, basato su un ordinamento deterministico (es. indirizzi dei nodi). In questo modo solo una delle due partizioni sopravvive.
Il detection della partizione funziona perche il gossip protocol mantiene in ogni nodo la consapevolezza di quanti nodi ci sono nel cluster totale. Quando un nodo si accorge di poter comunicare solo con un sottoinsieme, capisce di essere in una partizione e applica la strategia di downing.
Il professore ha sottolineato che la split-brain resilience e particolarmente critica se si usa la persistenza degli attori. Akka non replica automaticamente lo stato degli attori negli altri nodi. Se avete attori persistenti e si verifica uno split-brain, quando il cluster si ricompone potreste avere due versioni divergenti dello stesso attore. Ci sono strategie avanzate nella documentazione, ma vanno studiate e configurate esplicitamente.
In un contesto distribuito, gli attori comunicano sempre attraverso ActorRef, esattamente come in un contesto locale. La differenza e che l'ActorRef puo referenziare un attore che vive su un nodo diverso: quando inviamo un messaggio a quel ref, il sistema si occupa di serializzare il messaggio e spedirlo sulla rete al nodo giusto.
Il professore ha elencato tre modi per ottenere un ActorRef di un attore su un nodo remoto:
ctx.self a un attore remoto, che cosi ottiene un ref per rispondere. Questo e il modo piu comune.akka://system@host:port/user/actorName), si puo chiedere al sistema di risolverlo. Tuttavia questo approccio e fragile perche il path puo cambiare.Quando si invia un messaggio attraverso la rete, e necessario serializzarlo. Akka utilizza di default Jackson JSON per la serializzazione. Questo significa che tutti i messaggi scambiati tra attori in contesto distribuito devono essere (direttamente o indirettamente) serializzabili in JSON. Il professore ha accennato che non c'e molto altro da sapere se non che i messaggi custom vanno annotati o configurati per essere serializzabili correttamente.
Anche i messaggi che passano attraverso il receptionist devono essere serializzabili. Se usate tipi custom non standard, assicuratevi che Jackson possa serializzarli. Il professore ha detto che non e un problema nella pratica, ma va tenuto a mente.
Il receptionist e un attore di sistema che funge da registry distribuito: permette agli attori di registrarsi sotto una service key e ad altri attori di scoprirli. E la soluzione consigliata per il bootstrapping della comunicazione in un cluster Akka.
Il receptionist offre tre operazioni fondamentali:
Register: un attore si registra con una chiave. Il receptionist associa l'ActorRef dell'attore a quella chiave.Find: richiede una istantanea (snapshot) degli attori attualmente registrati per una data chiave. Utile quando il set degli attori e stabile.Subscribe: si sottoscrive per ricevere notifiche ogni volta che un attore si registra o si deregistra per quella chiave. Utile quando il set e dinamico.Il professore ha enfatizzato la differenza tra find e subscribe: la find da una risposta una tantum, mentre la subscribe continua a notificare l'attore ogni volta che ci sono cambiamenti. La scelta dipende dal caso d'uso. Nell'esempio mostrato a lezione, un attore manager usa subscribe per ricevere notifiche man mano che i worker si registrano (visto che i worker potrebbero non essere ancora partiti al momento della subscribe).
Ecco il pattern mostrato dal professore:
// Registrazione (lato worker)
ctx.system.receptionist ! Receptionist.Register(WorkerServiceKey, ctx.self)
// Sottoscrizione (lato manager)
ctx.system.receptionist ! Receptionist.Subscribe(WorkerServiceKey, ctx.self)
// L'attore manager gestisce Listing per ottenere i worker
Behaviors.receiveMessage {
case WorkerServiceKey.Listing(actors) =>
// actors contiene la lista aggiornata dei worker
comportamentoAttivo(actors)
// ...
}
Il receptionist e un ottimo modo per ottenere il primo contatto con attori remoti, ma il professore avverte: non sovraccaricatelo. Non e pensato per scalare a centinaia di migliaia di attori che fanno find/subscribe in continuazione. Usatelo per il bootstrap, non per il routing fine-grained.
Quando usarlo: il set degli attori e stabile (es. un pool di worker che viene creato all'avvio e non cambia).
Comportamento: il receptionist risponde una sola volta con la lista corrente degli ActorRef associati alla chiave.
Vantaggio: semplice, un solo scambio di messaggi.
Limite: se un worker si registra dopo la find, non viene visto.
Quando usarlo: il set degli attori cambia nel tempo (worker che partono/arrivano dinamicamente).
Comportamento: il receptionist notifica l'attore ogni volta che un worker si registra o si deregistra.
Vantaggio: sempre aggiornato, adatto a scenari dinamici.
Limite: l'attore deve gestire i messaggi di notifica. Leggero overhead aggiuntivo.
Il cluster sharding e il meccanismo che permette di distribuire un insieme di attori (detti entities) attraverso i nodi del cluster in modo trasparente. E ideale per attori con identita univoca (es. un attore per ogni utente, per ogni sessione, per ogni conto corrente) dove non ci interessa su quale nodo l'attore vive, ma vogliamo che ci sia una distribuzione bilanciata.
Il professore ha mostrato un esempio semplice: un attore Counter che mantiene un contatore interno e risponde a messaggi Increment e GetValue. Lo scopo e creare piu istanze di Counter distribuite automaticamente nel cluster.
Per usare lo sharding:
Sharding con una EntityTypeKey che identifica il protocollo (i messaggi che l'attore sa gestire).ClusterSharding con l'entity factory (come creare l'attore quando serve).EntityRef ottenuta con sharding.entityRefFor(typeKey, entityId).// Inizializzazione dello sharding
val sharding = ClusterSharding(system)
val counterTypeKey = EntityTypeKey[Counter.Command]("counter")
val shard = sharding.init(
Entity(counterTypeKey, createBehavior = ctx => Counter())
)
// Ottenere un EntityRef e inviare un messaggio
val counter = sharding.entityRefFor(counterTypeKey, entityId = Random.nextInt(3).toString)
counter ! Counter.Increment
In questo esempio, l'entityId e un numero casuale tra 1 e 3: questo fa si che gli attori counter vengano distribuiti su diversi nodi del cluster (in base alla funzione di hash dell'entityId). Il professore ha mostrato in diretta i log che evidenziano come i vari counter vengono creati su nodi diversi.
Il cluster sharding e strettamente legato alla split-brain resilience: se un nodo muore, le entity che vivevano su quel nodo vengono ricreate su un altro nodo dal meccanismo di sharding. Tuttavia, per attori persistenti, il recupero dello stato richiede un'attenta configurazione.
Dopo la parte pratica su Akka Cluster, il professore ha spostato l'attenzione sulle basi teoriche del modello Actor, attingendo al modulo 3.2 del corso. Il modello Actor e stato originariamente introdotto da Carl Hewitt e colleghi al MIT negli anni '70, in un contesto di ricerca sull'Intelligenza Artificiale. Successivamente, Gul Agha e Akinori Yonezawa negli anni '80 e '90 lo hanno sviluppato come unificazione tra OOP e concorrenza (Concurrent Object-Oriented Programming).
Il modello Actor e una teoria matematica che tratta gli attori come i primitivi universali della computazione concorrente digitale. L'idea centrale e che tutto e un attore: ogni entita computazionale e un attore con un identificatore univoco e una mailbox (coda di messaggi). Ogni interazione avviene esclusivamente tramite scambio asincrono di messaggi. Questo e fortemente legato all'idea originale di OOP di Alan Kay, dove il punto fondamentale era il message passing.
Il modello Actor e strettamente legato all'OOP originale di Alan Kay: "The key point was message passing". Un attore e un oggetto che incapsula stato, comportamento e un flusso di controllo logico. Gli oggetti classici non incapsulano il flusso di controllo.
Nonostante le origini accademiche, il modello Actor ha trovato ampia adozione industriale: Erlang (telecomunicazioni), Akka/Scala (JVM), HTML5 Web Workers, Dart isolates. E considerato un'alternativa valida al multithreading tradizionale per costruire sistemi concorrenti scalabili.
flowchart LR
A1[Attore 1] -->|send msg| A2[Attore 2]
A2 -->|send reply| A1
A1 -->|create| A3[Attore 3]
A3 -->|become| A3'
Il comportamento di un attore si compone utilizzando solo tre primitive (azioni):
Send e per la programmazione concorrente cio che l'invocazione di procedura e per la programmazione sequenziale: il mattoncino fondamentale. Create e l'analogo dell'astrazione di procedura: permette di definire nuovi attori. Become permette all'attore di cambiare il proprio comportamento (e quindi il proprio stato locale) per il messaggio successivo, dando agli attori una history-sensitive behaviour necessaria per oggetti mutabili condivisi.
Uno degli aspetti piu importanti del modello Actor e la semantica macro-step o run-to-completion: un attore, quando riceve un messaggio, esegue il corrispondente handler nella sua interezza prima di passare al messaggio successivo. Questo evita race condition e semplifica il ragionamento sui programmi. Tuttavia, il professore ha sottolineato che questo complica la programmazione: nasce il problema dell'asynchronous spaghetti che vedremo piu avanti.
La location transparency e il pilastro che permette ad Akka Cluster di funzionare: un ActorRef si comporta allo stesso modo sia che l'attore sia locale o remoto. Il professore ha sottolineato piu volte questo concetto durante la lezione.
Il modello Actor e stato implementato in molti linguaggi e framework. Le slides del professore confrontano tre implementazioni significative: Erlang (linguaggio nativo), Akka (framework JVM), e ActorFoundry (framework accademico Java).
Erlang e un linguaggio funzionale sviluppato in Ericsson per applicazioni di telecomunicazione. La sua VM (BEAM) gestisce processi leggeri come entita logiche, non legate a thread OS. Un programma Erlang e un insieme di funzioni; i processi (attori) vengono creati con spawn, comunicano con ! (send) e ricevono con receive su pattern matching.
counter(Sum) ->
receive
{inc} ->
counter(Sum + 1);
{getValue, Pid} ->
Pid ! {count_value, Sum},
counter(Sum)
end.
Erlang e noto per l'estrema efficienza: centinaia di migliaia di processi su un singolo host.
Akka e un framework industriale per Java e Scala, che supersede la vecchia libreria Scala Actors. Offre attori tipati (Akka Typed), cluster, sharding, persistence, e un ricco ecosistema. Gli attori Akka sono definiti tramite Behaviors e ActorContext.
public class MyUntypedActor extends UntypedActor {
LoggingAdapter log = Logging.getLogger(getContext().system(), this);
public void onReceive(Object message) throws Exception {
if (message instanceof String) {
log.info("Received String message: {}", message);
getSender().tell("received");
} else unhandled(message);
}
}
Akka e la piattaforma di riferimento per il corso, ed e quella che il professore usa in tutti gli esempi pratici.
ActorFoundry e un framework accademico Java sviluppato dal gruppo di Agha all'Universita dell'Illinois. Usa annotazioni @message per definire gli handler. Supporta ottimizzazioni come Kilim e constraint di sincronizzazione locale.
public class PingActor extends Actor {
ActorName otherPinger;
@message
public void start(ActorName other) {
otherPinger = other;
send(otherPinger, "ping", self(), "...");
}
}
E principalmente usato per scopi di ricerca e didattici.
Una differenza fondamentale tra i framework e come gestiscono il message loop:
receive ... end. Piu flessibile ma anche piu verboso. Supporta la selective receive con guardie.Con la semantica macro-step (un messaggio alla volta, run-to-completion), la logica applicativa viene spezzata in una serie di handler (o callback) non strutturati, ciascuno dei quali reagisce a un evento diverso. Questo e il problema dell'asynchronous spaghetti citato dal professore rifacendosi a [RS-12].
Esempio dalle slide: un attore deve valutare l'espressione sin(x)*cos(y) delegando i calcoli a due attori specializzati (sinActor e cosActor). Con la semantica macro-step, l'attore principale deve:
La logica e spezzata in tre handler separati, senza una struttura sequenziale evidente. La soluzione moderna sono i Future/Promise pipelines.
Akka introduce lo stash come meccanismo per gestire messaggi che arrivano in momenti inappropriati. Lo stash e una coda implicita separata dalla mailbox principale:
stash(): accoda il messaggio corrente nello stash.unstashAll(): re-inserisce tutti i messaggi stashati nella mailbox principale.Il pattern classico e: in uno stato transitorio, i messaggi non gestibili vengono stashati; quando si arriva allo stato giusto (o scade un timer), si fa unstashAll() e si riprocessano.
// Pattern Akka: stash per gestire un protocollo a stati
Behaviors.receive { (ctx, msg) =>
msg match {
case "open" =>
unstashAll()
Behaviors.receiveMessage {
case "write" => // scrivi...
case "close" =>
unstashAll()
context.unbecome()
case _ => stash()
}
case _ => stash()
}
}
Il pattern Future e un idioms ricorrente: un attore-future fa da proxy per un risultato non ancora disponibile. Quando il valore viene calcolato, il future notifica tutti gli attori in attesa. Questo pattern e alla base della comunicazione RPC-like nel modello actor.
Altri pattern importanti menzionati nelle slide: Serializer, Fork-Join, e i vari Messaging Patterns da [VER16] (Message Channel, Publish-Subscribe, Command Message, Message Router, Content-based Filter, ecc.).
Il professore apre la seconda parte della lezione (modulo 4.2) introducendo i principali algoritmi distribuiti. Il primo problema affrontato e la mutua esclusione in ambiente distribuito, analogo al problema concorrente classico ma con processi che non condividono memoria ne clock.
Esiste un coordinatore (P0) che detiene il token. I processi client inviano richieste al coordinatore, che concede il token quando la richiesta e eligible.
La parte complessa e garantire la fairness. Poiche le richieste possono arrivare in ordine diverso dall'ordine causale, ogni processo include il proprio vector clock v nella richiesta. Il coordinatore mantiene due strutture dati:
reqList: lista delle richieste ricevute ma non ancora soddisfatte.reqDone: array che conta quante richieste di ogni processo sono state soddisfatte.Una richiesta w e eligible se per ogni processo j, il valore w.v[j] e uguale a reqDone[j] (oppure reqDone[j] + 1 se j == w.p). In pratica: non ci sono richieste accadute prima di w che non sono state ancora soddisfatte.
I processi piggybackano il proprio vector clock su tutti i messaggi in uscita. Quando un processo riceve un messaggio, aggiorna il proprio vector clock con il component-wise max. Cosi il vettore nella richiesta contiene la conoscenza causale del richiedente.
L'algoritmo centralizzato richiede 2 messaggi per richiesta (richiesta + token di risposta) piu i messaggi per propagare i vector clock. Il coordinatore e un single point of failure e puo diventare un collo di bottiglia.
L'algoritmo di Ricart-Agrawala (1981) e una soluzione decentralizzata: non esiste un coordinatore centrale. Ogni processo partecipa alla decisione scambiando messaggi timestampati. L'algoritmo richiede 2(N-1) messaggi per ogni accesso alla CS.
Pi imposta myts al proprio clock logico e invia un messaggio (req, myts) a tutti gli altri processi.Pj, un processo Pi risponde con OK se:
myts == infinity), oppurePj nella propria pendingQ.OK da tutti gli altri processi.OK a tutti i processi in pendingQ e resetta myts a infinity.L'algoritmo funziona anche con canali non FIFO, perche l'ordinamento e garantito dai timestamp. La fairness e assicurata dalla relazione d'ordine sui timestamp (a parita di timestamp, vince il PID piu basso).
Il problema dell'elezione del leader si pone quando un gruppo di processi distribuiti deve scegliere un coordinatore comune. L'algoritmo di Chang-Roberts assume che i processi siano disposti su un anello logico (sovrapposto alla rete fisica) e che ogni processo abbia un PID univoco.
L'idea e semplice: il processo con il PID piu alto viene eletto leader. L'algoritmo funziona cosi:
(election, myId) al vicino sinistro sull'anello e si marca come awake.j > myid: inoltra il messaggio (il candidato e piu forte).j == myid: il messaggio ha fatto tutto il giro e sono io il leader: invio (leader, myid).j < myid e non sono ancora awake: inizio una nuova elezione con il mio ID.j < myid e sono gia awake: inghiotto il messaggio (lo scarto).(leader, j): salvo il leader e inoltro il messaggio (se non sono io il leader).
flowchart LR
subgraph Anello PID
P1[P1] --> P2[P2]
P2 --> P3[P3]
P3 --> P4[P4]
P4 --> P1
end
P1 -.->|"election(1)"| P2
P2 -.->|"election(2)"| P3
P3 -.->|"election(3)"| P4
P4 -.->|"election(4) → leader(4)"| P1
Complessita: nel caso peggiore, l'elezione richiede 2N-1 messaggi di elezione e N messaggi di leader propagation. L'algoritmo e utilizzato in pratica per eleggere coordinatori in algoritmi centralizzati (il coordinatore della mutua esclusione, ad esempio).
L'algoritmo di Chandy-Lamport (1985) permette di catturare una istantanea globale consistente di un sistema distribuito, senza fermare i processi e senza perdere messaggi in transito. E uno dei risultati piu importanti e influenti nel campo dei sistemi distribuiti, alla base di molte tecniche di debugging e checkpointing.
Un taglio consistente (consistent cut) e un insieme di eventi che rispetta la relazione di causalita: se un evento e' (send) precede causalmente e (receive), e e e nel taglio, allora anche e' deve essere nel taglio. In altre parole, non si puo includere la ricezione di un messaggio senza includerne anche l'invio. Uno snapshot globale consistente e un taglio consistente degli stati locali di tutti i processi, piu lo stato dei canali di comunicazione.
L'algoritmo usa un meccanismo di marker per coordinare la cattura dello snapshot:
j:
j come chiuso (ho ricevuto il marker).I canali FIFO garantiscono che nessun messaggio inviato da un processo white possa arrivare dopo il marker a un processo red. Questo assicura che gli stati locali catturati siano mutuamente concorrenti.
Il risultato e un'istantanea globale che corrisponde allo stato del sistema "subito prima che i processi diventassero rossi". Questa istantanea e consistente e puo essere usata per rilevare deadlock, terminare computazioni, o come checkpoint per il recovery.
In un sistema completamente asincrono, non c'e alcuna restrizione sull'ordinamento dei messaggi: massimo non-determinismo, massima concorrenza. Tuttavia, in molte applicazioni, e utile ridurre il non-determinismo imponendo delle garanzie di ordinamento.
La forma piu debole: i messaggi inviati da uno stesso mittente a uno stesso destinatario vengono ricevuti nell'ordine di invio. Molti middleware di messaggistica garantiscono questo per default.
Un passo oltre: se send(m1) → send(m2) (m1 precede causalmente m2), allora rec(m1) → rec(m2) (m1 deve essere ricevuto prima di m2). Non ci sono sorpassi causali.
L'algoritmo per garantire l'ordinamento causale usa matrici di clock (estensione dei vector clock):
Pi mantiene una matrice m[1..N][1..N] dove m[j,k] conta i messaggi inviati da Pj a Pk noti a Pi.Pi invia a Pj, incrementa m[i,j] e allega la matrice al messaggio.+1).L'ordine totale e ancora piu forte: per messaggi multicast, tutti i processi vedono i messaggi nello stesso ordine. Questo richiede un coordinamento aggiuntivo e si basa su algoritmi come ISIS o sequencer centralizzato. E la base per i sistemi di replicated state machine.
Garanzia: i messaggi tra coppia (mittente, destinatario) arrivano in ordine di invio.
Costo: basso, spesso fornito dal protocollo di trasporto (TCP).
Limitazione: non dice nulla su messaggi da mittenti diversi o a destinatari diversi.
Garanzia: se invio di m1 precede causalmente invio di m2, allora m1 viene ricevuto prima di m2 ovunque.
Costo: matrice NxN di interi per processo. Buffer per messaggi non ancora eligible.
Utile per: applicazioni dove l'ordine causale e importante (es. aggiornamenti a un documento condiviso).
Garanzia: tutti i processi vedono tutti i messaggi multicast nello stesso ordine globale.
Costo: richiede un sequencer o un consenso distribuito.
Utile per: replicated state machine, database distribuiti, sistemi di consensus.
Il consenso e il problema fondamentale del distributed computing: un insieme di processi deve accordarsi su un valore, nonostante possibili fallimenti. E pervasivo nei sistemi reali: transazioni DB, leader election, state machine replication, atomic broadcast, clock synchronization, blockchain.
Le proprieta che un algoritmo di consenso deve soddisfare sono tre:
Il risultato piu sorprendente e importante: in un sistema asincrono, anche con un singolo processo che puo crashare, il consenso e impossibile da risolvere (Fischer, Lynch, Patterson, 1985). Non esiste alcun algoritmo deterministico che garantisca termination in queste condizioni. Questo risultato ha profonde implicazioni pratiche: tutti i sistemi reali fanno assunzioni di sincronia (timeout, failure detector) per aggirare l'impossibilita.
FLP dimostra che non esiste un algoritmo di consenso deterministico che sia contemporaneamente: tollerante a un singolo crash, funzionante in rete asincrona, e garantito terminare sempre. Almeno una di queste tre condizioni va rilassata.
Se assumiamo un sistema sincrono (limite superiore noto per ritardo messaggi e durata azioni), il consenso e possibile con un semplice algoritmo a round:
V, l'insieme dei valori conosciuti.f+1 round (dove f e il massimo numero di fallimenti tollerati):
V non ancora inviati.V con i valori ricevuti.decide(V) comune a tutti.Numero di messaggi: O((f+1)N^2).
Il caso piu estremo: i processi faulty possono avere comportamento arbitrario (byzantine), inviando valori sbagliati o contraddittori. Il problema e noto come Byzantine General Agreement (BGA).
Il risultato principale: non esiste un protocollo f-resiliente per BGA se N <= 3f. In pratica, servono almeno 3f+1 processi per tollerare f guasti byzantini. L'algoritmo procede per round con un coordinatore rotante (re).
Paxos (Lamport, 1998) e una famiglia di protocolli per il consenso in presenza di crash-stop. E il protocollo piu citato in letteratura, ma notoriamente difficile da comprendere. Raft (Ongaro & Ousterhout, 2013) e un'alternativa progettata per essere understandable: stessa tolleranza ai guasti di Paxos, ma con una struttura piu chiara basata su leader election, log replication e safety.
flowchart TD
Client -->|"set x=3"| Leader[Leader]
subgraph Raft Cluster
Leader -->|"AppendEntries"| F1[Follower]
Leader -->|"AppendEntries"| F2[Follower]
Leader -->|"AppendEntries"| F3[Follower]
end
F1 -->|"ACK"| Leader
F2 -->|"ACK"| Leader
F3 -->|"ACK"| Leader
Leader -->|"commit x=3"| Client
Il consenso e tipicamente usato per implementare macchine a stati replicate: ogni server ha una copia della macchina a stati e un log. Il consensus algorithm garantisce che tutti i server applichino gli stessi comandi nello stesso ordine. Se un server fallisce, la macchina a stati continua a funzionare sugli altri. Questa architettura e alla base di molti sistemi fault-tolerant: GFS, HDFS, RAMCloud, Chubby, ZooKeeper, etcd.
Il professore ha menzionato Paxos e Raft come "further explorations". Il risultato di FLP, le proprieta del consenso e la differenza tra crash-stop e byzantine sono argomenti chiave per l'esame.
TimerScheduler?Behaviors.withTimers. Prende una funzione (ActorContext[T], TimerScheduler[T]) => Behavior[T] e restituisce un behavior che ha accesso allo scheduler dei timer.
startTimerAtFixedRate e startTimerWithFixedDelay?startTimerAtFixedRate tenta di mantenere l'intervallo fisso tra gli inizi di ogni attivazione, indipendentemente da quanto dura l'elaborazione. startTimerWithFixedDelay aspetta che l'elaborazione del tick precedente sia terminata prima di contare il ritardo successivo, evitando sovrapposizioni.
Per evitare stati divergenti. Se entrambe le partizioni continuano a operare indipendentemente, quando la rete viene ripristinata ci sono due versioni dello stesso stato (es. attori persistenti) che non sono riconciliabili. E meglio far sopravvivere una sola partizione (la piu numerosa) e sacrificare l'altra.
L'ordinamento causale garantisce che se send(m1) → send(m2) (m1 precede causalmente m2), allora rec(m1) → rec(m2) (m1 viene ricevuto prima di m2 ovunque). Il FIFO garantisce solo l'ordine tra coppie mittente-destinatario; il causale gestisce anche messaggi che transitano attraverso processi intermedi.
Usando canali FIFO e il meccanismo dei marker. Un processo che riceve un marker su un canale FIFO si "tinge di rosso" e salva lo stato locale; poiche i canali sono FIFO, nessun messaggio inviato da un processo bianco puo arrivare dopo il marker a un processo rosso. Questo assicura che la ricezione di un messaggio non preceda causalmente il suo invio nello snapshot.
In un sistema asincrono, anche con un solo processo che puo crashare (unannounced process death), il problema del consenso e impossibile da risolvere deterministicamente. Non esiste un algoritmo che garantisca sempre termination, agreement e integrity in queste condizioni.
Ricart-Agrawala: 2(N-1) messaggi per accesso alla CS (N-1 richieste + N-1 OK). Chang-Roberts: nel caso peggiore 2N-1 messaggi di elezione + N messaggi di leader propagation.
Find restituisce una tantum la lista attuale degli attori registrati per una service key. Subscribe registra l'attore per ricevere notifiche ogni volta che la lista cambia (nuove registrazioni o deregistrazioni). Find e per set statici, subscribe per set dinamici.
Un attore lavora solo quando riceve un messaggio: senza messaggi, rimane bloccato (reattivo puro). La pro-attivita si ottiene tramite timer (self-scheduling di messaggi futuri) o tramite attori multipli che si coordinano. Il timer permette all'attore di "svegliarsi" autonomamente e compiere azioni pianificate.