Il modello ad attori nasce come alternativa al multi-threading tradizionale per la programmazione concorrente e distribuita. L'idea centrale è che tutto nel sistema sia un attore: un'entità autonoma, puramente reattiva, che comunica solo tramite scambio asincrono di messaggi.
Nella lezione, il Prof. Ricci sottolinea come il modello ad attori sia diventato sempre più rilevante nel panorama mainstream. Framework come Akka (ora Pekko sulla JVM), Erlang, HTML5 Web Workers e Dart Isolates sono esempi concreti di adozione di questo modello. La ragione principale è che gli attori offrono una via d'uscita dai problemi classici della programmazione concorrente: race condition, deadlock, complessità della sincronizzazione manuale.
Un attore è come un "oggetto concorrente": incapsula stato e flusso di controllo. A differenza degli oggetti classici dell'OOP, dove lo stato è protetto ma il metodo viene eseguito nel thread del chiamante, un attore possiede un proprio flusso di controllo logico e processa i messaggi uno alla volta.
Il Prof. Ricci ricorda come Alan Kay, padre della OOP, intendesse originariamente gli oggetti proprio come entità che comunicano tramite message passing. Il modello ad attori realizza compiutamente questa visione: ogni attore ha un identificatore unico, una mailbox (coda di messaggi) e un comportamento che determina come reagire ai messaggi ricevuti.
Il modello Actor è una delle alternative al multi-threading che potete adottare nel secondo assignment. È importante capire la differenza fondamentale: mentre nei thread la comunicazione avviene tramite memoria condivisa (con i relativi problemi di race condition e lock), negli attori la comunicazione avviene esclusivamente tramite messaggi asincroni.
L'idea centrale è: everything is an actor. Ogni entità computazionale è un attore, con:
Nella lezione di ripasso il messaggio chiave è condensato così: un actor è uno stato incapsulato che reagisce solo ai messaggi. Niente accesso diretto allo stato altrui, niente controllo condiviso, solo comunicazione asincrona.
Actor = mailbox + stato + comportamento. Il controllo vive nel messaggio che arriva, non in una variabile condivisa.
| Concetto | In pratica |
|---|---|
send | Invia un messaggio senza bloccare il mittente |
create | Istanzia un nuovo actor con un comportamento preciso |
become | Cambia il comportamento per il prossimo messaggio |
| macro-step | Un handler si completa prima di passare al messaggio successivo |
send(other, "ping", self(), "ciao")
// invio asincrono: il mittente continua subito
public void ping(ActorName caller) {
send(caller, "alive", self());
become(new WaitingForAck());
}
Gli actor semplificano il ragionamento sulle race, ma diventano difficili da leggere se spezzi la logica in troppi handler senza un protocollo chiaro.
Un handler non deve bloccare. Se ti serve attesa, buffering o chaining: sposta la logica nel protocollo, non in un while infinito.
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. Il punto di partenza è la domanda: "come possiamo modellare il calcolo concorrente in modo matematico?"
La risposta è sorprendentemente semplice: l'attore è il primitivo universale del calcolo concorrente digitale. Il modello Actor è una teoria matematica che tratta gli attori come i primitivi universali della computazione concorrente. Tutto — incluse le strutture di controllo tradizionali (if, while, procedure) — può essere modellato come pattern di scambio di messaggi tra attori. Ogni entità nel sistema è un attore con un identificatore univoco e una mailbox (coda di messaggi).
Il modello Actor è fortemente legato all'idea originale di OOP secondo Alan Kay: il punto fondamentale dell'OOP non erano le classi o l'ereditarietà, ma il message passing ("The key point was message passing"). Gli oggetti sono entità autonome che comunicano scambiandosi messaggi — esattamente come gli attori. Con una differenza: un attore incapsula stato, comportamento e un flusso di controllo logico; gli oggetti classici non incapsulano il flusso di controllo.
Un attore è dunque un'entità computazionale che incapsula stato, comportamento e un flusso di controllo logico. A differenza degli oggetti classici, gli attori sono autonomi e reattivi: lavorano solo quando ricevono un messaggio.
flowchart LR
subgraph A1 [Attore A]
S1[(Stato)] & C1[Comportamento] & ID1[ID]
end
subgraph A2 [Attore B]
S2[(Stato)] & C2[Comportamento] & ID2[ID]
end
A1 -->|messaggio asincrono| A2
A2 -->|messaggio asincrono| A1
Un attore è definito da tre elementi essenziali:
flowchart LR
A1[Attore 1] -->|send msg| A2[Attore 2]
A2 -->|send reply| A1
A1 -->|create| A3[Attore 3]
A3 -->|become| A3b[Attore 3 con nuovo behavior]
Nonostante le origini accademiche, il modello Actor ha trovato ampia adozione industriale: Erlang per sistemi telecom estremamente robusti, Akka/Pekko per applicazioni enterprise su JVM, HTML5 Web Workers per il parallelismo nel browser, Dart isolates, e molti altri framework. È considerato un'alternativa valida al multithreading tradizionale per costruire sistemi concorrenti scalabili.
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 è puramente reattivo: può solo rispondere a messaggi, non iniziare azioni autonomamente.
Il modello ad attori definisce solo tre primitive per comporre il comportamento di un attore. Ogni altra costruzione (sincronizzazione, ordinamento, pattern) è derivabile da queste tre.
| Primitiva | Azione | Analogia sequenziale |
|---|---|---|
send | Invia asincronamente un messaggio a un attore specificato | Equivalente alla chiamata di procedura (procedure invocation) |
create | Crea un attore con il comportamento specificato | Equivalente all'astrazione di procedura (procedure abstraction) |
become | Specifica un nuovo comportamento (e stato locale) per il prossimo messaggio | Assegnazione di stato: dota l'attore di comportamento history-sensitive |
Il professore sottolinea l'eleganza del modello: con solo tre primitive si può esprimere qualsiasi computazione concorrente. send è l'equivalente concorrente della chiamata di procedura; create è l'equivalente della definizione di procedura; become permette la storia (cambio di stato nel tempo).
Send è per la programmazione concorrente ciò che l'invocazione di procedura è per la programmazione sequenziale: il mattoncino fondamentale. Create è 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 modellare oggetti mutabili condivisi.
Send — invia un messaggio asincrono a un attore specificato. È per la programmazione concorrente ciò che l'invocazione di procedura è per la programmazione sequenziale: l'operazione fondamentale di interazione.
In Pekko si usa l'operatore tell o !: actorRef ! messaggio.
Create — crea un nuovo attore con un comportamento specificato. È per la programmazione concorrente ciò che l'astrazione di procedura è per la programmazione sequenziale.
In Pekko: context.spawn(behavior, "nome").
Become — specifica un nuovo comportamento che l'attore userà per rispondere al prossimo messaggio. Questo dà agli attori la capacità di avere un comportamento history-sensitive, necessario per modellare dati mutabili condivisi.
In Pekko: context.become(nuovoBehavior) restituito dalla receive.
Un aspetto cruciale è che un attore può comunicare solo con attori di cui conosce l'identificatore. Questi identificatori possono essere scambiati all'interno dei messaggi, permettendo la costruzione di topologie di comunicazione dinamiche.
Un'altra osservazione importante: a differenza degli oggetti classici dell'OOP, gli attori incapsulano anche il controllo — un oggetto normale non ha un flusso di controllo proprio (è passivo), mentre un attore è intrinsecamente attivo. Tuttavia, gli attori sono disaccoppiati dai thread fisici del sistema operativo: sono entità logiche. Come le goroutine di Go, una piattaforma attori può gestire milioni di attori su pochi thread fisici.
Le tre primitive sono state enunciate da Hewitt nel 1973 e rimangono valide oggi. Ogni framework moderno le implementa con nomi e sintassi diversi, ma la semantica è sempre quella.
La semantica del modello Actor si fonda su cinque aspetti chiave:
| Principio | Descrizione |
|---|---|
| Comportamento puramente reattivo | Un attore lavora solo quando riceve un messaggio. Nessun messaggio = bloccato. |
| Incapsulamento dello stato | Un attore non può accedere direttamente allo stato interno di un altro attore. |
| Macro-step semantics | Una volta ricevuto un messaggio, la computazione viene eseguita completamente prima di servire un altro messaggio (run-to-completion). |
| Fairness | Un messaggio inviato a un attore viene prima o poi recapitato e processato. |
| Location transparency | Per inviare un messaggio a un attore basta conoscere la sua identità, non la sua locazione fisica. |
Quando un attore riceve un messaggio, l'intero handler viene eseguito completamente prima di passare al messaggio successivo. Questo elimina alla radice il problema delle race condition sullo stato interno. Gli handler devono però avere un comportamento non bloccante — l'unico punto di blocco è gestito dal ciclo eventi dell'attore.
flowchart LR
A[Attore] --> B[mailbox: msg1, msg2, msg3]
B --> C[estrai msg1]
C --> D[esegui handler]
D --> E[msg1 completato]
E --> F[estrai msg2]
F --> G[esegui handler]
G --> H[msg2 completato]
Le conseguenze della macro-step semantics sono profonde:
La macro-step semantics è cruciale: evita le race condition perché l'handler viene eseguito atomicamente rispetto agli altri messaggi. Tuttavia, complica la programmazione perché un handler non può bloccarsi in attesa di un altro messaggio — bloccherebbe tutto l'attore.
La semantica macro-step è una benedizione per la correttezza ma una maledizione per la leggibilità. Il Prof. Ricci cita il problema dello "asynchronous spaghetti" (Ricci & Santi, AGERE! 2012): la logica applicativa viene spezzata in una serie di handler disconnessi, ciascuno dei quali reagisce a un singolo messaggio.
La macro-step semantics è 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.
Un attore non può accedere direttamente allo stato interno di un altro attore. L'unico modo per interagire con un attore è inviargli un messaggio. Questo incapsulamento è molto più forte di quello dell'OOP classico: non solo i dati, ma anche il flusso di controllo è incapsulato. L'attore decide quando e come processare un messaggio, e può persino rimandare messaggi (stashing).
La fairness è un altro punto cruciale. Hewitt voleva un modello efficace anche nei sistemi distribuiti, quindi stabilì che: se un attore invia un messaggio, questo sarà eventualmente recapitato alla mailbox del destinatario. Non c'è garanzia sul tempo, né sull'ordine. Questa assunzione è volutamente debole per essere realizzabile in ambienti distribuiti reali (diversamente da UDP, che non garantisce neppure la consegna).
La fairness in message delivery è un assunto fondamentale del modello Actor: un messaggio inviato viene eventualmente recapitato ed elaborato. Non ci sono garanzie temporali né di ordinamento. La frase "eventually delivered" è un concetto chiave da ricordare.
La location transparency permette di inviare messaggi a un attore conoscendo solo la sua identità (actor ref), senza sapere se risiede sullo stesso processo, sulla stessa macchina, o su un nodo remoto. Questo è ciò che rende il modello Actor naturalmente adatto a sistemi distribuiti: lo stesso meccanismo funziona per attori locali e remoti.
La location transparency è il pilastro che permette ad Akka/Pekko Cluster di funzionare: un ActorRef si comporta allo stesso modo sia che l'attore sia locale o remoto. Il professore ha sottolineato più volte questo concetto durante la lezione.
L'implementazione pratica del modello attore si basa su un event loop implicito o esplicito. L'architettura di controllo di un attore è un message loop (implicitamente gestito dal framework):
loop {
msg <- waitForMsg() // attesa bloccante (solo qui)
handler <- selectHandler(msg) // dispatch del messaggio
execute(handler) // esecuzione atomica
}
Esempi: ActorFoundry, Akka/Pekko, Vert.x, Web Workers.
Il programmatore scrive il loop di ricezione e usa pattern matching. Esempio: Erlang.
La macro-step semantics è condivisa da entrambi gli approcci: un handler viene eseguito completamente prima di ricevere il messaggio successivo. L'unico punto di blocco è la receive. Questo implica che gli handler devono avere un comportamento non bloccante — la stessa sfida della never-blocking rule vista per gli event loop.
Il widget seguente mostra il ciclo di vita di un attore visto dal punto di vista del message loop:
Il modello Actor ha diverse implementazioni, con varianti semantiche significative. Le più importanti presentate a lezione sono tre: Erlang (linguaggio nativo), Akka/Pekko (framework JVM), e ActorFoundry (framework accademico Java).
Erlang è un linguaggio funzionale sviluppato in Ericsson per applicazioni di telecomunicazione. La sua VM (BEAM) gestisce processi leggeri come entità logiche, non legate a thread OS. Un programma Erlang è 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 è noto per l'estrema efficienza: centinaia di migliaia di processi su un singolo host.
Akka è 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 (oggi nel fork Apache Pekko) è la piattaforma di riferimento per il corso, ed è quella che il professore usa in tutti gli esempi pratici.
ActorFoundry è un framework accademico Java sviluppato dal gruppo di Agha all'Università 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(), "...");
}
}
È principalmente usato per scopi di ricerca e didattici.
Una differenza fondamentale tra i framework è come gestiscono il message loop:
receive ... end, con pattern matching e guardie. Più flessibile e con maggiore controllo, ma anche più verboso e con più disciplina richiesta. Supporta la selective receive con guardie.ActorFoundry è un framework accademico basato su Java, sviluppato dal gruppo di Gul Agha all'Università dell'Illinois (UIUC). Include ottimizzazioni e idee da altri lavori di ricerca come Kilim. Mostra in modo chiaro e minimalista le primitive fondamentali del modello: il programmatore estende la classe Actor e definisce i metodi che saranno invocati alla ricezione di un messaggio.
Il codice seguente mostra un classico ping-pong fra attori:
Analizziamo gli elementi chiave:
extends Actor: ogni attore estende la classe base Actor@message: annotation che marca un metodo come gestore di messaggi — quando arriva un messaggio, il framework invoca automaticamente il metodo corrispondentesend(target, "messageName", args...): primitiva di invio asincrono — il primo argomento è l'attore destinatario, il secondo è una stringa che identifica il tipo di messaggio (corrispondente al nome del metodo)self(): restituisce l'identificatore dell'attore corrente (di tipo ActorName)create(Classe.class): crea un nuovo attore specificando la classe che ne definisce il comportamentoIn 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.
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(...) è invece un invio asincrono di un dato nella mailbox del destinatario, senza trasferimento del flusso di controllo.
Il framework di riferimento del corso è Apache Pekko, fork open-source di Akka: quando Lightbend (l'azienda dietro Akka) ha cambiato licenza in Business Source License, la comunità ha forkato il cuore tecnologico sotto l'ombrello Apache. La trascrizione della lezione riporta il nome come "Pecco": in questo capitolo normalizziamo ovunque a Pekko. Pekko è compatibile al 100% con Akka: gli stessi moduli, le stesse API (package org.apache.pekko), la stessa documentazione. Tutti gli esempi preesistenti per Akka funzionano con Pekko senza modifiche.
Akka è il framework per attori più maturo a livello industriale per l'ecosistema JVM. Scritto in Scala ma utilizzabile da entrambi i linguaggi, offre un ricco set di funzionalità tra cui tolleranza ai guasti stile Erlang ("let it crash"), clustering, stashing, e pattern di supervisione e di messaggistica avanzati. Supporta sia untyped actors (Java) che typed actors (Scala).
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 è più concisa grazie al pattern matching nativo del linguaggio. Il metodo receive definisce una partial function che matcha i messaggi in arrivo. context.system dà accesso al sistema attori. Il pattern matching è esaustivo (il caso _ cattura tutti i messaggi sconosciuti).
Akka/Pekko 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 è quindi implicitamente reattivo — la reattività è una proprietà dell'architettura, non del codice scritto dal programmatore.
Le dipendenze necessarie per iniziare con Pekko includono il modulo core typed e il modulo di logging basato su SLF4J. La versione typed dell'API è un'evoluzione importante: nell'API classica (untyped) si poteva mandare qualsiasi messaggio a qualsiasi attore, anche messaggi che l'attore non era in grado di gestire. Con l'API typed, ogni attore dichiara il tipo T dei messaggi che accetta, e il compilatore garantisce la correttezza a tempo di compilazione.
| Concetto | Ruolo |
|---|---|
ActorSystem | Root del sistema ad attori; runtime che gestisce tutti gli attori |
Behavior<T> | Definisce il comportamento di un attore in risposta a messaggi di tipo T |
ActorRef<T> | Riferimento tipizzato a un attore; si usa per inviargli messaggi |
ActorContext<T> | Contesto dell'attore: permette di spawnare figli, fermarsi, accedere al sistema |
Il sistema ad attori ha una struttura gerarchica. L'ActorSystem è la radice: crea gli attori di primo livello. Ogni attore può a sua volta essere padre di altri attori, formando un albero di supervisione. Questo è ispirato al modello di Erlang, dove la filosofia è "Let It Crash": gli attori possono fallire, ma il padre (supervisore) decide come reagire.
flowchart TD
S[ActorSystem] --> A1[Attore A]
S --> A2[Attore B]
A1 --> C1[Attore C - figlio di A]
A1 --> C2[Attore D - figlio di A]
A2 --> E1[Attore E - figlio di B]
Un behavior in Pekko è il comportamento dell'attore: una funzione che prende un messaggio e un contesto e restituisce il prossimo behavior. È importante capire che il prossimo behavior non deve essere per forza lo stesso: un attore può cambiare comportamento dinamicamente in risposta ai messaggi.
import org.apache.pekko.actor.typed.*;
import org.apache.pekko.actor.typed.javadsl.*;
public class HelloWorld {
public static Behavior<String> create() {
return Behaviors.setup(context -> {
return Behaviors.receiveMessage(message -> {
System.out.println("Hello " + message);
return Behaviors.same();
});
});
}
public static void main(String[] args) {
ActorSystem<String> system = ActorSystem.create(HelloWorld.create(), "hello-system");
system.tell("World!");
}
}
L'actor ref è il riferimento tramite cui si interagisce con un attore. È parametrizzato sul tipo T dei messaggi accettati: ActorRef<T>. Se si prova a inviare un messaggio di tipo sbagliato, il programma non compila.
Distinguere bene: Behavior<T> è cosa l'attore fa; ActorRef<T> è dove inviare messaggi; ActorContext<T> è il contesto per operazioni come spawn, stop, watch.
Il Prof. Ricci mostra l'esempio classico per illustrare l'interazione tra attori: il protocollo Ping-Pong. Due attori si scambiano messaggi: Pinger e Ponger. Il messaggio Ping contiene un campo replyTo con l'actor ref a cui rispondere.
public interface PingProtocol { }
public static final class Ping implements PingProtocol {
public final ActorRef<PongProtocol> replyTo;
public Ping(ActorRef<PongProtocol> replyTo) {
this.replyTo = replyTo;
}
}
public interface PongProtocol { }
public static final class Pong implements PongProtocol { }
public static Behavior<PingProtocol> pingerBehavior() {
return Behaviors.setup(context -> Behaviors.receiveMessage(msg -> {
if (msg instanceof Ping p) {
p.replyTo.tell(new Pong());
return Behaviors.same();
}
return Behaviors.unhandled();
}));
}
public static Behavior<PongProtocol> pongerBehavior() {
return Behaviors.setup(context -> Behaviors.receiveMessage(msg -> {
if (msg instanceof Pong) {
System.out.println("Ricevuto Pong!");
return Behaviors.same();
}
return Behaviors.unhandled();
}));
}
Il pattern è chiaro: il messaggio Ping "incapsula" il protocollo di risposta portando con sé il riferimento a chi deve rispondere. Questo evita la necessità di un meccanismo di sincronizzazione separato. Il Prof. Ricci sottolinea che questo è il modo idiomatico di fare request-response nel mondo ad attori: via messaggio con campo replyTo, non via chiamata bloccante.
Invece di usare ask (che restituisce una Future), il modo più idiomatico e puro è includere l'actor ref del mittente nel messaggio stesso. Questo mantiene il sistema completamente asincrono e reattivo.
Erlang è il linguaggio che ha portato il modello attore nella produzione industriale: probabilmente il linguaggio di programmazione concorrente più robusto e utilizzato dall'industria, insieme ad Ada. Sviluppato in Ericsson a partire dal 1987 per costruire applicazioni telecom, è un linguaggio funzionale che si basa su un modello a processi leggeri (non thread OS) che comunicano via message passing asincrono. Il cuore di Erlang è la BEAM (Bogdan/Björn's Erlang Abstract Machine), una macchina virtuale che implementa un modello ad attori nativo: i processi Erlang sono concettualmente identici agli attori.
flowchart LR
subgraph BEAM[BEAM Virtual Machine]
P1[Processo 1
mailbox]
P2[Processo 2
mailbox]
P3[Processo N
mailbox]
end
P1 -->|invia messaggio| P2
P2 -->|invia messaggio| P3
P3 -->|invia messaggio| P1
| Caratteristica | Thread OS | Processo Erlang |
|---|---|---|
| Costo creazione | Decine di microsecondi | ~5 microsecondi |
| Numero massimo | Migliaia | Centinaia di migliaia |
| Memoria per processo | MB (stack + TCB) | ~300 parole |
| Scheduling | Preemptivo (OS) | Cooperativo (BEAM) |
| Isolamento | Memoria condivisa | Nessuna memoria condivisa |
Il Prof. Ricci avverte: non confondete i processi Erlang con i processi del sistema operativo. I processi Erlang sono entità virtuali gestite dalla BEAM, molto più leggeri e veloci da creare. Ogni processo ha una propria mailbox e non condivide memoria con altri processi.
Joe Armstrong, il creatore di Erlang, ha definito il linguaggio come "il miglior modo per scrivere sistemi concorrenti, distribuiti e fault-tolerant". La BEAM supporta centinaia di migliaia di processi con costo di creazione di pochi microsecondi.
Erlang è un linguaggio funzionale: un programma descrive una serie di funzioni, con pattern matching per determinare quale clausola eseguire. Le variabili iniziano con la lettera maiuscola (come in Prolog). Non esistono variabili globali.
La primitiva spawn crea un nuovo processo (attore) specificando modulo, nome funzione e parametri (o una fun anonima). Restituisce un PID (Process IDentifier) che identifica univocamente il processo. L'operatore ! invia un messaggio a un processo: Pid ! Message. La receive selettiva estrae un messaggio dalla mailbox del processo usando pattern matching:
% Creazione processo
Pid = spawn(fun () -> account(0) end).
% Invio messaggio
Pid ! Message.
% Ricezione con pattern matching
receive
Pattern1 [ when Guard1 ] -> Expression1;
Pattern2 [ when Guard2 ] -> Expression2;
...
end
Il processo si blocca finché non trova un messaggio nella coda che matcha un pattern con guardia verificata.
% Esempio Erlang: area server (dalle slide)
-module(area_server0).
-export([loop/0]).
loop() ->
receive
{rectangle, Width, Ht} ->
io:format("Area of rectangle is ~p~n", [Width * Ht]),
loop();
{circle, R} ->
io:format("Area of circle is ~p~n", [3.14159 * R * R]),
loop();
Other ->
io:format("I don't know what ~p is~n", [Other]),
loop()
end.
% Nell'interprete:
% 1> Pid = spawn(fun area_server0:loop/0).
% 2> Pid ! {rectangle, 3, 4}.
% Area of rectangle is 12
Notare che: (1) ogni caso della receive termina con ; tranne l'ultimo che termina con .; (2) ogni caso chiama ricorsivamente loop() per rimettersi in ascolto del messaggio successivo; (3) il pattern Other cattura qualsiasi messaggio non riconosciuto.
Il Prof. Ricci mostra come in Erlang anche un semplice contatore sia un processo. Non esiste memoria condivisa: tutto è un processo. Lo stato (il valore corrente) vive nel parametro della funzione ricorsiva.
-module(counter).
-export([start/0]).
start() -> loop(0).
loop(Sum) ->
receive
{inc} ->
loop(Sum + 1);
{getValue, Pid} ->
Pid ! {count_value, Sum},
loop(Sum)
end.
% 1> Pid = spawn(counter, start, []).
% 2> Pid ! {inc}.
% 3> Pid ! {getValue, self()}.
account(Balance) ->
receive
{deposit, Amount, Whom} -> Whom ! {deposit_receipt, Amount},
account(Balance + Amount);
{balance, Whom} -> Whom ! {balance, Balance},
account(Balance);
{withdrawal, Amount, Whom} when Amount > Balance ->
Whom ! overdraft, account(Balance);
{withdrawal, Amount, Whom} -> Whom ! {withdrawal_receipt, Amount},
account(Balance - Amount)
end.
L'esempio con la for comprehension mostra come creare decine di migliaia di processi in pochi millisecondi, misurando il tempo di spawn:
-module(processes).
-export([max/1]).
max(N) ->
Max = erlang:system_info(process_limit),
io:format("Maximum allowed processes:~p~n",[Max]),
statistics(runtime),
statistics(wall_clock),
L = for(1, N, fun() -> spawn(fun() -> wait() end) end),
{_, Time1} = statistics(runtime),
{_, Time2} = statistics(wall_clock),
...
% Output per N=20000:
% Process spawn time=5.5 (9.4) microseconds
In Erlang, non esistono oggetti o dati mutabili condivisi. Anche un semplice contatore è un processo con la propria mailbox. Questa filosofia radicale elimina ogni possibilità di race condition.
Il professore sottolinea che Erlang non è un linguaggio accademico ma è stato usato (ed è ancora usato) per fare software molto robusto in ambito telecom. La BEAM è una delle macchine virtuali concorrenti più collaudate al mondo.
Il modello ad attori prevede diversi pattern di interazione, ciascuno con i propri casi d'uso. Il Prof. Ricci li presenta in ordine crescente di complessità.
L'attore invia un messaggio e non si aspetta una risposta. È il caso più semplice e comune: si usa actorRef.tell(messaggio) o l'operatore !. Esempio: inviare un Increment a un contatore senza curarsi del risultato.
L'attore mittente include nel messaggio un campo replyTo: ActorRef<Risposta>. Il destinatario usa questo riferimento per inviare la risposta. È il pattern idiomatico del mondo ad attori: preserva l'asincronia e non richiede blocking.
Pekko fornisce anche l'operatore ask che restituisce una Future. Questo è utile quando si vuole interagire con un attore da codice non-actor (es. da un main) o quando serve una risposta ma non si vuole implementare il protocollo manualmente.
import static org.apache.pekko.actor.typed.javadsl.AskPattern.ask;
import java.time.Duration;
import java.util.concurrent.CompletionStage;
CompletionStage<String> risposta = ask(
actorRef,
replyTo -> new RichiediDati(replyTo),
Duration.ofSeconds(5),
system.scheduler()
);
risposta.thenAccept(valore -> {
System.out.println("Ricevuto: " + valore);
});
Il Prof. Ricci fa notare che ask è utile ma "esce un po' dal modello puro". L'uso della Future introduce un meccanismo che non è nativo del modello ad attori. Tuttavia, quando usato con giudizio, risolve il problema dell'asynchronous spaghetti: si possono combinare più ask in sequenza con thenCompose o in parallelo con Promise.all.
Come ogni paradigma, anche il modello Actor ha i suoi pattern ricorrenti. I principali sono:
La macro-step semantics semplifica il ragionamento sugli attori ma introduce un problema serio: la logica applicativa viene frammentata in un insieme non strutturato di handler/callback, ciascuno dei quali reagisce a un evento specifico. Quando un flusso di lavoro richiede una sequenza di passi, ogni passo diventa un handler separato. Il professore chiama questo il problema degli "spaghetti asincroni" (asynchronous spaghetti), rifacendosi a [RS-12].
L'esempio classico 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 non può bloccarsi in attesa dei risultati. Deve:
Questo frammenta la logica in tre handler separati, perdendo la struttura sequenziale naturale dell'algoritmo: ogni risposta richiede un handler diverso, e bisogna tracciare lo stato per sapere se l'altra risposta è già arrivata. La letteratura [RS-12] documenta approfonditamente questo problema.
Un future è un proxy per un risultato inizialmente sconosciuto. Nell'Actor model, un future è rappresentato da un attore con tre stati: senza valore e senza clienti in attesa; clienti in coda fino a quando il valore è disponibile; valore disponibile che notifica i clienti in attesa.
Il future può essere passato ad altri attori prima di essere risolto, aumentando il parallelismo. Linguaggi come E e AmbientTalk offrono supporto nativo ai future/promise. Akka/Pekko 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 può 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;
}
Le LSC (Local Synchronization Constraints) separano il quando un messaggio viene processato dal come. Un messaggio disabilitato viene messo in una save queue per essere processato in seguito.
Lo stash è 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 è: 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 problema degli spaghetti asincroni colpisce più 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.
Un pattern comune è 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 è implementabile con le primitive di base ma è complesso — per questo i framework offrono supporto diretto, come la primitiva call in ActorFoundry. Il pattern Future è alla base della comunicazione RPC-like nel modello actor: un attore-future fa da proxy per un risultato non ancora disponibile e, quando il valore viene calcolato, notifica tutti gli attori in attesa.
Gli attori sono entità 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. Le soluzioni classiche includono: dividere l'attore in più attori interagenti, oppure il self-sending di messaggi.
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 può iniziare da solo la stampa — deve aspettare un messaggio che glielo richieda. La soluzione proposta dal professore è 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 è semplice ma profondo: ogni stampa è 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 è una reazione), ma l'auto-invio crea un effetto proattivo complessivo.
Il simulatore mostra l'alternanza fra il messaggio di auto-invio (che fa progredire la stampa) e la possibilità di inserire uno stop in qualsiasi momento. Il professore sottolinea che questa è una soluzione elegante ma parziale; la letteratura [RS-12] propone astrazioni più avanzate per integrare proattività e reattività a livello di linguaggio.
Il self-sending di messaggi è una tecnica potente che ricorda il postMessage degli event loop: l'attore programma la sua prossima attività mandando un messaggio alla propria mailbox. Questo rispetta la natura reattiva (c'è sempre un messaggio che innesca l'azione) pur ottenendo un comportamento ciclico/pro-attivo.
Il problema della proattività è affrontato anche dalla ricerca: esistono proposte per estendere il modello Actor con astrazioni che permettano di specificare comportamenti proattivi (piani, goal) mantenendo la reattività come meccanismo di interazione. Tuttavia, nel modello di base, il self-sending rimane la tecnica più diffusa e pragmatica.
Uno degli aspetti più potenti del modello ad attori è il meccanismo di supervisione, ispirato a Erlang. L'idea è che gli attori falliscano (lanciando eccezioni), ma il padre-supervisore decide come reagire. Non si cerca di prevenire tutti i fallimenti: si accetta che accadano e si fornisce un meccanismo di recovery ("Let It Crash").
Lo state explorer seguente mostra il ciclo di vita completo di un attore, incluso lo stato di fallimento gestito dal supervisore:
Quando un attore figlio fallisce, il padre può applicare diverse strategie:
| Strategia | Comportamento |
|---|---|
Restart | Riavvia l'attore figlio: viene creato da capo con stato iniziale (quello definito in setup) |
Resume | Riprende l'esecuzione del figlio mantenendo lo stato interno corrente (delicato, perché lo stato potrebbe essere corrotto) |
Stop | Ferma definitivamente l'attore figlio |
Escalate | Propaga il fallimento al supervisore del padre, che deciderà a sua volta |
Il meccanismo di supervisione ha un effetto collaterale molto importante: la pulizia automatica delle risorse. Se un attore padre fallisce, tutti i figli vengono automaticamente fermati e le loro risorse rilasciate. Questo è molto diverso dal mondo dei thread, dove un thread orfano continua a vivere e il programmatore deve implementare meccanismi ad hoc per chiudere file, rilasciare lock, ecc.
Behaviors.setup(context -> {
ActorRef<MyMsg> child = context.spawn(
Behaviors.supervise(childBehavior())
.onFailure(SupervisorStrategy.restart()),
"child-actor"
);
return childBehavior();
});
Il Prof. Ricci menziona anche la funzione watch: un padre può osservare un figlio e ricevere una notifica ChildFailed o Terminated quando termina. Questo permette al padre di reagire alla terminazione (es. cleanup aggiuntivo o terminazione a catena del sistema).
Un tema importante toccato nella lezione riguarda le garanzie di consegna dei messaggi tra attori. Pekko (come Akka ed Erlang) adotta una semantica at-most-once: i messaggi possono essere persi (per crash, problemi di rete, ecc.), ma non saranno mai duplicati.
| Garanzia | Descrizione |
|---|---|
| At-most-once | Ogni messaggio viene consegnato al massimo una volta. Possibile perdita, mai duplicazione. |
| At-least-once | Ogni messaggio viene consegnato almeno una volta. Possibile duplicazione, mai perdita. |
| Exactly-once | Ogni messaggio viene consegnato esattamente una volta. Massimo costo e complessità. |
Nel modello di base, la send non dà alcuna garanzia sui tempi di consegna né 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.
Le implementazioni pratiche come Pekko rafforzano il modello di base: viene garantito un ordinamento sender-receiver. Se A invia il messaggio M1 e poi M2 a B, M1 viene consegnato prima di M2. Tuttavia, non c'è garanzia di ordinamento tra coppie diverse: se A invia M1 a B e M2 a C, M2 potrebbe arrivare prima di M1. Per gestire la sincronizzazione e l'ordinamento locale, alcuni framework offrono inoltre vincoli di sincronizzazione locale (LSC in ActorFoundry) o lo stashing di Akka/Pekko, visti nella sezione 10.
Non date per scontato l'ordinamento globale dei messaggi. Il modello garantisce solo l'ordinamento per coppia sender-receiver. Se avete bisogno di ordinamento globale, dovete implementarlo voi usando pattern di messaggistica opportuni (es. sequencer actor).
Pekko fornisce un modulo di testing integrato con ScalaTest e JUnit. Il test kit permette di creare un ActorSystem di test, spawnare attori e verificare che i messaggi vengano scambiati correttamente.
import org.apache.pekko.actor.testkit.typed.javadsl.*;
@Test
public void testPingPong() {
var testKit = ActorTestKit.create();
var probe = testKit.createTestProbe();
var pinger = testKit.spawn(pingerBehavior());
pinger.tell(new Ping(probe.ref()));
probe.expectMessageClass(Pong.class);
}
Il Prof. Ricci sottolinea l'importanza di testare gli attori, soprattutto perché la semantica asincrona può nascondere bug sottili. Il test kit di Pekko permette di: (1) spawnare attori in un ambiente controllato, (2) usare probe per catturare i messaggi in uscita, (3) impostare timeout per evitare test che si bloccano.
Il testing degli attori è più vicino al testing di integrazione che al testing unitario. Anche un singolo attore ha bisogno del runtime (ActorSystem) per funzionare. Il test kit fornisce un ambiente leggero per questo scopo.
Il Prof. Ricci conclude la lezione con una serie di raccomandazioni pratiche su cosa evitare quando si sviluppa con gli attori.
Queste raccomandazioni sono state sottolineate esplicitamente dal professore durante la lezione. Ricordatele per il progetto e per l'esame.
| Pratica scorretta | Spiegazione |
|---|---|
| Bloccare gli handler | Non fare Thread.sleep(), I/O bloccante, o attese in un handler. Blocca l'event loop dell'attore. |
Usare ask dappertutto | Preferire messaggi con replyTo per mantenere la purezza del modello ad attori. |
| Trattare la supervisione come validazione | La supervisione serve per gestire fallimenti imprevisti, non per validare input errati. |
| Esporre il contesto | Non esporre mai ActorContext all'esterno dell'attore. Rompe l'incapsulamento. |
| Attori monolitici | Meglio tanti piccoli attori che uno grande. Si possono creare milioni di attori: sfruttate questa caratteristica. |
Il Prof. Ricci ricorda che il modello ad attori consente di definire in modo esplicito il protocollo di interazione tra componenti, offre un modo strutturato per gestire fallimenti e ciclo di vita, e costituisce la base per passare dalla programmazione concorrente a quella distribuita (argomento del prossimo capitolo).
Send (inviare un messaggio asincrono), Create (creare un nuovo attore con un comportamento specificato), Become (cambiare il comportamento dell'attore per il prossimo messaggio).
Quando un attore riceve un messaggio, l'intero handler viene eseguito completamente prima di passare al messaggio successivo. Questo elimina le race condition sullo stato interno dell'attore.
Perché lo stato resta incapsulato e il controllo passa solo attraverso messaggi asincroni, non tramite memoria condivisa.
tell e ask in Pekko?tell invia un messaggio fire-and-forget (non si aspetta risposta). ask invia un messaggio e restituisce una Future per attendere la risposta. Il pattern idiomatico nel mondo ad attori è usare tell con un campo replyTo nel messaggio.
At-most-once: i messaggi possono essere persi ma mai duplicati. Per quanto riguarda l'ordinamento, viene garantito l'ordine per coppia sender-receiver (se A manda M1 e poi M2 a B, M1 arriva prima di M2).
È la filosofia di Erlang adottata anche da Pekko: non cercare di prevenire ogni possibile fallimento, ma accettarlo e fornire meccanismi di recovery automatici attraverso la gerarchia di supervisione. Il padre dell'attore fallito decide se riavviarlo, riprenderlo o fermarlo.
Un processo Erlang è un'entità logica gestita dalla BEAM, non un thread OS. La creazione costa ~5 microsecondi (contro decine di microsecondi per un thread), si possono creare centinaia di migliaia di processi, e ogni processo ha una memoria di circa 300 parole (contro MB per un thread). Non condividono memoria.
Pending (creata, in attesa di completamento), Resolved/Fulfilled (completata con successo), Rejected (completata con errore). Una volta risolta o rifiutata, la Promise è settled e il suo stato non cambia più.
Blocchi lunghi, wait attive e loop infiniti: rompono la reattività del sistema.
Bloccare gli handler (non fare mai blocking call), usare ask dappertutto (preferire messaggi con replyTo), usare la supervisione per validazione (serve per fallimenti imprevisti), esporre il contesto dell'attore all'esterno, creare attori monolitici (preferire tanti piccoli attori).
Tradurre la traccia in ruoli, messaggi, stato minimo e invarianti prima di scrivere implementazione.