Programmazione Concorrente e Distribuita — Prof. Alessandro Ricci, UNIBO

Programmazione Asincrona, Promise, Async/Await e introduzione agli Attori

2026-03-30174 minModuli 2.1 · 3.2 · Lab Executors registrazione originale

In questa lezione

1. Asynchronous Programming: inquadramento

La programmazione asincrona è uno stile di programmazione sempre più centrale nello sviluppo software moderno. Il suo cuore è la gestione di computazioni, richieste o processi che vengono eseguiti in modo non bloccante, astraendo il più possibile dai thread fisici del sistema operativo.

Il professore introduce il tema partendo da un'osservazione pratica: nei laboratori (Lab 6) avete già iniziato a esplorare i task come unità di lavoro concorrente. L'obiettivo di questa lezione è chiudere il cerchio su task, executor e virtual thread, per poi affrontare il paradigma della programmazione asincrona pura — event loop, promise, async/await — e infine introdurre il modello a attori.

Per l'esame

Il primo assignment richiede di proporre una soluzione concorrente basata su thread e, come versione successiva, di rivederla usando task ed executor dove ritenuto opportuno. La comprensione della separazione tra logica (cosa va eseguito in parallelo) e fisica (quanti thread/core sono disponibili) è un punto centrale della valutazione.

Storicamente, il dibattito tra eventi e thread attraversa l'intera storia dei sistemi concorrenti. Lavori fondamentali come "Why threads are a bad idea (for most purposes)" di Ousterhout (1996) e "The Problem with Threads" di Edward Lee (2006) hanno mostrato come l'approccio event-driven offra vantaggi in termini di assenza di race condition e deadlock basso livello, mentre i thread rimangono insostituibili per lo sfruttamento del parallelismo hardware. La sfida vera è metterli insieme.

2. Panorama dei modelli asincroni

Il panorama attuale della programmazione asincrona include quattro grandi famiglie di approcci, spesso combinati tra loro:

Architettura a event loop: un singolo flusso di controllo attende eventi e dispaccia handler. È la base del Reactor Pattern. Esempi: JavaScript nel browser, Node.js, Vert.x, tutte le GUI.

Funzioni asincrone con continuation-passing style: la callback è l'ultimo parametro e viene invocata quando il risultato è pronto. Soffre del problema della "pyramid of doom" quando si annidano chiamate asincrone.

Oggetti proxy che rappresentano un valore futuro. Permettono di appiattire l'annidamento delle callback grazie al chaining (.then()). Una promise può essere risolta o rifiutata una sola volta (immutabile).

Estensione linguistica che permette di scrivere codice asincrono con sintassi sincrona usando le parole chiave async e await. Sotto il cofano usa le coroutine. Supportata ormai da tutti i linguaggi moderni (JavaScript, Python, C#, Kotlin, Dart, Rust).

Idea chiave

Il professore sottolinea che questi modelli non sono in competizione: gli event loop sono l'architettura di esecuzione, le promise sono il meccanismo di composizione, e async/await è la sintassi che rende il tutto leggibile. Ciascuno opera a un diverso livello di astrazione.

3. Event Loop e Reactor Pattern

L'architettura a event loop è il cuore pulsante della programmazione asincrona. Il modello astratto è sorprendentemente semplice:

loop {
    Event ev = waitForEvent(eventQueue);
    Handler handler = selectHandler(ev);
    execute(handler);
}

Un unico thread di controllo esegue questo ciclo per tutta la vita del programma. La event queue contiene gli eventi generati dall'ambiente (click, timer, risposte di rete) o dagli stessi event handler. Gli handler vengono eseguiti atomicamente rispetto al ciclo: se un evento arriva mentre un handler è in esecuzione, verrà servito solo quando l'handler corrente termina.

Il Reactor Pattern (Schmidt et al., 2000) formalizza questa architettura come pattern di progettazione per sistemi concorrenti e distribuiti. I partecipanti principali sono:

sequenceDiagram
    participant Client as Client
    participant ID as Initiation Dispatcher
    participant EH as Event Handler
    participant CEH as Concrete EH

    Client->>ID: register handle + handler
    Client->>ID: event occurs on handle
    ID->>EH: handle_event(handle)
    EH->>CEH: perform app logic
    CEH-->>EH: result
    EH-->>ID: done
    
Nota del redattore

Il Reactor Pattern è la base su cui sono costruiti framework come Node.js, Vert.x, Netty e tutti i server asincroni moderni. La separazione tra dispatcher (infrastruttura) e handler (logica applicativa) è la chiave della sua flessibilità.

4. Never-Blocking Rule e Multiple Event Loops

Dal modello dell'event loop discende una regola fondamentale, che il professore chiama never-blocking rule:

Regola fondamentale

Gli event handler non devono mai bloccarsi e devono sempre terminare. Una chiamata bloccante o un ciclo infinito all'interno di un handler bloccherebbero l'intero event loop, impedendo il processamento di tutti gli eventi successivi nella coda.

La soluzione è sostituire ogni operazione potenzialmente bloccante con una richiesta asincrona. La richiesta viene servita da altri thread (esterni all'event loop), e quando il risultato è pronto genera un evento nella coda. Questo è il meccanismo che permette di fare I/O, accesso a database o computazioni lunghe senza mai bloccare il ciclo principale.

Un sistema può essere composto da più componenti attivi, ciascuno con il proprio event loop. In questo caso i componenti:

Idea chiave

Questa architettura a event loop multipli è esattamente quella che ritroveremo nel modello Actor e in framework come Vert.x, dove ogni verticle ha il proprio event loop e i verticle comunicano tramite messaggi asincroni sull'event bus.

5. Async Functions e Continuation-Passing Style

Data la never-blocking rule, sorge il problema centrale: come ottenere il risultato di una operazione asincrona senza bloccarsi? La risposta è il modello a callback, che in termini di programmazione funzionale si chiama Continuation-Passing Style (CPS).

Nel CPS, una funzione prende un argomento extra — la continuazione (tipicamente una callback) — e quando ha calcolato il risultato, invece di restituirlo (return), lo passa alla continuazione. Lo stack non viene usato: il controllo passa direttamente alla funzione continuazione.

// CPS in Java (esempio astratto dalle slide)
void sum(int x, int y, Consumer<Integer> cont) {
    cont.accept(x + y);
}
sum(1, 3, (res) -> { System.out.println(res); });

Il professore fa un esempio concreto in JavaScript, mostrando come una funzione asincrona come asyncInc prenda un valore e una callback, esegua l'incremento nel ciclo successivo dell'event loop (tramite setTimeout(..., 0)) e chiami la callback con il risultato.

L'annotated code seguente mostra il flusso:

La callback (continuazione) viene eseguita dall'event loop nello stesso thread logico che ha innescato la chiamata asincrona — questo è il punto cruciale che evita le race condition. Il thread fisico che ha eseguito materialmente il task asincrono (es. una lettura di rete) è diverso, ma la continuazione torna sempre all'event loop.

6. Callback Hell e Pyramid of Doom

Nonostante l'eleganza concettuale, il CPS puro porta a un problema ben noto: il callback hell (o pyramid of doom). Quando si devono eseguire più operazioni asincrone in sequenza, l'annidamento delle callback cresce orizzontalmente e verso destra, rendendo il codice illeggibile e difficile da mantenere.

// Esempio dalle slide: la "piramide"
step1(function(result1) {
    step2(function(result2) {
        step3(function(result3) {
            // e così via...
        });
    });
});

Un esempio concreto mostrato dal professore è quello del server HTTP in Node.js:

http.createServer((request, response) => {
    let uri = url.parse(request.url).pathname;
    let filename = path.join(process.cwd(), uri);
    path.exists(filename, (exists) => {
        if (exists) {
            fs.readFile(filename, (err, data) => {
                response.writeHead(200);
                response.end(data);
            });
        } else {
            response.writeHead(404);
            response.end();
        }
    });
}).listen(8080);
Problema

Il codice si sviluppa in orizzontale: per capire il flusso bisogna spostarsi a destra, giù, e poi tornare su. La modularità e la riusabilità vengono compromesse. Come dice un developer anonimo citato nelle slide: "I love async, but I can't code like this."

I problemi principali del callback hell sono:

7. Promise: stati e API

Le Promise (proposte originariamente da Friedman e Wise nel 1976) sono la risposta al callback hell. Una Promise è un oggetto proxy che rappresenta il risultato futuro di una operazione asincrona. Le proprietà fondamentali sono:

Esplora i tre stati con il widget interattivo qui sotto:

// Creazione di una Promise (esempio dalle slide)
function delayWithRandom(t, p) {
    let promise = new Promise((resolve, reject) => {
        setTimeout(() => {
            let r = Math.random();
            if (r > p) {
                resolve(r);   // successo
            } else {
                reject(r);    // fallimento
            }
        }, t);
    });
    return promise;
}

let promise = delayWithRandom(1000, 0.5);
promise.then(
    (r) => { console.log("successo:", r); },
    (r) => { console.log("fallimento:", r); }
);

Il costruttore new Promise((resolve, reject) => { ... }) riceve una funzione executor che avvia subito il lavoro asincrono. Le funzioni resolve e reject sono i callback che, quando chiamati, portano la promise nello stato rispettivo.

8. Promise Chaining e composizione

La caratteristica più potente delle Promise è che .then() restituisce a sua volta una Promise, permettendo il chaining. Questo appiattisce la pyramid of doom in una sequenza lineare di chiamate:

// Promise chaining: la piramide diventa sequenza (dalle slide)
function delayWithRand(t) {
    return new Promise((resolve) => {
        setTimeout(() => { resolve(Math.random()); }, t);
    });
}

let promise = delayWithRand(1000);
promise
    .then((r) => {
        console.log("successo:", r);
        return delayWithRand(1500);
    })
    .then((r) => {
        console.log("successo:", r);
        return delayWithRand(500);
    })
    .then((v) => {
        console.log(v);
    });

Il professore sottolinea due casi d'uso fondamentali:

Sequenza (task1 ; task2)

asyncFunc1(...)
    .then((v) => { return asyncFunc2(...); })
    .then((v) => { return asyncFunc3(...); });

Parallelo (task1 | task2)

let myPromise = asyncFunc1(...);
myPromise.then((v) => { return asyncFunc2(...); });
myPromise.then((v) => { return asyncFunc3(...); });

Per il parallelismo con JOIN, la Promise API fornisce Promise.all:

Promise.all([delayWithRand(1000), delayWithRand(2000)])
    .then((values) => {
        console.log(values[0]); // risultato prima promise
        console.log(values[1]); // risultato seconda promise
    });

Esiste anche Promise.race, che si risolve appena la prima tra le promise si stabilizza (successo o errore).

Attenzione

Le promise sono eager: appena costruite iniziano subito il lavoro asincrono. Questo può causare problemi se non si vuole avviare subito la computazione. Inoltre, le promise non sono cancellabili e non funzionano bene con i cicli (for tradizionale): per iterare asincronamente serve ricorrere alla ricorsione o ad async/await.

9. Async/Await: la sintassi sincrona per codice asincrono

L'evoluzione finale della programmazione asincrona è l'introduzione delle parole chiave async e await, standardizzate in JavaScript con ES2017 e ormai presenti in tutti i linguaggi moderni. L'obiettivo è permettere di scrivere codice asincrono con uno stile sincrono, mantenendo però la semantica asincrona.

// Stile asincrono con async/await
async function main() {
    console.log("before");
    let r1 = await delayWithRand(1000);
    console.log("successo:", r1);
    let r2 = await delayWithRand(1500);
    console.log("successo:", r2);
    console.log("after");
}
main();

L'operatore await si applica a una Promise e ne estrae il valore (o lancia un'eccezione se la promise è rejected). Durante l'attesa, il flusso di controllo viene ceduto: l'event loop può servire altri eventi. Quando la promise è risolta, la funzione async riprende dal punto in cui si era sospesa.

Il professore mostra due grafici di esecuzione per async chiamata con e senza await:

async function pause(t) {
    console.log("before");
    let promise = new Promise((resolve) => {
        setTimeout(() => { resolve(); }, t);
    });
    await promise;
    console.log("after"); // eseguito dopo il timer
}
console.log("before call");
pause(1000); // <-- senza await qui
console.log("after call");
// Output: before call, before, after call, after

La funzione parte, stampa "before", await cede il controllo, e torna subito al chiamante che stampa "after call". Solo dopo il timer, "after" viene stampato.

async function main() {
    console.log("before call");
    await pause(1000); // <-- con await qui
    console.log("after call");
}
main();
// Output: before call, before, after, after call

Qui await main() aspetta che tutta la funzione main sia completata. "after call" viene stampato solo dopo che pause ha terminato.

Per l'esame

Un errore comune è dimenticare di fare await di una funzione async. Senza await, la funzione parte ma il controllo prosegue immediatamente, portando a comportamenti non deterministici. Il professore lo chiama il "design clash" tra stile sincrono e asincrono: serve disciplina per non mescolarli in modo scorretto.

Limitazioni di async/await

10. Coroutine e Fiber: il meccanismo di basso livello

Sia async/await che i virtual thread condividono un meccanismo di base: le coroutine. Una coroutine è una generalizzazione del concetto di sottoprogramma (subroutine) che permette di sospendere e riprendere l'esecuzione in punti specifici, senza perdere lo stato locale.

Mentre una subroutine tradizionale ha un solo punto di ingresso e un solo punto di uscita (il return), una coroutine può avere più punti di sospensione (yield o await) e può essere ripresa dal punto in cui si era fermata.

flowchart LR
    subgraph Subroutine
        A[inizio] --> B[esecuzione] --> C[return]
    end
    subgraph Coroutine
        D[inizio] --> E[esecuzione] --> F[yield/await] --> G[sospesa]
        G --> H[ripresa] --> I[esecuzione] --> J[yield/await]
        J --> G
        J --> K[return final]
    end
    

Le fiber sono un concetto affine ma a livello di sistema: sono thread leggeri che usano cooperativa invece che preemptive multitasking. I virtual thread di Java (JDK 21+) sono la più recente incarnazione mainstream delle fiber. La differenza sottile: le coroutine sono un costrutto linguistico (un modo di controllare il flusso), le fiber sono un costrutto di sistema (un thread che non esegue in parallelo).

Il professore mostra un esempio in Kotlin, dove le coroutine sono parte integrante del linguaggio:

import kotlinx.coroutines.*

suspend fun greetDelayed(delayMillis: Long) {
    delay(delayMillis)
    println("Hello, World!")
}

fun main(args: Array<String>) = runBlocking {
    println("before async call")
    val result = async {
        println("inside the async call")
        delay(1000)
        println("exiting the async call")
        100
    }
    println("after the async call, before greet")
    greetDelayed(200)
    println("after greet trigger")
    println("${result.await()}")
}

Output (dalle slide): before async call, after the async call, before greet, inside the async call, Hello, World!, after greet trigger, exiting the async call, 100

11. Virtual Thread in Java

I Virtual Thread (introdotti in JDK 21 come funzionalità stabile) rappresentano il tentativo di Java di portare le fiber nel mondo mainstream, mantenendo la compatibilità con l'API java.lang.Thread esistente.

L'idea è quella di disaccoppiare il thread logico (il "filo" di esecuzione) dal thread fisico del sistema operativo. I virtual thread sono gestiti interamente dalla JVM, che li monta e smonta sui thread fisici (detto mounting/unmounting) in modo trasparente. Quando un virtual thread esegue un'operazione bloccante (es. I/O, wait(), join()), la JVM lo sospende e riutilizza il thread fisico per un altro virtual thread.

// Creazione di un Virtual Thread (esempio dalle slide del Lab)
Thread.ofVirtual()
    .name("my-virtual-thread")
    .start(() -> {
        System.out.println("Ciao da un virtual thread!");
    });

// Creare 100.000 virtual thread è possibile e leggero
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (int i = 0; i < 100_000; i++) {
        executor.submit(() -> {
            // computazione leggera
        });
    }
}
Idea chiave

La differenza fondamentale con async/await: con i virtual thread, non serve marcare esplicitamente i punti di sospensione. Ogni chiamata bloccante diventa automaticamente un punto di unmounting/remounting. Il codice rimane sincrono tradizionale, ma il thread sottostante non viene mai bloccato. Il professore nota che questo è simile a quanto fa C# con i suoi task, ma in Java è più trasparente: non serve await esplicito.

Dal punto di vista architetturale, i virtual thread non forniscono un trattamento first-class per eventi e computazioni reattive — questo è il punto del dibattito tuttora aperto tra virtual thread e async programming (Brian Goetz, 2022). I virtual thread risolvono il problema del costo dei thread, ma non offrono un modello per gestire eventi e reattività.

12. Task-Oriented Programming ed Executors

Prima di immergersi completamente nell'asincrono, il professore dedica una parte importante della lezione al task-oriented programming in Java, che fa da ponte tra il mondo dei thread e quello degli event loop.

Un task è un'unità di lavoro astratta, discreta e indipendente, disaccoppiata dal concetto di thread. L'idea è di adottare una strategia divide-et-impera: identificare i confini dei task (attività indipendenti, che non dipendono dallo stato/risultato di altri task) e scegliere una execution policy adeguata.

Le tre strategie di esecuzione tradizionali sono:

Strategia Descrizione Pro Contro
Sequenziale Un singolo thread serve le richieste una dopo l'altra Semplice, nessuna concorrenza Throughput e reattività bassissimi
Thread-per-task Un nuovo thread per ogni task Semplice, migliora throughput Overhead di creazione, consumo risorse, instabilità
Executor con pool Thread pool dimensionato sulle risorse disponibili Stabile, efficiente, separa logica da fisica Richiede attenzione a deadlock e blocking
// Esempio: TaskExecutionWebServer con FixedThreadPool (dalle slide Lab)
class TaskExecutionWebServer {
    private static final int NTHREADS
        = Runtime.getRuntime().availableProcessors() + 1;
    private static final Executor exec =
        Executors.newFixedThreadPool(NTHREADS);

    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            Socket connection = socket.accept();
            exec.execute(() -> {
                handleRequest(connection);
            });
        }
    }
}

Il thread pool segue il pattern producer-consumer: i produttori sono le attività che inviano task, i consumatori sono i thread del pool che li eseguono. I thread del pool eseguono un ciclo implicito: prendi il prossimo task dalla coda, eseguilo, torna in attesa.

Il professore mette in guardia da un pericolo subdolo:

Attenzione: deadlock con thread pool

Se i task all'interno del pool usano una barriera o attendono il completamento di altri task, e il pool ha un numero limitato di thread, si può verificare un deadlock: tutti i thread del pool sono bloccati in attesa, e nessuno può eseguire i task che sbloccherebbero la situazione. La soluzione "sbagliata" ma immediata è creare un pool con tanti thread quanti sono i task. La soluzione corretta è ripensare la struttura dei task per evitare attese bloccanti all'interno del pool.

13. ExecutorService, Callable e Future

L'interfaccia ExecutorService estende Executor aggiungendo il controllo del ciclo di vita e il supporto per task con risultato. Ecco la gerarchia:

flowchart BT
    Exec["<<interface>> Executor\nexecute(Runnable)"] --> ExecService["<<interface>> ExecutorService\nsubmit(Callable<T>): Future<T>\nshutdown(), awaitTermination()"]
    ExecService --> SchedExec["<<interface>> ScheduledExecutorService\nschedule(), scheduleAtFixedRate()"]
    ExecFactory["Executors (factory)\nnewFixedThreadPool(), newCachedThreadPool(),\nnewSingleThreadExecutor(), newScheduledThreadPool()"]
    ExecFactory -.->|crea| ExecService
    

Un task che produce un risultato si definisce tramite l'interfaccia Callable<V> (che ha V call() throws Exception invece di void run()) e viene sottomesso con submit(), che restituisce un oggetto Future<V>.

// Callable + Future pattern
ExecutorService exec = Executors.newFixedThreadPool(4);
Future<Integer> future = exec.submit(() -> {
    // computazione lunga...
    return 42;
});

// ... altro lavoro concorrente ...

Integer risultato = future.get(); // bloccante se non ancora pronto

Il ciclo di vita di un ExecutorService ha tre stati:

Per attendere il completamento: awaitTermination(timeout, unit) sospende il chiamante finché tutti i task non sono finiti o scade il timeout.

14. Cancellazione cooperativa e interruption

La cancellazione di un'attività concorrente segue un approccio cooperativo, non asincrono/preemptive. In Java, il meccanismo principale è l'interruption:

// Pattern di cancellazione cooperativa (dalle slide)
public class PrimeGenerator implements Runnable {
    private volatile boolean cancelled;

    public void run() {
        BigInteger p = BigInteger.ONE;
        while (!cancelled) {
            p = p.nextProbablePrime();
            // ... elaborazione ...
        }
    }

    public void cancel() { cancelled = true; }
}

Il flag volatile garantisce che le scritture di cancel() siano visibili immediatamente al thread in esecuzione. Se il thread è bloccato su una wait(), sleep() o join(), si usa il meccanismo di interrupt() che sblocca questi stati e lancia InterruptedException.

Per l'esame

La differenza tra shutdown() (graceful: completa i task in coda) e shutdownNow() (abrupt: tenta di cancellare) è un classico da esame. shutdownNow() restituisce la lista dei task che non sono stati avviati. La poison pill è una tecnica alternativa: si inserisce un oggetto "sentinella" nella coda condivisa che segnala ai consumatori di terminare.

15. Fork-Join Framework e Structured Concurrency

Il Fork-Join Framework (Java SE 7) estende il modello degli executor per gestire algoritmi map/reduce e divide-et-impera dove la topologia dei dati non è nota in anticipo. Le classi principali sono:

// Schema fork-join: merge sort parallelo (idea dalle slide)
class MergeSortTask extends RecursiveAction {
    private int[] array;
    private int lo, hi;

    protected void compute() {
        if (hi - lo < THRESHOLD) {
            Arrays.sort(array, lo, hi);
        } else {
            int mid = (lo + hi) / 2;
            var left = new MergeSortTask(array, lo, mid);
            var right = new MergeSortTask(array, mid, hi);
            left.fork();   // fork asincrono
            right.compute();
            left.join();   // attendi il risultato
            merge(array, lo, mid, hi);
        }
    }
}

Più recentemente, Java ha introdotto la Structured Concurrency (JEP 437, JDK 20+), che estende il modello fork-join con un approccio strutturato: se un task si divide in sottotask concorrenti, questi devono tornare tutti allo stesso blocco di codice (scope) del task padre.

// StructuredTaskScope: esempio dalle slide
Response handle() throws ExecutionException, InterruptedException {
    try (var scope = new StructuredTaskScope.ShutdownOnFailure()) {
        Future<String> user = scope.fork(() -> findUser());
        Future<Integer> order = scope.fork(() -> fetchOrder());
        scope.join();            // attendi entrambi
        scope.throwIfFailed();   // propaga errori
        return new Response(user.resultNow(), order.resultNow());
    } // tutti i sottotask sono garantiti completati qui
}
Idea chiave

La Structured Concurrency risolve un problema di visibilità: con i thread tradizionali, un task avviato ma non completato può "fuggire" e rimanere in esecuzione anche dopo che il metodo chiamante è terminato. Con StructuredTaskScope, la durata dei sottotask è lessicalmente delimitata dal blocco try-with-resources.

16. Attori: teoria del modello

Il modello Actor (o Attori) è stato originariamente introdotto da Carl Hewitt e colleghi al MIT negli anni '70, nel contesto dell'intelligenza artificiale. È stato successivamente sviluppato da Gul Agha e Akinori Yonezawa negli anni '80 e '90 come unificazione tra programmazione orientata agli oggetti e concorrenza (Concurrent Object-Oriented Programming).

Oggi il modello gioca un ruolo centrale nel mainstream, come alternativa al multi-threading tradizionale. Linguaggi e framework come Erlang, Scala/Akka, HTML5 Web Workers e Dart Isolate ne sono esempi concreti.

Per l'esame

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:

Il modello è fortemente legato all'idea originale di Alan Kay di programmazione orientata agli oggetti, dove il punto chiave era il message passing, non l'ereditarietà o il polimorfismo.

17. Attori: primitive e semantica

Il comportamento di un attore si basa su sole tre primitive (azioni fondamentali):

Primitiva Descrizione Analogia sequenziale
send Inviare un messaggio asincrono a un attore Procedura invocation
create Creare un nuovo attore con un dato comportamento Procedura abstraction
become Specificare un nuovo comportamento per i messaggi futuri Assegnazione di stato (history-sensitive)
Idea chiave

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

Semantica di esecuzione: macro-step

L'architettura di controllo di un attore è un messaggio loop (implicitamente gestito dal framework):

loop {
    msg <- waitForMsg();          // attesa bloccante (solo qui)
    handler <- selectHandler(msg); // dispatch del messaggio
    execute(handler);              // esecuzione atomica
}

La semantica macro-step (run-to-completion) garantisce che un handler venga eseguito completamente prima di servire il prossimo messaggio. Questo elimina le race condition all'interno di un singolo attore, ma introduce la stessa sfida della never-blocking rule vista per gli event loop: gli handler non devono bloccarsi.

Il widget seguente mostra il ciclo di vita di un attore:

Le proprietà semantiche chiave del modello sono:

18. Implementazioni: Akka, ActorFoundry, Erlang

Il modello Actor ha diverse implementazioni, con varianti semantiche significative. Le più importanti presentate in questa lezione sono:

ActorFoundry (ambito accademico)

Framework Java sviluppato dal gruppo di Agha all'UIUC. Usa annotazioni (@message) per marcare i metodi che sono handler di messaggi. Il programmatore estende la classe Actor e definisce i metodi che saranno invocati alla ricezione di un messaggio.

// ActorFoundry: esempio Ping/Pong (dalle slide)
public class PingActor extends Actor {
    ActorName otherPinger;

    @message
    public void start(ActorName other) {
        otherPinger = other;
        send(otherPinger, "ping", self(), "called from " + self());
    }

    @message
    public void ping(ActorName caller, String msg) {
        send(stdout, "println", "Received ping (" + msg + ") from " + caller);
        send(caller, "alive", self().toString() + " is alive");
    }

    @message
    public void alive(String reply) {
        send(stdout, "println", "Received " + reply + " from pinged actor");
    }
}

Akka (ambito industriale)

Framework maturo per Java e Scala, con supporto a fault tolerance (modello "let it crash" di Erlang), clustering, e pattern di messaggistica avanzati. Supporta sia untyped actors (Java) che typed actors (Scala).

// Akka untyped actor in Java (dalle slide)
import akka.actor.UntypedActor;

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", getSelf());
        } else {
            unhandled(message);
        }
    }
}

Erlang (il pioniere)

Linguaggio funzionale sviluppato in Ericsson dal 1987 per applicazioni di telecomunicazione. Offre un supporto nativo alla concorrenza con processi leggeri (BEAM virtual machine) e comunicazione tramite message passing. I processi Erlang sono concettualmente identici agli attori.

% 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

Erlang usa explicit receive: il programmatore scrive esplicitamente il ciclo di ricezione dei messaggi, con pattern matching e guardie. Questo offre maggiore controllo ma richiede più disciplina. La differenza fondamentale con l'approccio a event loop implicito (ActorFoundry, Akka) è chiara: nel primo caso il loop è embedded nel framework, nel secondo è esplicito nel codice.

19. Actor Idioms e pattern di messaggistica

Come ogni paradigma, anche il modello Actor ha i suoi pattern ricorrenti (Schumacher, AGERE! 2012). I principali sono:

Future

Un attore che funge da proxy per un risultato non ancora disponibile. È un modo per implementare comunicazione sincrona (RPC-like) all'interno di un modello asincrono, massimizzando la concorrenza: il future può essere passato ad altri attori prima di essere risolto.

Serializer

Un attore che serializza l'accesso a una risorsa condivisa, garantendo che le operazioni siano eseguite una alla volta sulla sua mailbox.

Fork-Join

Un pattern per dividere un compito in sotto-compiti paralleli e aggregare i risultati, simile all'omonimo pattern nel mondo dei thread ma realizzato interamente via message passing.

Il professore accenna anche al problema della pro-attività: gli attori sono entità puramente reattive (lavorano solo quando arriva un messaggio). Come si modella un comportamento pro-attivo, come calcolare e stampare i primi 100 numeri primi fermandosi se arriva un messaggio di stop? Le soluzioni includono:

Nota del redattore

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.

Ordinamento dei messaggi e sincronizzazione

Una delle sfide del modello Actor è che non ci sono garanzie sull'ordine di ricezione dei messaggi — anche quando due messaggi vengono inviati dallo stesso mittente in sequenza. L'ordinamento deve essere ottenuto tramite pattern di scambio messaggi.

Per gestire la sincronizzazione locale, alcuni framework offrono vincoli di sincronizzazione locale (Local Synchronization Constraints), come in ActorFoundry con le annotazioni @Disable, o lo stashing di Akka:

// Akka stashing pattern (dalle slide)
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()
    }
}

Verifica le tue conoscenze

Qual è la differenza fondamentale tra thread e attori per la comunicazione?

Nei thread la comunicazione avviene tramite memoria condivisa (con variabili mutabili, race condition, lock, synchronized). Negli attori la comunicazione avviene esclusivamente tramite messaggi asincroni (message passing). Lo stato di un attore non è mai accessibile direttamente da un altro attore.

Cosa stabilisce la "never-blocking rule" nell'event loop?

Gli event handler non devono mai contenere chiamate bloccanti (I/O, attese) e devono sempre terminare. Una chiamata bloccante blocca l'intero event loop, impedendo il processamento di tutti gli eventi successivi. Le operazioni bloccanti devono essere sostituite da richieste asincrone servite da thread esterni che poi generano un evento nella coda.

Qual è il problema principale delle promise (eager) e come lo risolve async/await?

Le promise sono eager: appena costruite iniziano subito il lavoro. Inoltre, con .then() non si possono passare parametri facilmente alle funzioni chiamate. Async/await risolve entrambi i problemi: l'avvio è controllato dalla chiamata esplicita della funzione async, e i parametri si passano normalmente come in una chiamata sincrona.

Cosa significa "macro-step semantics" nel modello Actor?

Significa che ogni handler di messaggio viene eseguito completamente (run-to-completion) prima che l'attore possa servire il messaggio successivo. Questo evita race condition all'interno dell'attore (lo stato non viene mai interrotto a metà di un handler) ma richiede che gli handler non siano bloccanti, proprio come nell'event loop.

Come si differenziano i virtual thread dai thread platform tradizionali?

I virtual thread sono gestiti interamente dalla JVM (non dal sistema operativo), sono molto più leggeri (se ne possono creare centinaia di migliaia), e il loro mounting/unmounting sui thread fisici è automatico e trasparente. Rispetto ad async/await, non serve marcare esplicitamente i punti di sospensione: ogni operazione bloccante diventa automaticamente un punto di unmounting.

Qual è lo scopo di StructuredTaskScope in Java?

Strutturare un task come un nucleo di sottotask concorrenti che devono tutti tornare allo stesso blocco lessicale (il scope). Garantisce che tutti i sottotask siano completati quando il blocco termina (grazie al try-with-resources), eliminando il problema dei thread "fuggiti". Propaga automaticamente le eccezioni dal sottotask al padre.

Quali sono i tre stati di una Promise?

Pending — appena creata, il risultato non è ancora disponibile. Resolved (fulfilled) — l'operazione asincrona è completata con successo, il valore è disponibile. Rejected — l'operazione è fallita con un errore. Una volta che una promise è settled (resolved o rejected), il suo stato e valore sono immutabili.

Cosa sono la "poison pill" e lo "stashing" nel contesto degli attori?

La poison pill è un oggetto sentinella inserito nella coda dei messaggi che segnala a un attore/consumer di terminare. Lo stashing (Akka) è un meccanismo per accodare temporaneamente i messaggi ricevuti quando l'attore non è nello stato giusto per processarli, per poi riprenderli quando il comportamento cambia (es. con context.become()).