Programmazione Concorrente e Distribuita — Prof. Alessandro Ricci, UNIBO

Attori e modelli a scambio di messaggi

2026-04-24109 min registrazione originale

In questa lezione

1. Dai thread agli attori: il percorso del corso

Il professor Ricci apre la lezione con un breve recap del percorso affrontato finora. Siamo arrivati alla terza parte del corso, che riguarda i modelli di programmazione concorrente basati sullo scambio di messaggi. L'assignment 3 riguarderà proprio questi modelli.

Thread, modello a memoria condivisa, mutua esclusione con semafori e monitor. L'interazione fra processi avviene attraverso meccanismi che condividono la memoria: variabili condivise protette da lock, semafori, monitor. È la fondazione classica della programmazione concorrente.

Approcci asincroni e ad eventi: programmazione reattiva, flussi di eventi, callback. Pur cambiando radicalmente lo stile di programmazione, resta un'idea di condivisione della memoria sottostante. Strumenti come i flussi reattivi e le promise pipeline facilitano la gestione della complessità.

Il modello a scambio di messaggi come alternativa radicale: processi (o attori) comunicano esclusivamente inviandosi messaggi, senza alcuna memoria condivisa. Da qui partiamo con i modelli a message passing puri, per arrivare al modello Actor di Hewitt e alle sue implementazioni moderne (Erlang, Akka, ActorFoundry).

La terza parte si divide in due moduli: il modulo 3.1 introduce i modelli di message passing classici (canali, send/receive, sincrono/asincrono, guarded communication), mentre il modulo 3.2 approfondisce il modello Actor, la sua semantica e le implementazioni in linguaggi come Erlang, Scala/Akka, e ActorFoundry.

2. Message passing: motivazioni e filosofia

Il message passing è un modello di comunicazione in cui l'unico modo per i processi di interagire è scambiarsi messaggi attraverso primitive di send e receive. Non sono permessi oggetti condivisi, monitor, lock, semafori, o qualsiasi altra astrazione di memoria condivisa.

Idea chiave

«Do not communicate by sharing memory; instead, share memory by communicating.» — Questo mantra, popolarizzato dal linguaggio Go ma radicato nel CSP di Hoare, riassume la filosofia del message passing.

Il modello nasce storicamente per la programmazione distribuita, dove i processi risiedono su nodi diversi e non hanno memoria condivisa. Tuttavia, con l'avvento del multi-core e del many-core, il message passing ha guadagnato terreno anche come alternativa ai meccanismi classici di sincronizzazione per la programmazione concorrente su singola macchina.

Attenzione

La programmazione concorrente con memoria condivisa è notoriamente difficile: race condition, deadlock, data race, complessità della sincronizzazione fine. Il message passing semplifica il ragionamento eliminando la memoria condivisa: ogni processo ha il proprio stato privato e l'unica interazione possibile è lo scambio di messaggi.

Shared MemoryMessage Passing
Thread/Lock/Semafori/MonitorSend/Receive/Canali/Attori
Stato condiviso e mutabileStato privato per processo
Race condition impliciteNessuna race (nessuna memoria condivisa)
Sincronizzazione esplicitaSincronizzazione tramite protocolli di messaggi
Difficile da scalare a sistemi distribuitiNaturale per sistemi distribuiti

Storicamente, il message passing asincrono fu introdotto da Brinch-Hansen nel 1970 per i sistemi operativi del computer RC4000. Bob Balzer nel 1971 introdusse la nozione di communication port, alla base del message passing asincrono moderno. La comunicazione sincrona fu introdotta da C.A.R. Hoare nel 1978 con il formalismo CSP (Communicating Sequential Processes). Parallelamente, nel 1973 Carl Hewitt introdusse il modello Actor, basato su message passing asincrono.

3. Canali e primitive fondamentali

Nel modello a message passing, la comunicazione avviene attraverso canali. Un canale è un'entità dichiarata globalmente che funge da mezzo di comunicazione fra processi. La dichiarazione di un canale specifica il nome, i tipi dei dati che trasporta, e opzionalmente la dimensione del buffer.

chan ch(type id1, ..., type idn)   // dichiarazione di canale
send ch(expr1, expr2, ...)          // invio messaggio
receive ch(var1, var2, ..., varn)  // ricezione messaggio

Le primitive fondamentali sono due:

Per l'esame

L'accesso al contenuto di ogni canale è atomico. I canali sono dichiarati globali ai processi. Questa atomicità garantisce che non ci siano race condition sull'operazione di send/receive a livello del canale stesso.

4. Comunicazione sincrona vs asincrona

Una distinzione fondamentale nei modelli a message passing riguarda la semantica della send:

Nella comunicazione sincrona, il processo chiamante si blocca sulla send fino a quando il messaggio non viene effettivamente ricevuto dal destinatario sullo stesso canale. Non c'è bisogno di buffer: lo scambio avviene quando entrambi i processi sono pronti (incontro, o rendez-vous). La comunicazione è vista come una azione atomica (inter-)azione fra due processi. È il modello più primitivo, usato nei process algebra come CSP.

Il trasferimento dati sul canale ch avviene solo quando il puntatore di controllo del mittente è sulla send e quello del destinatario è sulla receive. Se uno dei due arriva prima, si blocca in attesa dell'altro.

Nella comunicazione asincrona, i canali hanno un buffer FIFO dove i messaggi vengono accodati. La send ha successo immediatamente non appena il messaggio viene accodato nel buffer del canale. La receive si blocca solo se non ci sono messaggi disponibili nel canale.

Questo modello è più flessibile e disaccoppia temporalmente mittente e destinatario. In alcuni modelli e sistemi, i canali con message passing asincrono sono chiamati anche porte (ports).

La scelta fra sincrono e asincrono ha implicazioni profonde sulla semantica del programma concorrente. Il modello sincrono è più deterministico ma meno flessibile; quello asincrono è più espressivo ma introduce problemi di ordinamento e buffering. La maggior parte dei sistemi moderni adotta una forma di comunicazione asincrona, spesso con supporto per pattern di sincronizzazione opzionali.

5. Schemi di comunicazione e pattern

I canali possono essere utilizzati secondo diversi schemi di comunicazione:

SchemaDescrizioneTipico per
One-to-oneCanale usato solo da una coppia di processi (mittente e destinatario)Comunicazione sincrona (es. Occam/Transputer)
Many-to-manyPiù mittenti e più destinatari sullo stesso canaleCompetizione in ricezione, non-determinismo
Many-to-onePiù mittenti, un solo destinatario. Tipico con le porte (ports)Client-server, code di richieste

Il professore mostra un esempio classico con tre processi che comunicano attraverso canali distinti: P invia requestA a Q e requestB a R, poi riceve le risposte dai rispettivi canali di risposta, somma i risultati e li stampa. Questo pattern semplicissimo illustra come la comunicazione via canali permetta di orchestrare interazioni complesse in modo strutturato.

chan requestA(int value)
chan requestB(int value)
chan response(int value)

// Processo P
send requestA(5)
send requestB(6)
receive response(r1)
receive response(r2)
write(r1 + r2)

// Processo Q
receive requestA(r)
send response(r * 2)

// Processo R
receive requestB(r)
send response(r + 1)

Il classico problema del produttore-consumatore con un singolo canale è l'esempio più semplice di comunicazione many-to-one (o one-to-one): il produttore invia dati sul canale, il consumatore li riceve. Con semantica asincrona, il buffer del canale disaccoppia i due processi.

6. Client-server, active monitor e allocatori di risorse

Un pattern fondamentale nei sistemi a message passing è l'interazione client-server. Il server è un processo che resta in attesa di richieste su un canale di request e risponde su canali dedicati per ogni client.

chan request(int, kind, arg_type)
chan[NCLIENTS] reply(arg_result)

process Server {
  while (true) {
    receive request(clientID, kind, args)
    if (kind == op1) { /* corpo di op1 */ }
    else if (kind == opN) { /* corpo di opN */ }
    send reply[clientID](results)
  }
}

process Client[i = 0...N-1] {
  send request(i, opXXX, myargs)
  receive reply[i](myres)
}

Il server agisce come un active monitor: come un monitor classico, incapsula risorse e garantisce accesso mutuamente esclusivo (un messaggio alla volta viene elaborato), ma a differenza del monitor passivo, il server è un processo attivo con un suo flusso di controllo. È tipicamente usato per implementare allocatori di risorse.

La tabella di corrispondenza mostrata dal professore evidenzia l'analogia fra monitor passivi e active monitor basati su message passing:

Monitor (passivo)Processo Server (attivo)
Variabili permanentiVariabili locali del server
Identificatori di proceduraCanale di request e operazioni (kind)
Chiamata di procedurasend request(...) + receive reply(...)
Entry del monitorreceive request(...)
Ritorno dalla procedurasend reply(...)
Wait su condition variableSalvataggio richieste in coda pending
Signal su condition variablePreleva e processa richieste pending
Corpo delle procedureSwitch/case sul tipo di operazione

7. Guarded communication e receive selettiva

Il problema centrale: come fare a ricevere messaggi che possono arrivare su più canali contemporaneamente? La soluzione classica è la guarded communication, introdotta da Dijkstra nel 1974.

Il costrutto è:

B ; C → S

Dove B è una condizione booleana (guardia), C è un'operazione di comunicazione (tipicamente receive), e S è un blocco di codice. La guardia ha successo se B è vera e eseguire C non causerebbe blocco. La guardia fallisce se B è falsa. La guardia blocca se C non può ancora essere eseguita.

Il costrutto if con guarded communication permette di selezionare non-deterministicamente fra più alternative:

if  B1 ; C1 → S1;
[]  B2 ; C2 → S2;
[]  B3 ; C3 → S3;
fi
  1. Valuta le condizioni booleane nelle guardie
  2. Se tutte falliscono → l'if termina senza effetto
  3. Se almeno una ha successo → scegline una non-deterministicamente
  4. Se tutte bloccano → attendi finché una guardia non ha successo
  5. Esegui l'operazione C per la guardia scelta, poi esegui S

Il costrutto do ripete la selezione finché tutte le guardie falliscono:

do  B1 ; C1 → S1;
[]  B2 ; C2 → S2;
[]  B3 ; C3 → S3;
od
Idea chiave

La guarded communication separa what (quali messaggi sono processabili) da when (quando possono essere processati) — la condizione booleana esprime la disponibilità logica, la receive l'arrivo fisico del messaggio.

8. Bounded buffer con guarded communication

Un esempio concreto mostra come un processo BoundedBufferManager gestisca un buffer circolare condiviso usando guarded communication. Il processo ha due possibili operazioni: put (solo se buffer non pieno) e get (solo se buffer non vuoto).

process BoundedBufferManager {
  int nItems = 0;
  int maxElems = ...;
  Queue<ItemType> queue = ...;
  do nItems < maxElems; receive put(item, replyChan)
    → queue.add(item); nItems++; send replyChan(ack);
  [] nItems > 0; receive get(replyChan)
    → ItemType el = queue.remove(); nItems--; send replyChan(el);
  od
}

Il produttore e il consumatore interagiscono con il manager attraverso protocolli di richiesta-risposta:

process Producer(chan myChan){
  loop { ItemType el = produce(); send put(el, myChan); receive myChan(ack); }
}
process Consumer(chan myChan){
  loop { send get(myChan); receive myChan(el); consume(el); }
}

Il simulatore interattivo seguente permette di avanzare passo-passo il produttore e il consumatore, mostrando l'effetto delle guardie sull'evoluzione del sistema.

Nota del redattore

Nella simulazione, il Produttore produce un elemento e tenta di inviarlo. Se il buffer e' pieno (nItems = maxItems), la put viene bloccata. Il Consumatore richiede un elemento; se il buffer e' vuoto (nItems = 0), la get viene bloccata. La gestione dei canali di reply (ack) e' semplificata per chiarezza didattica.

9. Pattern peer-to-peer: tre soluzioni a confronto

Il professore presenta il problema dello scambio di valori: N processi, ciascuno con un valore intero locale v, devono determinare ciascuno il valore minimo e massimo fra tutti. Tre soluzioni diverse illustrano diversi trade-off fra numero di messaggi, grado di concorrenza, e centralizzazione.

Un coordinatore raccoglie tutti i valori, calcola min e max, e li distribuisce. Pochi messaggi (2N-2), ma il coordinatore diventa collo di bottiglia e la receive del coordinatore introduce latenza. Il coordinatore non puo' ricevere finche' non ha inviato tutti i risultati?

chan values(int), results[n](int, int)
process P[0] { // coordinatore
  int v = ..., smallest = v, largest = v;
  for i in [1...n-1] { receive values(new); ... }
  for i in [1...n-1] { send results[i](smallest, largest); }
}

Vantaggio: numero ridotto di messaggi. Svantaggio: bottleneck sul coordinatore.

Ogni processo invia il suo valore a tutti gli altri, poi calcola localmente min e max. Massimizza il parallelismo ma richiede N*(N-1) messaggi — fattibile solo per N piccolo.

chan values[n](int)
process P[i = 0 to n-1] {
  int v = ..., smallest = v, largest = v;
  for j in [0...n-1], j != i { send values[j](v); }
  for k in [1...n-1] { receive values[i](new); ... }
}

Vantaggio: massima distribuzione, parallelismo totale. Svantaggio: N^2 messaggi, non scala.

I processi sono organizzati in un anello logico. Ogni processo riceve dal predecessore e invia al successore. Due fasi: determinazione del min/max globale e propagazione del risultato. Numero ridotto di messaggi ma parallelismo limitato.

chan values(int), results[n](int, int)
process P[0] { // coordinatore
  send values[1](smallest, largest)
  receive values[0](smallest, largest)
  send values[1](smallest, largest)
}
process P[i = 1 to n-1] {
  receive values[i](smallest, largest)
  // aggiorna con il proprio v
  send values[(i+1)%n](smallest, largest)
  receive values[i](smallest, largest)
  send values[(i+1)%n](smallest, largest)
}

Vantaggio: pochi messaggi (lineare). Svantaggio: parallelismo sequenziale (si procede a onda).

Per l'esame

Il professore sottolinea l'importanza di saper analizzare e confrontare le tre soluzioni in termini di numero di messaggi scambiati, grado di parallelismo, e presenza di bottleneck. Questo tipo di analisi e' tipico delle domande d'esame.

10. Filosofi a cena con message passing

Il problema dei filosofi a cena in ambiente distribuito: ogni filosofo e' su un nodo diverso della rete, e le forchette (risorse) possono essere distribuite. Due soluzioni principali, entrambe basate su message passing.

Soluzione centralizzata: un Waiter globale

Un processo Waiter funge da allocatore centrale di forchette. I filosofi inviano richieste di getForks e releaseForks; il waiter mantiene una coda delle richieste pending e gestisce la deadlock avoidance.

chan getForks(int,int,chan)
chan releaseForks(int,int,chan)

process Waiter {
  boolean availForks[0..N-1] = {false,...};
  List<Request> pending = ...;
  do receive getForks(fork1,fork2,ReplyChanID)
    → if availForks[fork1] && availForks[fork2] ...
       else pending.add(...)
  [] receive releaseForks(fork1,fork2)
    → availForks[fork1]=true; availForks[fork2]=true;
       // controlla richieste pending
  od
}

Soluzione distribuita: un Waiter per forchetta

Ogni forchetta e' gestita da un proprio processo Waiter. I filosofi adottano una strategia di resource hierarchy (prendono prima la forchetta con indice minore) per evitare deadlock. I waiter sono piu' semplici (gestiscono una sola forchetta), ma il protocollo di coordinamento fra filosofi e waiter e' piu' articolato.

chan getFork[0..N-1], getForkReply[0..N-1]
chan releaseFork[0..N-1], releaseForkReply[0..N-1]

process Philosopher[i:0..N-1] {
  int first = i, second = (i+1)%N;
  if (second < first) { first = second; second = i; }
  loop {
    think();
    send getFork[first](); receive getForkReply[first]();
    send getFork[second](); receive getForkReply[second]();
    eat();
    send releaseFork[first](); receive releaseForkReply[first]();
    send releaseFork[second](); receive releaseForkReply[second]();
  }
}
Idea chiave

La soluzione distribuita elimina il collo di bottiglia del waiter centralizzato, ma richiede un protocollo piu' sofisticato per evitare deadlock. La resource hierarchy (ordinare le forchette per indice) e' la stessa strategia vista nel corso per la prevenzione dei deadlock.

11. Ada e il rendez-vous sincrono

Ada e' uno dei pochi linguaggi mainstream che offre il rendez-vous sincrono come primitiva di comunicazione nativa fra task. Un task chiama un entry su un altro task, e il chiamante si blocca fino a quando il task chiamato non accetta la chiamata.

Il rendez-vous estende la sincronizzazione base: non solo il mittente aspetta che il destinatario riceva, ma aspetta anche che il destinatario completi l'elaborazione e restituisca il risultato.

Attenzione

Il nome "rendez-vous" richiama l'immagine di due persone che si danno appuntamento: il primo che arriva deve aspettare l'arrivo del secondo. La comunicazione avviene solo quando entrambi sono pronti.

L'esempio mostrato dal professore e' un classico programma Ada con due task:

task Gourmet is
  entry Make_A_Hot_Dog;
end Gourmet;

task body Gourmet is
begin
  for Index in 1..4 loop
    accept Make_A_Hot_Dog do
      delay 0.8;
      Put_Line("Put hot dog in bun and add mustard");
    end Make_A_Hot_Dog;
  end loop;
end Gourmet;

-- Il chiamante:
for Index in 1..4 loop
  Gourmet.Make_A_Hot_Dog;  -- chiamata bloccante
  Put_Line("Eat the resulting hot dog");
end loop;

Il risultato dell'esecuzione mostra alternanza regolare: ogni chiamata al Gourmet si sincronizza con un accept, producendo l'output ordinato. Questo e' un esempio di sincronizzazione intrinseca garantita dal modello.

Mentre Ada e il CSP di Hoare adottano il modello sincrono, la maggior parte dei linguaggi e framework moderni preferisce il message passing asincrono, considerato piu' flessibile e scalabile. Il modello Actor, che vedremo nelle prossime sezioni, e' basato su comunicazione asincrona.

12. Il modello Actor: nascita e filosofia

Il modello Actor fu originariamente introdotto da Carl Hewitt e colleghi al MIT negli anni '70, nel contesto dell'intelligenza artificiale. Successivamente sviluppato da Gul Agha e Akinori Yonezawa negli anni '80 e '90 come unificazione fra OOP e concorrenza: la cosiddetta Concurrent Object-Oriented Programming.

  1. 1973 — Carl Hewitt introduce il modello Actor come teoria matematica per il calcolo concorrente
  2. 1977 — Hewitt pubblica "Viewing Control Structures as Patterns of Passing Messages"
  3. 1986 — Gul Agha pubblica la tesi "Actors: A Model of Concurrent Computation in Distributed Systems"
  4. 1987 — Yonezawa e Tokoro curano "Concurrent Object Oriented Programming" (MIT Press)
  5. 1990 — Agha pubblica su CACM: il modello Actor come unificazione di OOP e concorrenza
  6. 2000+ — Erlang, Scala/Akka, HTML5 Web Workers, DART isolates portano gli attori nel mainstream

Il modello Actor e' una teoria matematica che tratta gli "Attori" come le primitive universali del calcolo concorrente digitale. L'idea fondamentale: everything is an actor. Ogni entita' nel sistema e' un attore con un identificatore univoco e una mailbox (coda di messaggi).

Idea chiave

Il modello Actor e' fortemente legato all'idea originale di OOP secondo Alan Kay: il punto fondamentale dell'OOP non erano le classi o l'ereditarieta', ma il message passing. Gli oggetti sono entita' autonome che comunicano scambiandosi messaggi — esattamente come gli attori.

Oggi il modello Actor gioca un ruolo importante nel mainstream: Erlang per sistemi telecom estremamente robusti, Akka per applicazioni enterprise su JVM, HTML5 Web Workers per il parallellismo nel browser, DART isolates, e molti altri framework.

Nota del redattore

Lo state explorer illustra il ciclo di vita di un attore: dalla creazione all'attesa di messaggi (ATTIVO), all'esecuzione atomica del gestore (ELABORAZIONE), fino alla terminazione. Un attore e' puramente reattivo: puo' solo rispondere a messaggi, non iniziare azioni autonomamente.

13. Attori: le tre primitive e la semantica

Un attore e' un'entita' computazionale che incapsula uno stato, un comportamento e un flusso di controllo logico. A differenza degli oggetti classici dell'OOP, gli attori incapsulano anche il controllo: un oggetto normale non ha un flusso di controllo proprio (e' passivo), mentre un attore e' intrinsecamente attivo. Tuttavia, gli attori sono disaccoppiati dai thread fisici del sistema operativo: sono entita' logiche. Come i goroutine di Go, una piattaforma attori puo' gestire milioni di attori su pochi thread fisici.

Le tre primitive fondamentali per comporre il comportamento di un attore sono:

PrimitivaAzioneAnalogia
sendInvia asincronamente un messaggio a un attore specificatoEquivalente alla chiamata di procedura nella programmazione sequenziale
createCrea un attore con il comportamento specificatoEquivalente all'astrazione di procedura
becomeSpecifica un nuovo comportamento (e stato locale) per il prossimo messaggioDota l'attore di comportamento history-sensitive

L'attore puo' comunicare solo con attori di cui conosce l'identificatore. Gli identificatori possono essere scambiati nei messaggi, permettendo la creazione dinamica di topologie di comunicazione.

Semantica degli attori

La semantica del modello Actor si fonda su cinque aspetti chiave:

Attenzione

Nel modello di base, la send non da' alcuna garanzia sui tempi di consegna ne' sull'ordine di ricezione. Anche quando due send vengono eseguite in sequenza dallo stesso mittente, i messaggi potrebbero arrivare in ordine diverso al destinatario. Ogni forma di ordinamento deve essere realizzata tramite pattern di scambio messaggi.

14. Macro-step semantics, fairness e trasparenza

La macro-step semantics e' uno degli aspetti piu' importanti della semantica degli attori. Un handler (gestore di messaggio) viene eseguito completamente prima di ricevere il messaggio successivo. Questo implica che gli handler devono avere un comportamento non bloccante — l'unico punto di blocco e' gestito dal ciclo eventi dell'attore.

Le conseguenze della macro-step semantics sono profonde:

La macro-step semantics e' identica a quella delle architetture event-driven: GUI management, JavaScript con callback, Node.js. Tutte condividono lo stesso modello: un ciclo eventi che estrae un evento, esegue il gestore fino al completamento, poi passa al prossimo evento.

loop {
  msg <- waitForMsg()
  handler <- selectHandler(msg)
  execute(handler)  // run-to-completion
}

La fairness e' un altro punto cruciale. Hewitt voleva un modello efficace anche nei sistemi distribuiti, quindi stabilì che: se un attore invia un messaggio, questo sara' eventualmente recapitato alla mailbox del destinatario. Non c'e' garanzia sul tempo, ne' sull'ordine. Questa assunzione e' volutamente debole per essere realizzabile in ambienti distribuiti reali (diversamente da UDP, che non garantisce neppure la consegna).

Per l'esame

La fairness in message delivery e' un assunto fondamentale del modello Actor: un messaggio inviato viene eventualmente recapitato ed elaborato. Non ci sono garanzie temporali ne' di ordinamento. La frase "eventually delivered" e' un concetto chiave da ricordare.

La location transparency permette di inviare messaggi a un attore conoscendo solo la sua identita', senza sapere se risiede sullo stesso processo, sulla stessa macchina, o su un nodo remoto. Questo e' cio' che rende il modello Actor naturalmente adatto a sistemi distribuiti.

15. ActorFoundry: esempio concreto di attori in Java

ActorFoundry e' un framework accademico basato su Java, sviluppato dal gruppo di Gul Agha all'Universita' dell'Illinois. Include ottimizzazioni e idee da altri lavori di ricerca come Kilim. Mostra in modo chiaro e minimalista le primitive fondamentali del modello.

Il codice seguente mostra un classico ping-pong fra attori:

Analizziamo gli elementi chiave:

In PingBoot, l'attore di boot crea due attori PingActor e avvia il protocollo inviando a pinger1 il messaggio "start" con il riferimento a pinger2. I due attori si scambiano messaggi "ping" e "alive", stampando il tutto sullo stdout tramite l'attore standard stdout.

Nota del redattore

La notazione send(otherPinger, "ping", self(), ...) mostra la differenza fondamentale rispetto alla chiamata a metodo OOP: otherPinger.ping(...) sarebbe una chiamata bloccante con trasferimento del controllo; send(...) e' invece un invio asincrono di un dato nella mailbox del destinatario, senza trasferimento del flusso di controllo.

16. Akka: attori in Java e Scala

Akka e' il framework per attori piu' maturo a livello industriale per l'ecosistema JVM. Scritto in Scala ma utilizzabile da entrambi i linguaggi, offre un ricco set di funzionalita' tra cui tolleranza ai guasti stile Erlang, stashing, e pattern di supervisione.

import akka.actor.UntypedActor;
import akka.event.Logging;
import akka.event.LoggingAdapter;

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);
  }
}

L'attore estende UntypedActor e sovrascrive onReceive. Il metodo riceve un Object generico e usa pattern matching tramite instanceof per determinare il tipo di messaggio. getSender() restituisce il riferimento al mittente per poter rispondere.

import akka.actor.Actor
import akka.actor.Props
import akka.event.Logging

class MyActor extends Actor {
  val log = Logging(context.system, this)
  def receive = {
    case "test" => log.info("received test")
    case _      => log.info("received unknown message")
  }
}

La versione Scala e' piu' concisa grazie al pattern matching nativo del linguaggio. Il metodo receive definisce una partial function che matcha i messaggi in arrivo. context.system da' accesso al sistema attori. Il pattern matching e' esaustivo (il caso _ cattura tutti i messaggi sconosciuti).

Approfondimento

Akka implementa l'architettura a event loop incapsulato (encapsulated event loop): il programmatore definisce solo gli handler (come onReceive o receive), e il framework gestisce implicitamente il ciclo di attesa dei messaggi. L'attore e' quindi implicitamente reattivo — la reattivita' e' una proprieta' dell'architettura, non del codice scritto dal programmatore.

17. Erlang: processi, mailbox e pattern matching

Erlang e' probabilmente il linguaggio di programmazione concorrente piu' robusto e utilizzato dall'industria, insieme ad Ada. Sviluppato in Ericsson dal 1987 per costruire applicazioni telecom, si basa su un modello a processi leggeri (non thread OS) che comunicano via message passing asincrono.

Caratteristiche principali

Primitive fondamentali

La primitiva spawn crea un nuovo processo (attore) specificando modulo, nome funzione e parametri. Restituisce un PID (Process IDentifier) che identifica univocamente il processo.

Pid = spawn(fun () -> account(0) end).

L'operatore ! invia un messaggio a un processo: Pid ! Message. La receive selettiva estrae un messaggio dalla mailbox del processo usando pattern matching:

receive
  Pattern1 [ when Guard1 ] -> Expression1;
  Pattern2 [ when Guard2 ] -> Expression2;
  ...
end

Il processo si blocca finche' non trova un messaggio nella coda che matcha un pattern con guardia verificata.

Per l'esame

Il professore sottolinea che Erlang non e' un linguaggio accademico ma e' stato usato (ed e' ancora usato) per fare software molto robusto in ambito telecom. La BEAM e' una delle macchine virtuali concorrenti piu' collaudate al mondo.

18. Async spaghetti, future, stashing e LSC

La macro-step semantics semplifica il ragionamento sugli attori ma introduce un problema serio: il codice di un'applicazione logica viene frammentato in un insieme non strutturato di handler, ciascuno che reagisce a un evento specifico. Il professore chiama questo il problema degli "spaghetti asincroni".

L'esempio classico: un attore deve calcolare sin(x) * cos(y) delegando a due attori separati il calcolo di seno e coseno. Con la macro-step semantics, l'attore non puo' bloccarsi in attesa dei risultati. Deve:

  1. Inviare richieste a sin-actor e cos-actor
  2. Gestire la risposta di sin-actor in un handler
  3. Gestire la risposta di cos-actor in un altro handler
  4. Quando entrambi i risultati sono arrivati, calcolare il prodotto

Questo frammenta la logica in tre handler separati, perdendo la struttura sequenziale naturale dell'algoritmo. La letteratura [RS-12] documenta approfonditamente questo problema.

Soluzioni

Un future e' un proxy per un risultato inizialmente sconosciuto. Nell'Actor model, un future e' rappresentato da un attore con tre stati: senza valore e senza clienti in attesa; clienti in coda fino a quando il valore e' disponibile; valore disponibile che notifica i clienti in attesa.

Il future puo' essere passato ad altri attori prima di essere risolto, aumentando il parallelismo. Linguaggi come E e AmbientTalk offrono supporto nativo ai future/promise. Akka fornisce Future e Promise come pattern fondamentali.

Akka introduce il meccanismo di stashing: i messaggi ricevuti possono essere accantonati temporaneamente in una coda separata (stash) e recuperati successivamente (unstashAll). Questo permette di gestire protocolli con stati senza frammentare la logica.

class ActorWithProtocol extends Actor with Stash {
  def receive = {
    case "open" =>
      unstashAll()
      context.become({
        case "write" => // do writing...
        case "close" => unstashAll(); context.unbecome()
        case msg => stash()
      }, discardOld = false)
    case msg => stash()
  }
}

ActorFoundry implementa le Local Synchronization Constraints (LSC): condizioni di abilitazione/disabilitazione specificate per gli handler tramite annotazioni. Una condizione @Disable definisce quando un messaggio NON puo' essere processato; i messaggi disabilitati vengono accodati in una save queue per elaborazione successiva.

@Disable(messageName = "put")
public Boolean disablePut(Integer x) {
  if (bufferReady) { return (tail == bufferSize); }
  else return true;
}
Idea chiave

Il problema degli spaghetti asincroni colpisce piu' in generale tutte le architetture event-driven basate su callback: programmazione Web (JavaScript), GUI management, Node.js, e attori. Alcune soluzioni includono promise pipeline e costrutti linguistici che permettono di scrivere codice dall'aspetto sequenziale mantenendo l'elaborazione asincrona.

RPC-like messaging

Un pattern comune e' simulare chiamate RPC in un modello puramente asincrono. Il client invia una richiesta e poi controlla i messaggi in arrivo. Se arriva la risposta, la processa; altrimenti, bufferizza i messaggi non correlati per elaborazione futura. Questo protocollo e' implementabile con le primitive di base ma e' complesso — per questo i framework offrono supporto diretto, come la primitiva call in ActorFoundry.

19. Il problema della proattivita'

Gli attori sono entita' puramente reattive: lavorano solo quando ricevono un messaggio. Ma come si modella un comportamento proattivo, orientato al raggiungimento di obiettivi tramite un piano di azioni? Il professore dedica una parte significativa della lezione a questo problema, che ritiene concettualmente importante.

L'esempio concreto: un attore deve calcolare e stampare i primi 100 numeri, ma deve poter ricevere un messaggio stop che interrompe la stampa. Un attore puramente reattivo non puo' iniziare da solo la stampa — deve aspettare un messaggio che glielo richieda. La soluzione proposta dal professore e' il self-sending: l'attore invia messaggi a se stesso per procedere incrementalmente nel lavoro.

// Messaggio di start
send me, "iterat_print", i, end

// Handler per iterat_print
if (not stopped)
  print(i)
  if (i < end)
    send me, "iterat_print", i+1, end

// Handler per stop
stopped = true

Il pattern e' semplice ma profondo: ogni stampa e' un passo atomico gestito da un messaggio. Se arriva un messaggio stop prima del completamento, l'attore smette di auto-inviarsi messaggi e la stampa si ferma. L'attore rimane reattivo (ogni messaggio e' una reazione), ma l'auto-invio crea un effetto proattivo complessivo.

Nota del redattore

Il simulatore mostra l'alternanza fra il messaggio di auto-invio (che fa progredire la stampa) e la possibilita' di inserire uno stop in qualsiasi momento. Il professore sottolinea che questa e' una soluzione elegante ma parziale; la letteratura [RS-12] propone astrazioni piu' avanzate per integrare proattivita' e reattivita' a livello di linguaggio.

Il problema della proattivita' e' affrontato anche dalla ricerca: esistono proposte per estendere il modello Actor con astrazioni che permettano di specificare comportamenti proattivi (piani, goal) mantenendo la reattivita' come meccanismo di interazione. Tuttavia, nel modello di base, il self-sending rimane la tecnica piu' diffusa e pragmatica.

Verifica le tue conoscenze

Quali sono le tre primitive fondamentali del modello Actor?

send (invio asincrono di un messaggio a un attore), create (creazione di un nuovo attore con un comportamento specificato), become (specifica di un nuovo comportamento per il prossimo messaggio). Send e' analoga alla chiamata di procedura nella programmazione sequenziale; create all'astrazione di procedura; become fornisce comportamento history-sensitive.

Spiegate la differenza fra comunicazione sincrona e asincrona nel message passing.

Nella comunicazione sincrona, la send e' bloccante fino a quando il messaggio non viene ricevuto dal destinatario sullo stesso canale. Non servono buffer. Nella comunicazione asincrona, i canali hanno un buffer FIFO: la send ha successo immediato appena il messaggio viene accodato, mentre la receive si blocca solo se non ci sono messaggi. Il modello Actor usa comunicazione asincrona.

Cosa si intende per "macro-step semantics" nel modello Actor?

La macro-step semantics (run-to-completion) stabilisce che una volta ricevuto un messaggio, il corrispondente handler viene eseguito completamente prima di servire il messaggio successivo. Questo elimina le race condition sullo stato interno dell'attore, ma complica la programmazione perche' un handler non puo' bloccarsi in attesa di risultati — deve organizzarsi in handler separati, portando al problema degli "spaghetti asincroni".

Quali sono le tre soluzioni al problema dello scambio di valori (min/max) e quali sono i loro trade-off?

1. Centralizzata: un coordinatore raccoglie i valori e distribuisce il risultato. Pochi messaggi ma bottleneck. 2. Simmetrica: ogni processo invia il suo valore a tutti gli altri. Massimo parallelismo ma N^2 messaggi. 3. Ad anello: i processi sono organizzati in un anello logico. Numero lineare di messaggi ma parallelismo sequenziale (a onda).

Come si risolve il problema della proattivita' negli attori?

Gli attori sono puramente reattivi, quindi per comportamenti proattivi si usa il self-sending: l'attore invia messaggi a se stesso per procedere incrementalmente in un compito. Ogni passo e' una reazione a un messaggio auto-inviato. Se arriva un messaggio di stop, l'attore smette di auto-inviarsi messaggi e il compito si ferma. E' una soluzione parziale; la ricerca propone astrazioni linguistiche piu' avanzate.

Cosa dice il teorema di fairness nel modello Actor?

La fairness in message delivery stabilisce che se un attore invia un messaggio a un altro attore, questo viene eventualmente recapitato e processato. Non ci sono garanzie sul tempo di consegna ne' sull'ordine di arrivo. Anche due send successive dallo stesso mittente possono arrivare in ordine diverso. Ogni forma di ordinamento deve essere implementata tramite pattern di scambio messaggi.

Qual e' la differenza fra un monitor passivo e un active monitor basato su message passing?

Un monitor passivo (OOP classico) incapsula variabili e procedure con mutua esclusione, ma e' un'entita' passiva: non ha un flusso di controllo proprio. Un active monitor e' un processo attivo che riceve richieste su un canale e risponde su canali dedicati. La mutua esclusione e' garantita dal fatto che il processo server elabora un messaggio alla volta. La tabella di corrispondenza mostra come mappare: variabili del monitor a variabili locali del server, procedure a tipi di operazione, chiamate a send+receive, return a send reply.