Il capitolo in un paragrafo. Un attore è un'entità con un indirizzo, una mailbox e uno stato privato, che fa qualcosa solo quando riceve un messaggio e comunica solo inviandone. Ogni messaggio viene elaborato fino in fondo prima del successivo (macro-step), quindi sullo stato di un attore non ci sono race condition né lock. Il prezzo è che un handler non può mai aspettare: la logica si spezza in tanti handler (lo «spaghetti asincrono») e servono future, stash o messaggi a se stessi per ricomporla. Pekko, Erlang e ActorFoundry realizzano la stessa idea in modi diversi, e la supervisione con la location transparency la estende ai guasti e alla distribuzione.
Il modello nasce come alternativa ai thread con memoria condivisa: tutto è un attore, e gli attori non condividono nulla. Ognuno ha quattro elementi:
Un oggetto dell'OOP incapsula lo stato, ma i suoi metodi girano nel thread di chi li chiama: è un componente passivo. Un attore incapsula anche il flusso di controllo: decide lui quando elaborare un messaggio, quindi è un componente attivo, pur essendo puramente reattivo. È l'idea originale di Alan Kay per l'OOP, dove il punto chiave era lo scambio di messaggi. Gli attori sono entità logiche, non thread del sistema operativo: un runtime ne esegue migliaia o milioni su pochi thread.
Nella formulazione di Gul Agha (1986), che sviluppa il modello di Carl Hewitt (MIT, 1973), un attore che elabora un messaggio può fare solo tre cose:
| Primitiva | Effetto | Analogo sequenziale | Pekko (typed) | Erlang |
|---|---|---|---|---|
| send | invia un messaggio in modo asincrono: il mittente prosegue subito | chiamata di procedura | ref.tell(msg) | Pid ! Msg |
| create | crea un nuovo attore con un comportamento iniziale | astrazione di procedura | ctx.spawn(behavior, "nome") | spawn(Fun) |
| become | fissa il comportamento (e lo stato) per il prossimo messaggio | assegnamento: dà una storia all'attore | l'handler restituisce il prossimo Behavior | ricorsione in coda con il nuovo stato |
Un attore può inviare messaggi solo agli attori di cui conosce l'indirizzo: quelli che ha creato, quelli che conosceva alla creazione e quelli ricevuti dentro un messaggio. Poiché gli indirizzi viaggiano nei messaggi, la topologia della comunicazione cambia a run time.
| Proprietà | Significato |
|---|---|
| Reattività pura | Un attore lavora solo quando riceve un messaggio; senza messaggi è fermo. |
| Incapsulamento | Nessuno accede allo stato di un attore: l'unica interazione è inviargli un messaggio. |
| Macro-step | Un messaggio viene elaborato per intero (run-to-completion) prima di prendere il successivo. |
| Fairness | Ogni messaggio inviato viene prima o poi consegnato ed elaborato, senza garanzie di tempo né di ordine. |
| Location transparency | Per inviare basta l'indirizzo: non importa se l'attore è nello stesso processo o su un altro nodo. |
Il macro-step è la proprietà che rende gli attori semplici da ragionare: dentro un handler l'attore si comporta come un programma sequenziale, perché nessun altro handler dello stesso attore può interromperlo. Non servono lock sullo stato. La condizione è che l'handler non si blocchi mai: un attore bloccato non elabora più nessun messaggio. È la stessa regola del never-blocking nell'event loop di JavaScript, e gli attori ne condividono sia i vantaggi sia i problemi (sezione 5).
La fairness è volutamente debole: Hewitt voleva un modello realizzabile anche su reti reali, dove si può promettere al più che un messaggio prima o poi arrivi.
Spariscono le race condition sullo stato, non i problemi di concorrenza. L'ordine di arrivo dei messaggi resta non deterministico, quindi ci possono essere race sui messaggi. E due attori che aspettano ciascuno la risposta dell'altro prima di proseguire, ad esempio mettendo da parte ogni altro messaggio, sono in deadlock a livello di protocollo.
Dietro ogni attore c'è un ciclo di questo tipo, e il macro-step è il corpo di un'iterazione:
loop {
msg <- waitForMsg() // l'unico punto in cui l'attore si blocca
handler <- selectHandler(msg) // sceglie l'handler in base al messaggio
execute(handler) // macro-step: fino in fondo
}
Le implementazioni si dividono secondo chi scrive questo ciclo:
receive … end dove vuole, dentro una funzione ricorsiva. La receive è selettiva: estrae il primo messaggio che corrisponde a uno dei pattern con guardia e lascia gli altri in mailbox. Così un processo può fermarsi ad aspettare proprio la risposta che gli serve, a metà di un calcolo, al prezzo di più disciplina e di mailbox che possono riempirsi di messaggi mai estratti.| Vantaggi del modello | Svantaggi |
|---|---|
| Nessuna race condition sullo stato, grazie al macro-step | Logica spezzata in molti handler (spaghetti asincrono) |
| Ogni attore si ragiona come un programma sequenziale | Difficile esprimere comportamenti proattivi |
| Concorrenza logica (N attori) separata da quella fisica (M thread) | Coordinare attori che devono aspettarsi è laborioso |
Apache Pekko è il fork open source di Akka, nato nel 2022 quando Lightbend ha cambiato la licenza di Akka. È il framework di riferimento del corso e dell'assignment 3. Il corso usa l'API typed, in cui il tipo dei messaggi accettati fa parte del riferimento: inviare un messaggio del tipo sbagliato è un errore di compilazione.
| Concetto | Che cos'è |
|---|---|
ActorSystem<T> | il runtime e l'attore radice (guardian), da cui discendono tutti gli altri |
Behavior<T> | che cosa fa l'attore con un messaggio di tipo T; ogni handler restituisce il prossimo behavior |
ActorRef<T> | dove inviare messaggi di tipo T |
ActorContext<T> | le operazioni dall'interno: spawn, stop, watch, getSelf, il log |
// Il protocollo: i messaggi sono tipi. Ping porta con sé a chi rispondere.
record Ping(ActorRef<Pong> replyTo) {}
record Pong() {}
static Behavior<Ping> ponger() {
return Behaviors.receive(Ping.class)
.onMessage(Ping.class, ping -> {
ping.replyTo().tell(new Pong()); // send
return Behaviors.same(); // stesso comportamento
}).build();
}
static Behavior<Pong> pinger(ActorRef<Ping> ponger, int left) {
return Behaviors.setup(ctx -> {
ponger.tell(new Ping(ctx.getSelf()));
return Behaviors.receive(Pong.class)
.onMessage(Pong.class, pong -> left > 1
? pinger(ponger, left - 1) // become: nuovo stato
: Behaviors.stopped())
.build();
});
}
Behavior<Void> main = Behaviors.setup(ctx -> {
ActorRef<Ping> p = ctx.spawn(ponger(), "ponger"); // create
ctx.spawn(pinger(p, 3), "pinger");
return Behaviors.empty();
});
ActorSystem.create(main, "ping-pong");
Il contatore left non è un campo modificato: ogni risposta restituisce un nuovo behavior con il valore aggiornato. È become nella forma di Pekko.
Erlang nasce in Ericsson a metà degli anni '80 per centrali telefoniche che non devono fermarsi mai. È un linguaggio funzionale: niente variabili globali, niente memoria condivisa, e le variabili iniziano con la maiuscola. I suoi processi sono attori: li gestisce la macchina virtuale BEAM, non il sistema operativo, e se ne creano centinaia di migliaia su un solo host, in pochi microsecondi ciascuno. Anche un contatore è un processo, e il suo stato vive nel parametro della funzione ricorsiva.
account(Balance) ->
receive
{deposit, Amount, Whom} ->
Whom ! {deposit_receipt, Amount},
account(Balance + Amount); % become: nuovo stato
{balance, Whom} ->
Whom ! {balance, Balance},
account(Balance);
{withdrawal, Amount, Whom} when Amount > Balance ->
Whom ! overdraft, % guardia: prelievo rifiutato
account(Balance);
{withdrawal, Amount, Whom} ->
Whom ! {withdrawal_receipt, Amount},
account(Balance - Amount)
end.
Pid = spawn(fun() -> account(0) end). % create
Pid ! {deposit, 100, self()}. % send
Ogni ramo della receive termina richiamando account, cioè rimettendosi in ascolto con lo stato aggiornato. L'ordine dei rami conta: la guardia when Amount > Balance viene provata prima del prelievo normale.
ActorFoundry è un framework Java accademico del gruppo di Agha (University of Illinois). Mostra le primitive allo stato puro: un attore estende Actor, gli handler sono metodi annotati @message, e il nome del messaggio è una stringa uguale al nome del metodo.
public class PingActor extends Actor {
@message
public void start(ActorName other) {
send(other, "ping", self()); // send: asincrono
}
@message
public void ping(ActorName caller) {
send(stdout, "println", "ping da " + caller);
send(caller, "alive", self() + " è vivo");
}
@message
public void alive(String reply) {
send(stdout, "println", reply);
}
}
public class PingBoot extends Actor {
@message
public void boot() {
ActorName a = create(PingActor.class); // create
ActorName b = create(PingActor.class);
send(a, "start", b);
}
}
send(other, "ping", …) sembra una chiamata other.ping(…), ma non trasferisce il controllo: deposita un messaggio nella mailbox e prosegue. Anche la stampa passa per un attore, stdout.
tell): invio senza attendere risposta. È il caso base.replyTo: la richiesta contiene l'indirizzo a cui rispondere, come Ping nell'esempio Pekko. Il protocollo sta tutto nei messaggi e nessuno si blocca: è la forma idiomatica, quella che Ricci raccomanda.CompletionStage, con un timeout obbligatorio. Serve soprattutto da codice che non è un attore, come il main, o per comporre più risposte.CompletionStage<Pong> reply = AskPattern.ask(
ponger, replyTo -> new Ping(replyTo),
Duration.ofSeconds(3), system.scheduler());
reply.thenAccept(pong -> System.out.println("risposta arrivata"));
Ask «esce un po' dal modello»: introduce future e timeout che il modello non prevede. Inoltre la callback gira fuori dal macro-step dell'attore, quindi non deve toccarne lo stato. Dentro un attore si usa ctx.ask (o pipeToSelf), che ritrasforma la risposta in un messaggio per l'attore.
Actor idioms (Schumacher, AGERE! 2012; la slide ne elenca tre e rimanda all'articolo per gli altri):
Messaging patterns (V. Vernon):
Storia del message passing e degli attori: 1970 Brinch Hansen, message passing asincrono nell'RC 4000 · 1971 Balzer, communication port · 1973 Hewitt, Bishop e Steiger, il modello ad attori · 1974 Dijkstra, guarded commands · 1977 Hewitt, Viewing control structures as patterns of passing messages · 1978 Hoare, CSP · 1986 Agha, Actors: a model of concurrent computation in distributed systems · 1986–87 Erlang in Ericsson · 1987 Yonezawa e Tokoro, Concurrent object-oriented programming · 1990 Agha, attori come unificazione di OOP e concorrenza (CACM) · 2009 Akka · 2009 Go, con goroutine e canali · oggi Pekko, Dart isolates, Web Workers.
Un handler non può aspettare. Consideriamo l'esempio delle slide: un attore deve calcolare sin(x) · cos(y) delegando i due calcoli agli attori Sin e Cos. In un programma sequenziale sarebbero due chiamate e un prodotto. Qui invece un handler invia le due richieste e termina. Le risposte arrivano in due handler diversi, in un ordine qualsiasi, e l'attore deve ricordarsi nello stato quale risultato ha già. Con conversazioni più lunghe il flusso logico si disperde tra handler scollegati: è lo spaghetti asincrono (Ricci e Santi, AGERE! 2012), lo stesso problema del callback hell.
Qui fai tu da runtime. Consegna un messaggio in viaggio alla mailbox del destinatario, poi fai eseguire a un attore un macro-step. I messaggi della stessa coppia mittente → destinatario arrivano nell'ordine di invio, come in Pekko; per il resto l'ordine lo scegli tu, o lo sceglie Passo a caso. Gli scenari corrispondono a questa sezione e alla successiva.
static Behavior<FileCmd> closed() {
return Behaviors.withStash(100, stash ->
Behaviors.receive(FileCmd.class)
.onMessage(Open.class, o -> stash.unstashAll(open(""))) // rimette in mailbox
.onMessage(Write.class, w -> { stash.stash(w); return Behaviors.same(); })
.build());
}
@Disable(messageName = "put") // put non si elabora mentre…
public Boolean disablePut(Integer x) {
return !bufferReady || tail == bufferSize; // …il buffer non è pronto o è pieno
}
Un attore puramente reattivo non può «decidere» di fare qualcosa: può solo reagire. Come si scrive allora un attore che stampa i numeri da 1 a 100 e deve anche poter essere fermato da un messaggio stop? Un ciclo for dentro l'handler occuperebbe un unico macro-step, e lo stop resterebbe in mailbox fino alla fine. La soluzione è spezzare il lavoro in passi, ognuno innescato da un messaggio che l'attore invia a se stesso:
on start(end): send self, print(1, end)
on print(i, end): if not stopped:
print i
if i < end: send self, print(i+1, end)
on stop: stopped := true
Tra un passo e l'altro lo stop può arrivare ed essere elaborato: prova lo scenario «Proattività» nel laboratorio. L'attore resta reattivo, ma il risultato è un comportamento che procede da solo. L'altra soluzione, parziale anch'essa, è dividere l'attore in più attori che interagiscono. In Pekko si ottiene lo stesso con i timer (capitolo 16).
Dall'esperienza di Erlang viene l'idea di non difendere ogni riga dai guasti: un attore che incontra un errore fallisce, e decide il suo supervisore, cioè l'attore che lo ha creato. Gli attori formano così un albero con radice nell'ActorSystem.
| Strategia | Effetto sul figlio fallito |
|---|---|
| Restart | viene ricreato con lo stato iniziale |
| Resume | riprende con lo stato che aveva, che potrebbe essere corrotto |
| Stop | viene fermato definitivamente |
| Escalate | il guasto passa al supervisore del padre |
ActorRef<Cmd> child = ctx.spawn(
Behaviors.supervise(worker())
.onFailure(IllegalStateException.class, SupervisorStrategy.restart()),
"worker");
ctx.watch(child); // il padre riceverà il segnale Terminated / ChildFailed
Nell'API typed di Pekko un attore che lancia un'eccezione per default viene fermato. Restart e resume si chiedono con supervise, mentre l'escalation passa per watch: il padre riceve ChildFailed e, se non lo gestisce, fallisce a sua volta. Un effetto utile dell'albero è la pulizia: quando un padre si ferma, si fermano anche tutti i suoi figli, mentre un thread orfano continua a vivere.
| Semantica | Garanzia |
|---|---|
| At-most-once | consegnato al più una volta: può perdersi, mai duplicato |
| At-least-once | consegnato almeno una volta: mai perso, può essere duplicato |
| Exactly-once | esattamente una volta: la più costosa da realizzare |
Pekko, come Akka ed Erlang, garantisce at-most-once: se serve di più, conferme e ritrasmissioni si scrivono nel protocollo. Il modello di base non garantisce nemmeno l'ordine. Pekko aggiunge l'ordine per coppia mittente → destinatario: se A invia M1 e poi M2 a B, B li riceve in quest'ordine. Tra coppie diverse invece non c'è alcun ordine, nemmeno causale. Nello scenario «Ordinamento» del laboratorio B riceve m3 prima di m1, anche se m3 è una conseguenza di un messaggio inviato dopo m1. Per un ordine globale serve un attore sequenziatore, o gli orologi logici del capitolo 17.
La location transparency fa sì che tutto questo valga uguale tra nodi diversi: il passaggio al cluster è nel capitolo 16.
| Da evitare | Da fare |
|---|---|
Bloccare un handler (Thread.sleep, I/O bloccante, attese attive) | Spezzare l'attesa in messaggi: replyTo, timer, messaggi a se stessi |
Usare ask ovunque | Protocolli message-driven con replyTo |
| Un attore monolitico che fa tutto | Tanti attori piccoli e specializzati: costano poco |
| Usare la supervisione per validare l'input | Validare nel protocollo; la supervisione è per i guasti imprevisti |
Esporre ActorContext fuori dall'attore | Protocolli espliciti come tipi (sealed interface, record) |
Per i test, Pekko offre un ActorTestKit che avvia un sistema di prova, e delle probe che fanno da destinatari e verificano i messaggi ricevuti. Anche il test di un solo attore ha bisogno del runtime, quindi somiglia più a un test di integrazione che a uno unitario.
ActorTestKit kit = ActorTestKit.create();
TestProbe<Pong> probe = kit.createTestProbe(Pong.class);
ActorRef<Ping> ponger = kit.spawn(ponger());
ponger.tell(new Ping(probe.getRef()));
probe.expectMessageClass(Pong.class);
kit.shutdownTestKit();
Le domande sull'assignment 3 (struttura degli attori e dei messaggi) sono nella pagina di preparazione.
Un modello di concorrenza in cui tutto è un attore: un'entità con indirizzo, mailbox, stato privato e comportamento, che non condivide memoria e interagisce solo con messaggi asincroni, elaborandoli uno alla volta per intero.
send (invio asincrono), create (nuovo attore con un comportamento iniziale) e become (comportamento e stato per il prossimo messaggio). Corrispondono a chiamata di procedura, astrazione di procedura e assegnamento.
Solo con gli attori di cui conosce l'indirizzo: quelli che ha creato, quelli che conosceva alla creazione e quelli ricevuti nei messaggi.
Attivo: incapsula il proprio flusso di controllo, a differenza di un oggetto o di un monitor. Dentro ha una mailbox, uno stato, un comportamento e un event loop, esplicito o incapsulato nel framework, che estrae un messaggio ed esegue l'handler fino in fondo.
In Pekko e ActorFoundry il ciclo di ricezione sta nel framework e il programmatore scrive solo gli handler: la receive è implicita. In Erlang il programmatore scrive la receive esplicitamente, e può essere selettiva.
Per il macro-step: mentre un handler è bloccato l'attore non elabora nessun altro messaggio. Ogni attesa va quindi spezzata in più handler, e la logica si frammenta: è lo spaghetti asincrono.
Lo mette nello stash e lo reinserisce in mailbox con unstashAll quando cambia stato. In ActorFoundry lo stesso effetto si ottiene con i local synchronization constraints.
Spezzando il lavoro in passi, ciascuno innescato da un messaggio che l'attore invia a se stesso (o da un timer). Tra un passo e l'altro gli altri messaggi, come uno stop, possono essere elaborati.
At-most-once, e ordine FIFO solo per ogni coppia mittente → destinatario. Tra coppie diverse non c'è ordine, nemmeno causale.
30 marzo 2026 (§16–19: introduzione, event loop, implementazioni, pattern) · 17 aprile (§12–18: modello, semantica, event loop, implementazioni, spaghetti asincrono, Erlang, pattern) · 24 aprile (§12–19: modello, primitive, semantica, ActorFoundry, Pekko, Erlang, spaghetti asincrono, proattività) · 27 aprile (§1–16 e quiz: Pekko typed, ping-pong, supervisione, pattern, Erlang, consegna, test, best practice) · 4 maggio (§1 e quiz: ripasso) · 11 maggio (§9–12: ripasso di modello, primitive, implementazioni e spaghetti asincrono). La mappa completa è in coverage-matrix.json.