Programmazione Concorrente e Distribuita — Prof. Alessandro Ricci

Attori, Timer, Cluster e Algoritmi Distribuiti

2026-05-11 133 min Module 3.2 + 4.2 registrazione originale

In questa lezione

1. Timer in Akka: Behaviors.withTimers

La 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.

Idea chiave

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.

Nota del redattore

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.

Timer e contesto applicativo

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.

Per l'esame

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).

2. startSingleTimer, FixedRate e strategie di scheduling

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

Il parametro key: perche serve

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).

Attenzione

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

3. Stash e Timer: la combinazione vincente

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.

Idea chiave

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.

4. Dal singolo nodo al cluster: membership e gossip

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.

Seed nodes e bootstrap

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:

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.

Attenzione

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.

Gossip protocol

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

5. Split-brain resilience e partizionamento

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".

Il problema della consistenza

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.

Idea chiave

La strategia di default in Akka Cluster e down una delle due partizioni (graceful degradation) piuttosto che tenerle entrambe vive con stati divergenti.

Strategie di risoluzione

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.

Per l'esame

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.

3+1Seed node piu 3 nodi normali
0Split 3-1: la maggioranza (3) sopravvive
2-2Parita: tie-breaking deterministico
1Una sola partizione resta attiva

6. Comunicazione remota: ActorRef, path e serializzazione

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.

Come ottenere un ActorRef remoto

Il professore ha elencato tre modi per ottenere un ActorRef di un attore su un nodo remoto:

  1. Incluso in un messaggio: un attore puo inviare il proprio ctx.self a un attore remoto, che cosi ottiene un ref per rispondere. Questo e il modo piu comune.
  2. Tramite path: se si conosce il path assoluto dell'attore (es. akka://system@host:port/user/actorName), si puo chiedere al sistema di risolverlo. Tuttavia questo approccio e fragile perche il path puo cambiare.
  3. Tramite receptionist: il meccanismo consigliato per il bootstrap, che vedremo nella prossima sezione.

Serializzazione

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.

Attenzione

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.

7. Receptionist: service discovery nel cluster

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.

register, find e subscribe

Il receptionist offre tre operazioni fondamentali:

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)
  // ...
}
Per l'esame

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.

8. Cluster sharding: distribuire attori su piu nodi

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.

L'esempio del contatore

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:

  1. Si definisce una Sharding con una EntityTypeKey che identifica il protocollo (i messaggi che l'attore sa gestire).
  2. Si inizializza il ClusterSharding con l'entity factory (come creare l'attore quando serve).
  3. Per parlare con un attore shardato, si usa una EntityRef ottenuta con sharding.entityRefFor(typeKey, entityId).
  4. L'entityId determina su quale shard (e quindi su quale nodo) l'attore viene creato.
// 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.

Nota del redattore

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.

9. Il modello Actor: fondamenti teorici

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).

L'idea fondante

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.

Idea chiave

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.

Attori nel mainstream moderno

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'
  1. 1973 Hewitt introduce il modello Actor in "A Universal Modular ACTOR Formalism"
  2. 1977 "Viewing Control Structures as Patterns of Passing Messages"
  3. 1986 Agha formalizza il modello nel libro "Actors: A Model of Concurrent Computation in Distributed Systems"
  4. 1987 Nasce Erlang in Ericsson per applicazioni di telecomunicazione
  5. 1990 "Concurrent Object-Oriented Programming" di Agha e Yonezawa
  6. 2009 Akka: framework actor industrial-grade per JVM
  7. 2010s Actor model adottato in Web Workers, Dart, e sistemi cloud-native

10. Primitive e semantica: send, create, become, macro-step

Le tre primitive fondamentali

Il comportamento di un attore si compone utilizzando solo tre primitive (azioni):

sendInvia un msg asincrono a un attore
createCrea un nuovo attore con un dato behavior
becomeCambia il behavior per il prossimo messaggio

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.

Semantica macro-step (run-to-completion)

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.

Altre proprieta chiave

Per l'esame

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.

11. Attori in pratica: Erlang, Akka, ActorFoundry

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.

Explicit vs Implicit receive

Una differenza fondamentale tra i framework e come gestiscono il message loop:

12. Asynchronous spaghetti, stashing e pattern di messaggistica

Il problema dell'asynchronous spaghetti

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:

  1. Inviare x a sinActor e y a cosActor.
  2. Ricevere il risultato da sinActor (handler 1).
  3. Ricevere il risultato da cosActor (handler 2).
  4. Moltiplicare i due risultati (handler 3).

La logica e spezzata in tre handler separati, senza una struttura sequenziale evidente. La soluzione moderna sono i Future/Promise pipelines.

Stashing come soluzione Akka

Akka introduce lo stash come meccanismo per gestire messaggi che arrivano in momenti inappropriati. Lo stash e una coda implicita separata dalla 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()
  }
}

Futures e messaggistica RPC-like

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.).

13. Mutua esclusione centralizzata con coordinator

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.

Proprieta della sezione critica distribuita

Algoritmo centralizzato

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:

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.

Idea chiave

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.

Per l'esame

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.

14. Ricart-Agrawala: mutua esclusione decentralizzata

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.

L'idea

  1. Per richiedere la CS, un processo Pi imposta myts al proprio clock logico e invia un messaggio (req, myts) a tutti gli altri processi.
  2. Alla ricezione di una richiesta da Pj, un processo Pi risponde con OK se:
    • non e interessato a entrare in CS (myts == infinity), oppure
    • la propria richiesta ha timestamp maggiore (quindi e successiva).
    Altrimenti, accoda Pj nella propria pendingQ.
  3. Un processo ottiene la CS quando ha ricevuto OK da tutti gli altri processi.
  4. Nel rilasciare la CS, invia OK a tutti i processi in pendingQ e resetta myts a infinity.
Premi "Passo successivo" per eseguire Ricart-Agrawala con 3 processi passo-passo.

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).

15. Leader election: Chang-Roberts

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.

Funzionamento

L'idea e semplice: il processo con il PID piu alto viene eletto leader. L'algoritmo funziona cosi:

  1. Uno o piu processi possono svegliarsi spontaneamente e iniziare un'elezione.
  2. Un processo che si sveglia invia un messaggio (election, myId) al vicino sinistro sull'anello e si marca come awake.
  3. Alla ricezione di un messaggio di elezione:
    • Se j > myid: inoltra il messaggio (il candidato e piu forte).
    • Se j == myid: il messaggio ha fatto tutto il giro e sono io il leader: invio (leader, myid).
    • Se j < myid e non sono ancora awake: inizio una nuova elezione con il mio ID.
    • Se j < myid e sono gia awake: inghiotto il messaggio (lo scarto).
  4. Alla ricezione di (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).

16. Chandy-Lamport: global snapshot

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.

Consistent cut

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.

Meccanismo dei marker

L'algoritmo usa un meccanismo di marker per coordinare la cattura dello snapshot:

  1. Tutti i processi sono inizialmente white. Un processo puo avviare lo snapshot in qualsiasi momento.
  2. Quando un processo diventa red:
    1. Salva il proprio stato locale.
    2. Invia un marker su tutti i canali in uscita (prima di qualsiasi altro messaggio).
  3. Alla ricezione di un marker su un canale j:
    • Se sono ancora white: divento red (salvo stato, invio marker).
    • Segno il canale j come chiuso (ho ricevuto il marker).
  4. I messaggi ricevuti su un canale dopo aver ricevuto il marker (ma prima di chiuderlo) vengono registrati come "in transito" e fanno parte dello stato del canale.
Idea chiave

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.

17. Message ordering: causale e totale

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.

FIFO ordering

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.

Causal ordering

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

Total order

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.

18. Consensus: FLP, Byzantine, Paxos e Raft

Il problema del consenso

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:

FLP Impossibility

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.

Il risultato FLP

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.

Algoritmo base in sistema sincrono

Se assumiamo un sistema sincrono (limite superiore noto per ritardo messaggi e durata azioni), il consenso e possibile con un semplice algoritmo a round:

Numero di messaggi: O((f+1)N^2).

Byzantine faults

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 e Raft

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

Replicated State Machines

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.

Per l'esame

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.

Verifica le tue conoscenze

Quale factory method di Akka fornisce l'accesso al TimerScheduler?

Behaviors.withTimers. Prende una funzione (ActorContext[T], TimerScheduler[T]) => Behavior[T] e restituisce un behavior che ha accesso allo scheduler dei timer.

Differenza tra 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.

Perche in un cluster Akka la strategia di default per lo split-brain e downare una partizione invece di tenerle entrambe vive?

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.

Cosa garantisce l'ordinamento causale dei messaggi? In cosa si differenzia dal FIFO?

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.

In che modo il Chandy-Lamport snapshot algorithm garantisce che gli stati locali siano mutuamente concorrenti?

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.

Cosa afferma il risultato FLP (Fischer-Lynch-Patterson)?

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.

Quanti messaggi servono per l'algoritmo Ricart-Agrawala? E per Chang-Roberts (caso peggiore)?

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.

Qual e la differenza tra find e subscribe nel receptionist Akka?

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.

In che senso l'attore e "puramente reattivo" e come risolve il problema della pro-attivita?

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.