Parte III — Message passing e attori · Capitolo 15

Il modello ad attori

~25 min di lettura1 laboratorio: un runtime ad attori in miniatura

In questo capitolo

  1. Che cos'è un attore
  2. Dov'è il ciclo degli eventi
  3. Tre implementazioni, un'idea
  4. Come parlano gli attori
  5. Il prezzo del macro-step
  6. Guasti e garanzie di consegna
  7. In pratica
  8. Verifica le tue conoscenze

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.

1. Che cos'è un attore

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.

Le tre primitive

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:

PrimitivaEffettoAnalogo sequenzialePekko (typed)Erlang
sendinvia un messaggio in modo asincrono: il mittente prosegue subitochiamata di proceduraref.tell(msg)Pid ! Msg
createcrea un nuovo attore con un comportamento inizialeastrazione di proceduractx.spawn(behavior, "nome")spawn(Fun)
becomefissa il comportamento (e lo stato) per il prossimo messaggioassegnamento: dà una storia all'attorel'handler restituisce il prossimo Behaviorricorsione 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.

Cinque proprietà semantiche

ProprietàSignificato
Reattività puraUn attore lavora solo quando riceve un messaggio; senza messaggi è fermo.
IncapsulamentoNessuno accede allo stato di un attore: l'unica interazione è inviargli un messaggio.
Macro-stepUn messaggio viene elaborato per intero (run-to-completion) prima di prendere il successivo.
FairnessOgni messaggio inviato viene prima o poi consegnato ed elaborato, senza garanzie di tempo né di ordine.
Location transparencyPer 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.

Cosa gli attori non eliminano

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.

2. Dov'è il ciclo degli eventi

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:

Vantaggi del modelloSvantaggi
Nessuna race condition sullo stato, grazie al macro-stepLogica spezzata in molti handler (spaghetti asincrono)
Ogni attore si ragiona come un programma sequenzialeDifficile esprimere comportamenti proattivi
Concorrenza logica (N attori) separata da quella fisica (M thread)Coordinare attori che devono aspettarsi è laborioso

3. Tre implementazioni, un'idea

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.

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

4. Come parlano gli attori

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.

Dalle slide: idiomi, pattern di messaggistica e storia (liste complete)

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.

5. Il prezzo del macro-step

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.

Laboratorio: un runtime ad attori in miniatura

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.

Le soluzioni

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
}

Proattività: mandarsi messaggi

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

6. Guasti e garanzie di consegna

Supervisione: let it crash

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.

StrategiaEffetto sul figlio fallito
Restartviene ricreato con lo stato iniziale
Resumeriprende con lo stato che aveva, che potrebbe essere corrotto
Stopviene fermato definitivamente
Escalateil 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.

Consegna e ordinamento

SemanticaGaranzia
At-most-onceconsegnato al più una volta: può perdersi, mai duplicato
At-least-onceconsegnato almeno una volta: mai perso, può essere duplicato
Exactly-onceesattamente 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.

7. In pratica

Da evitareDa fare
Bloccare un handler (Thread.sleep, I/O bloccante, attese attive)Spezzare l'attesa in messaggi: replyTo, timer, messaggi a se stessi
Usare ask ovunqueProtocolli message-driven con replyTo
Un attore monolitico che fa tuttoTanti attori piccoli e specializzati: costano poco
Usare la supervisione per validare l'inputValidare nel protocollo; la supervisione è per i guasti imprevisti
Esporre ActorContext fuori dall'attoreProtocolli 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.

Verifica le tue conoscenze

Che cos'è il paradigma ad attori?

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.

Quali sono le primitive del modello?

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.

Con chi può comunicare un attore?

Solo con gli attori di cui conosce l'indirizzo: quelli che ha creato, quelli che conosceva alla creazione e quelli ricevuti nei messaggi.

Un attore è un componente attivo o passivo? Com'è fatto dentro?

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.

Che cosa sono l'event loop incapsulato e la receive implicita?

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.

Perché un handler non deve bloccarsi, e che problema ne deriva?

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.

Che cosa succede in Pekko se un attore non può accettare un messaggio nel suo stato attuale?

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.

Come si rende proattivo un attore?

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.

Che garanzie di consegna e di ordine dà Pekko?

At-most-once, e ordine FIFO solo per ogni coppia mittente → destinatario. Tra coppie diverse non c'è ordine, nemmeno causale.

Da quali lezioni viene questo capitolo

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.