Questa lezione completa la seconda parte del corso, dedicata alla programmazione asincrona, e introduce la programmazione reattiva. Nella prima parte abbiamo approfondito i concetti fondamentali della programmazione concorrente con il modello multi-threading sincrono: componenti attivi con un flusso di controllo che esegue sequenze di azioni potenzialmente bloccanti, con sospensione del thread gestita dal sistema operativo.
Con la programmazione asincrona abbiamo cambiato prospettiva: non più thread che si bloccano in attesa, ma componenti reattivi che rispondono a stimoli esterni — richieste di rete, input da sensori, azioni dell'utente — senza mai bloccarsi. Oggi percorriamo l'evoluzione che porta dagli event loop alle callback, dalle Promise fino ad async/await e alle coroutine, per poi approdare al paradigma reattivo con Reactive Extensions.
La programmazione asincrona è prima di tutto event-driven: costruiamo sistemi che reagiscono a eventi senza mai rimanere bloccati in attesa. Il thread non si sospende mai; se deve attendere un risultato, si mette a disposizione per elaborare altri eventi.
Multi-threading sincrono — i componenti attivi hanno un flusso di controllo che definisce un processo come sequenza di azioni bloccanti. La sospensione del flusso avviene a livello OS (sospensione del thread). Modello classico: ogni richiesta = un thread. Problemi: context switching costoso, deadlock, race condition, memoria per ogni stack.
Programmazione asincrona / event-driven — i componenti reagiscono a stimoli esterni. Un singolo thread (event loop) gestisce tutti gli eventi in coda. Le funzioni non si bloccano mai: avviano un'operazione asincrona e registrano una continuazione che verrà eseguita quando il risultato è pronto. Modello: event loop + callback/promise/async-await.
La programmazione asincrona non è solo un insieme di tecniche: è un cambio radicale nel modo di pensare il flusso di controllo. Invece di avere uno stack di chiamate che cresce e decresce, ogni operazione asincrona spezza il flusso in due parti: l'avvio e la continuazione. È qui che nascono la complessità e la potenza del modello.
Il cuore della programmazione asincrona è l'event loop: un singolo thread di controllo che rimane in attesa di eventi e, quando uno si verifica, esegue il gestore associato. Non ci sono più thread sospesi in attesa: l'event loop processa un evento alla volta, dal primo all'ultimo, in modo atomico.
flowchart LR
EQ[("Coda eventi")] --> EL["Event Loop"]
EL -->|"preleva evento"| H["Handler"]
H -->|"esegue
atomicamente"| COMP
COMP -->|"può generare
nuovi eventi"| EQ
Schema semplificato del modello event loop: un singolo thread attende eventi, esegue gli handler in modo atomico, e gli handler stessi possono generare nuovi eventi.
Gli event handler non devono mai bloccarsi e devono sempre terminare. Una chiamata bloccante o un ciclo infinito fermerebbero l'event loop, impedendo il processamento degli eventi successivi. Se serve eseguire un'operazione lunga, la si affida a un task asincrono eseguito da altri thread (tipicamente un pool), che al termine inserirà un evento nella coda.
L'event loop può esistere in più istanze: un sistema può essere composto da più componenti attivi, ciascuno col proprio event loop, che non condividono memoria ma interagiscono generando eventi. È lo stesso modello logico del Reactor pattern di Schmidt (POSA2), dove un Initiation Dispatcher smista gli eventi agli Event Handler registrati.
Il professore sottolinea un punto cruciale: in un modello event loop puro, non ci possono essere race condition. Perché? Perché non c'è condivisione di stato tra thread concorrenti: c'è un solo thread che accede allo stato, in modo sequenziale. Le race condition nascono solo se si introduce un meccanismo come async/await che permette a un blocco di codice di sospendersi e riprendersi, come vedremo più avanti.
La never-blocking rule è il fondamento della programmazione asincrona. Dovete saper spiegare perché un handler non si blocca mai, come vengono gestite le operazioni lunghe (delegate a thread esterni che poi ripubblicano eventi), e perché in un event loop puro non ci sono race.
Dato che gli handler non possono bloccarsi, come facciamo a ottenere il risultato di un'operazione asincrona? La risposta è il Continuation Passing Style (CPS): invece di restituire un valore di ritorno, la funzione riceve un parametro aggiuntivo — una continuation — che viene chiamata quando il risultato è pronto.
Il CPS non è nato con la concorrenza: è uno stile di programmazione che può essere applicato anche al codice sequenziale. L'idea è che ogni funzione, invece di restituire un risultato tramite return, passa il risultato alla continuazione. In questo modo non serve più uno stack: la computazione diventa una sequenza di chiamate, senza mai tornare indietro.
Ecco come si trasforma una funzione normale in CPS:
Notate che in CPS sum non ha più un tipo di ritorno: restituisce void perché il "risultato" viene passato alla continuazione. Il corpo calcola x + y e poi chiama cont(x + y). Nel caso asincrono, la continuazione viene chiamata non immediatamente, ma quando il risultato dell'operazione asincrona è disponibile.
Nel CPS non si usa più lo stack per le chiamate di ritorno: ogni funzione chiama la continuazione e "non torna mai indietro". Questo rende il CPS un formato intermedio popolare per i compilatori di linguaggi funzionali, permettendo ottimizzazioni come la tail call optimization.
Il professore cita un aneddoto: "Spesso nei colloqui chiedono cosa vuol dire CPS e molti rispondono 'Continuous Passing Style' invece di 'Continuation Passing Style' — ma il senso è lo stesso: passare una funzione che rappresenta la continuazione della computazione."
Il CPS fu introdotto da Gerald Jay Sussman e Guy L. Steele Jr. nel 1975 mentre lavoravano su Scheme, ed è diventato il formato intermedio di riferimento per i compilatori di molti linguaggi funzionali.
Nel contesto asincrono, le callback sono esattamente continuazioni: funzioni che vengono chiamate quando il risultato della computazione asincrona è pronto. La differenza fondamentale rispetto al CPS puro è che la continuazione non viene invocata immediatamente, ma in un ciclo successivo dell'event loop.
Un aspetto importante è chi invoca la continuazione/callback. Il professore distingue due possibilità:
Con le callback possiamo scrivere codice asincrono, ma la complessità emerge quando dobbiamo coordinare più operazioni. Il professore mostra due pattern fondamentali:
asyncTask1(…, (res1) => {
asyncTask2(…, (res2) => {
…
}
})
asyncTask1(…, (res1) => { … })
asyncTask2(…, (res2) => { … })
Il problema emerge quando si annidano più callback: si crea la cosiddetta callback hell o pyramid of doom.
step1(function(result1){
step2(function(result2){
step3(function(result3){
// e così via…
}
}
})
Il professore evidenzia tre problemi principali di questo stile:
Fatevi un esempio concreto: pensate a un'app che deve caricare un utente, poi il suo profilo, poi la foto. Con le callback avreste tre livelli di annidamento. Dovete saper spiegare perché è un problema e come Promise e async/await lo risolvono.
Il termine "pyramid of doom" è stato coniato dalla comunità JavaScript per descrivere la forma triangolare che assume il codice quando le callback si annidano. La citazione "I love async, but I can't code like this" riportata nelle slide proviene da un complaint sul Node.js Google Group.
Le Promise, proposte originariamente nel 1976 da Daniel Friedman e D. Wise come proxy per risultati non ancora calcolati (simili ai future), sono diventate la soluzione al callback hell. Una Promise rappresenta il completamento (o fallimento) futuro di un'operazione asincrona, incapsulando l'azione asincrona stessa.
Una Promise può essere risolta (resolved) o rigettata (rejected) una e una sola volta. Una volta risolta, è immutabile: il suo stato e valore non cambiano mai più. Questa è la proprietà chiave che permette ragionamenti deterministici sul codice asincrono.
L'API fondamentale delle Promise si basa sul metodo then, che permette di agganciare callback per i casi di successo (onComplete) ed errore (onError):
let promisedPic = loadUserPic('john');
promisedPic.then(
(pic) => { ui.show(pic); },
(err) => { console.error(err); }
);
Il professore sottolinea un punto cruciale: quando chiamate promise.then(callback), non state eseguendo nulla. Siete in una fase di configurazione: state dichiarando cosa deve succedere quando l'evento si verifica. È la stessa idea del modello dichiarativo che ritroveremo nella programmazione reattiva.
Il then non esegue la callback: la registra. È una fase di configurazione in cui specificate in modo dichiarativo il workflow che deve svolgersi quando la Promise viene risolta. Questa distinzione è fondamentale e ritorna in tutto il paradigma asincrono e reattivo.
Il professore mostra come "promisificare" una chiamata AJAX tradizionale, incapsulando l'API callback-based in una Promise:
function get(url) {
// Restituisce una nuova Promise
return new Promise((resolve, reject) => {
let req = new XMLHttpRequest();
req.open('GET', url);
req.onload = () => {
if (req.status == 200) {
resolve(req.response);
} else {
reject(Error(req.statusText));
}
};
req.onerror = () => {
reject(Error("Network Error"));
};
req.send();
});
}
// Uso
get('story.json').then(
(response) => { console.log("Success!", response); },
(error) => { console.error("Failed!", error); }
);
La caratteristica più potente delle Promise è il chaining: il metodo then restituisce a sua volta una Promise, permettendo di incatenare le operazioni. Questo appiattisce la pyramid of doom in una sequenza lineare di .then().
// Versione con callback annidate
findUserById('john', (user) => {
findPic(user.picId, (pic) => {
ui.show(pic);
});
});
// Stessa logica con Promise chaining
findUserById('john')
.then((user) => {
return findPic(user.picId);
})
.then((pic) => {
ui.show(pic);
});
La differenza è evidente: il codice diventa lineare e leggibile. Ogni .then() restituisce una nuova Promise, e il valore restituito dalla callback diventa il valore di risoluzione della Promise successiva.
asyncFunc1(…)
.then((v) => {
console.log(v);
return asyncFunc2(…);
})
.then((v) => {
console.log(v);
return asyncFunc3(…);
})
.then(…);
let myPromise = asyncFunc1(…);
myPromise.then((v) => {
console.log(v);
return asyncFunc2(…);
});
myPromise.then((v) => {
console.log(v);
return asyncFunc3(…);
});
Nel pattern parallelo, entrambe le callback sono attaccate alla stessa Promise: vengono chiamate entrambe quando asyncFunc1 termina, con lo stesso valore risultato. Le due funzioni asincrone (asyncFunc2 e asyncFunc3) vengono quindi eseguite concorrentemente.
La possibilità di restituire una Promise da un then permette di appiattire la computazione asincrona: invece di annidare callback, si restituisce una nuova Promise che sarà risolta quando l'operazione asincrona successiva terminerà. È qui che si vede la differenza tra CPS e Promise: nel CPS si annida, nella Promise si incatena linearmente.
Il valore di ogni Promise in una catena è ciò che il precedente onComplete / onError restituisce:
let myProm1 = delayWithRandom(1000, 0.5);
let myProm2 = myProm1.then((v) => {
console.log(v);
return "done";
});
myProm2.then((v) => {
console.log(v); // Stampa "done"
});
L'API delle Promise offre metodi potenti per comporre operazioni asincrone parallele. I due principali sono Promise.all e Promise.race.
let myProm1 = delayWithRand(1000);
let myProm2 = delayWithRand(2000);
let myTotalProm = Promise.all([myProm1, myProm2]);
myTotalProm.then((values) => {
console.log(values[0]); // risultato prima promise
console.log(values[1]); // risultato seconda promise
});
Promise.all restituisce una Promise che si risolve solo quando tutte le Promise nella lista sono risolte, o viene rigettata appena una viene rigettata. È l'equivalente di un join point nella programmazione concorrente tradizionale.
let myProm1 = delayWithRand(1000);
let myProm2 = delayWithRand(2000);
let myTotalProm = Promise.race([myProm1, myProm2]);
myTotalProm.then((value) => {
console.log(value); // il valore della prima risolta
});
Promise.race si risolve o viene rigettata non appena una qualunque delle Promise della lista viene risolta o rigettata. Utile per implementare timeout o per prendere il primo risultato disponibile.
Le Promise migliorano significativamente la gestione errori rispetto alle callback. Le regole sono:
asyncTask()
.then((result) => { return transform(result); })
.catch((err) => { console.error("Errore:", err); })
.then((final) => { console.log("OK:", final); });
Nonostante i vantaggi, le Promise presentano alcuni limiti significativi che il professore analizza nel dettaglio.
Considerate questo codice apparentemente corretto ma in realtà sbagliato:
printTime();
delay(1000)
.then(printTime)
.then(delay(1000)) // PROBLEMA!
.then(printTime);
Il problema è che delay(1000) viene invocato immediatamente, non passato come funzione da chiamare. Il risultato corretto è:
printTime();
delay(1000)
.then(printTime)
.then(() => { return delay(1000); })
.then(printTime);
È facilissimo cadere in questo errore: passare il risultato dell'invocazione di una funzione invece di passare la funzione stessa. La differenza tra .then(delay(1000)) e .then(() => delay(1000)) è sottile ma cruciale. Nel primo caso la Promise viene creata subito (eagerness), nel secondo viene creata solo quando serve.
for (let i = 0; i < 3; i++){
delay(1000).then(() => {
let time = new Date().getTime();
console.log(i + " > " + time);
})
}
// NON funziona come previsto: tutte e tre partono concorrentemente!
Con un for classico, tutte le Promise vengono create nello stesso ciclo dell'event loop: partono quindi concorrentemente, non in sequenza. Per ottenere una sequenza con le Promise, serve la ricorsione:
function sequenza(indice) {
if (indice >= 3) return;
delay(1000).then(() => {
console.log(new Date().getTime());
sequenza(indice + 1);
});
}
Il professore commenta: "Se interpreti l'aspetto funzionale in modo puro, non hai bisogno del for. Ma per dirvi come si complica."
Le Promise non possono essere cancellate. Una volta avviato un task asincrono tramite Promise, non c'è modo di fermarlo. In alcuni scenari questo può portare a spreco di risorse.
Il professore usa un esempio concreto nel for: quando chiamate delay(1000) in un for, create 10 Promise che partono tutte nello stesso momento (stesso "ciclo" dell'event loop). Se volevate aspettare 1 secondo tra una stampa e l'altra, il risultato è sbagliato: tutte le stampe arrivano dopo un secondo, non ogni secondo per dieci secondi.
La soluzione più recente e promettente è l'estensione linguistica async/await, introdotta in ES2017 (l'8ª edizione dello standard ECMAScript, giugno 2017) e implementata ormai in molti linguaggi (C# con TAP, Python, DART, Kotlin). L'obiettivo è scrivere codice asincrono con uno stile sintatticamente sincrono, mantenendo la semantica asincrona.
// Stile Promise
let p = asyncFunc(…);
p.then((res) => { /* usa res */ });
// Stile async/await
let res = await asyncFunc(…);
// usa res → qui arrivo solo quando la Promise è risolta
L'operatore await sospende l'esecuzione della funzione senza bloccare il thread: il controllo viene ceduto (yield) e lo stato della computazione salvato. Quando la Promise si risolve, l'esecuzione riprende dal punto in cui era stata sospesa.
await si comporta come un "return riprendibile": quando una funzione esegue await, restituisce al chiamante una Promise in stato pending, salvando lo stato della computazione. Quando la Promise si risolve, la funzione viene ripresa dall'event loop al prossimo ciclo disponibile, come se fosse un evento. Sotto il cofano, questo meccanismo sfrutta le coroutine.
await si può usare solo dentro funzioni marcate async.async restituisce sempre una Promise, anche se non contiene await.async serve a delimitare il blocco che può essere sospeso.await, deve essere async, e chi la chiama con await deve essere async, e così via.var myFunc = async function() {
console.log("here1");
await delay(3000);
console.log("here2");
await delay(2000);
console.log("here3");
return 1 + 2;
};
Il professore mostra una differenza importante: chiamare una funzione async con o senza await produce comportamenti diversi.
async function pause(t){
console.log("before");
let promise = new Promise((resolve,reject) => {
setTimeout(() => { resolve(); }, t);
});
await promise;
console.log("after");
}
// SENZA await: "after" arriva dopo l'output del chiamante
console.log("before call");
pause(1000);
console.log("after call");
// CON await: tutto procede in ordine sequenziale
async function main(){
console.log("before call");
await pause(1000);
console.log("after call");
}
main();
async function main(){
console.log("Before");
for (let i = 0; i < 3; i++){
await waitFor(1000);
console.log("Step "+i);
}
console.log("After");
}
await non si può usare al livello top-level (serve almeno una funzione async main).async non può riprendersi mentre il thread è occupato ad eseguire altri handler (la semantica dell'event loop è preservata).{ ... } non sono più atomici: un singolo blocco può estendersi su più iterazioni dell'event loop, potenzialmente introducendo race condition.Promise.all).Il professore mette in guardia: con async/await i blocchi non sono più atomici. Se avete una variabile condivisa modificata in due punti diversi di uno stesso blocco async con un await in mezzo, tra le due modifiche l'event loop può eseguire altro codice che accede alla stessa variabile. È qui che nascono le race condition in un modello altrimenti single-threaded.
Il professore parla del "design clash" tra stile sincrono e asincrono: serve una forte disciplina per mescolare i due stili, e c'è bisogno di astrazioni di livello più alto che integrino naturalmente entrambe le prospettive. È qui che si inserisce la programmazione reattiva.
Il meccanismo alla base di async/await è la coroutine, una generalizzazione del concetto di subroutine che permette di sospendere e riprendere l'esecuzione. Le coroutine sono state introdotte già negli anni '60 da Conway (1963) per la compilazione di diagrammi di transizione e sono supportate da molti linguaggi: Simula, Smalltalk, Modula-2, e più recentemente Ruby, Lua, Julia, Go, Kotlin.
flowchart TB
subgraph Subroutine
A1["call X()"] --> A2["esegui corpo"]
A2 --> A3["return al chiamante"]
end
subgraph Coroutine
B1["call coroutine()"] --> B2["esegui fino a yield"]
B2 -->|"yield"| B3["sospendi e cedi controllo"]
B3 -->|"resume"| B4["riprendi dal punto di sospensione"]
B4 --> B2
end
Differenza tra subroutine (a sinistra) e coroutine (a destra): la subroutine torna sempre al chiamante, mentre la coroutine può sospendersi e riprendere l'esecuzione dal punto in cui si era fermata.
Le coroutine sono il mattoncino di base per implementare i lightweight thread (o fiber). Un thread può contenere molte coroutine al suo interno, ma solo una è in esecuzione in un dato momento. Sono veloci da creare e gestire perché non richiedono context switching a livello di sistema operativo.
import kotlinx.coroutines.*
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()}")
}
suspend fun greetDelayed(delayMillis: Long) {
delay(delayMillis)
println("Hello, World!")
}
L'output mostra l'alternanza tra le due coroutine:
before async call after the async call, before greet inside the async call Hello, World! after greet trigger exiting the async call 100
La funzione greetDelayed è marcata come suspend: può sospendersi senza bloccare il thread, permettendo al thread di essere usato per altre computazioni. Il delay è una funzione suspending che sospende l'esecuzione senza bloccare il thread sottostante.
In Kotlin, il coroutine dispatcher decide su quale thread avviare o riprendere una coroutine. I coroutine builders sono funzioni che prendono una suspending lambda e creano una coroutine per eseguirla:
async() — avvia una coroutine quando ci si aspetta un risultato.launch() — avvia una coroutine che non restituisce un risultato.runBlocking() — ponte per codice bloccante in contesto sospendibile.Il professore introduce il concetto di fiber (o fibra): un thread leggero che condivide lo spazio di indirizzi ma usa cooperazione invece di preemption. Le fiber sono implementate da vari sistemi: UNIX/pthreads, Windows/.NET e, più recentemente, i Virtual Threads di Java (da JDK 19).
Le coroutine sono un costrutto a livello di linguaggio (una forma di controllo del flusso), mentre le fiber sono un costrutto a livello di sistema (thread che non eseguono in parallelo). Le fiber possono essere viste come un'implementazione delle coroutine o come substrato su cui implementare coroutine.
Il professore menziona il dibattito recente su Virtual Threads vs async programming, citando l'articolo di Brian Goetz (2022). I virtual thread offrono migliore modularità per incapsulare flussi di controllo logici, ma — come i thread fisici — non offrono un trattamento di prima classe per eventi e computazioni reattive, che devono essere comunque gestiti a livello applicativo.
Con le Promise assumiamo che le computazioni asincrone producano un singolo risultato. Ma nelle applicazioni reali è frequente dover gestire flussi di dati/eventi asincroni: click del mouse, letture da un sensore, messaggi in arrivo da una connessione di rete. La programmazione reattiva (RP) nasce per questo: è la programmazione con flussi di dati asincroni.
La programmazione reattiva è un paradigma orientato ai flussi di dati e alla propagazione del cambiamento. Le variabili non hanno più un valore fisso: diventano reattive, aggiornandosi automaticamente quando cambiano le variabili da cui dipendono. È come se l'assegnamento non fosse più un'operazione imperativa ma la dichiarazione di una relazione tra quantità.
Le radici della RP risalgono al linguaggio Fran (Elliott & Hudak, 1997), un dominio-specifico funzionale per animazioni interattive. Da lì nasce la Functional Reactive Programming (FRP), basata sul synchronous dataflow (Lee & Messerschmitt, 1987) ma con vincoli real-time rilassati. Oggi la RP è implementata da framework e librerie per tutti i linguaggi mainstream: FrTime (Scheme), Reactive Extensions di Microsoft (.NET, RxJS, RxJava), Flapjax, Bacon.js, Scala.React, e molti altri.
Il paper di riferimento citato dal professore è il survey di Bainomugisha et al. (2013) su ACM Computing Surveys. Altri lavori importanti includono "Your mouse is a database" di Erik Meijer (2012) e i lavori di Salvaneschi sulla distributed RP (OOPSLA 2014).
Singolo risultato. La computazione asincrona produce un valore (o un errore) una volta sola. La composizione avviene tramite chaining di then o callback annidate. Il flusso è "freddo" nel senso che ogni then crea una nuova Promise.
Singolo risultato ma sintassi sincrona. L'operatore await sospende la funzione finché la Promise non è risolta, permettendo di scrivere codice sequenziale lineare anche se asincrono. Il blocco non è più atomico.
Flussi di valori nel tempo. Non un risultato singolo, ma una sequenza potenzialmente infinita di eventi (stream). Ogni evento può essere trasformato, filtrato, combinato con altri flussi usando operatori dichiarativi (map, filter, merge, zip). La propagazione del cambiamento è automatica.
L'idea centrale della RP è la propagazione automatica del cambiamento. Consideriamo l'esempio più semplice: se dichiaro var3 = var1 + var2, in un linguaggio reattivo var3 viene aggiornato automaticamente ogni volta che cambia var1 o var2.
flowchart LR
var1["var1 = 1"] --> var3["var3 = var1 + var2 = 3"]
var2["var2 = 2"] --> var3
Grafo delle dipendenze: var3 dipende da var1 e var2. Ogni volta che var1 o var2 cambiano, var3 viene automaticamente ricalcolato. Il framework costruisce e gestisce questo grafo dietro le quinte.
Il professore usa una metafora efficace: non stiamo più scrivendo un assegnamento (imperativo), ma stiamo dichiarando una relazione matematica tra quantità. Come dice il professore: "L'uguale in matematica non è un assegnamento, esprime una relazione che permette di affermare sempre il fatto che due quantità sono uguali."
Questa è la differenza fondamentale tra programmazione imperativa e reattiva. Nella programmazione imperativa scrivete come calcolare un valore; nella programmazione reattiva dichiarate cosa deve essere quel valore in ogni istante. Il "quando" lo gestisce automaticamente il framework.
Il professore si chiede: "Col modello che conosciamo finora, come implementeremmo questa propagazione?" La risposta è: con gli eventi. Ogni variabile è sorgente di eventi, e ogni volta che cambia, notifica i dipendenti che si aggiornano. Ma nella RP questo meccanismo è automatico e dichiarativo: non gestite eventi, definite relazioni. Il framework si occupa della notifica, della schedulazione degli aggiornamenti e della consistenza.
Il professore mette in guardia: se si rendesse osservabile uno stato intermedio in cui var1 è stato aggiornato ma var2 no, il sistema si troverebbe in una situazione inconsistente. Serve quindi che lo stato globale sia sempre consistente con le relazioni dichiarate. Questo è il problema dei glitch, che vedremo tra poco.
La RP si basa su due astrazioni fondamentali:
Valori continui nel tempo. Rappresentano un flusso ininterrotto di dati che ha sempre un valore definito in ogni istante. Un esempio è la temperatura di un sensore: è sempre definita, cambia nel tempo in modo continuo. Un timer che produce un tick ogni 100ms è un behaviour.
Valori discreti (o sparsi) nel tempo. Rappresentano eventi che occorrono in istanti specifici. Un esempio è il click del mouse: non è sempre definito, occorre quando l'utente clicca. Una sequenza di rilevazioni da un sensore di prossimità su un nastro trasportatore è un event stream.
Entrambe le astrazioni sono first-class values: possono essere passate a funzioni, combinate, trasformate. Il framework o linguaggio gestisce automaticamente la propagazione del cambiamento. Come dice il professore: "astrazioni che andiamo a manipolare e combinare sono flussi asincroni di dati che possono rappresentare eventi, valori che variano nel tempo, anche continui."
// Crea un behaviour timer che produce un valore ogni 100ms
var timer = timerB(100);
// Crea un secondo behaviour che arrotonda al secondo
var seconds = liftB(
function (time){
return Math.floor(time / 1000);
}, timer
);
// Inserisce il behaviour in un elemento DOM: si aggiorna automaticamente!
insertDomB(seconds, 'timer-div');
Questo esempio è completamente dichiarativo. Non ci sono callback, non ci sono event listener: definite la relazione seconds = floor(timer / 1000) e il framework si occupa di aggiornare il DOM automaticamente ogni volta che il timer scatta.
Nella programmazione imperativa dovreste: (1) creare un interval timer, (2) registrare un callback, (3) calcolare secondi, (4) aggiornare il DOM. Nella RP dichiarate semplicemente la relazione: il DOM contiene floor(timer / 1000) e tutto si aggiorna da sé. È la stessa differenza tra dire "sposta il tappeto" (imperativo) e dire "il tappeto deve essere sempre sotto il tavolo" (dichiarativo/reattivo).
Come avviene la propagazione del cambiamento? Il professore distingue due modelli fondamentali:
Il produttore, quando ha nuovi dati, li spinge (push) ai consumatori dipendenti. La propagazione è guidata dalla disponibilità di nuovi dati. Tipico dei linguaggi eager (JavaScript, Scala). Più efficiente per flussi con aggiornamenti frequenti, ma può portare a valutazioni ridondanti se il consumatore non è pronto. Esempi: Flapjax (basato su JavaScript), Scala.React.
Il consumatore, quando necessita di un valore, lo tira (pull) dal produttore. La propagazione è guidata dalla domanda (demand-driven). Tipico dei linguaggi lazy (Haskell, tramite lazy evaluation). Più efficiente quando il consumatore decide quando aggiornarsi e non vuole essere sommerso da notifiche non richieste.
I glitch sono inconsistenze temporanee che possono verificarsi durante la propagazione del cambiamento, specialmente nel modello push. Si verificano quando una computazione viene eseguita prima che tutte le sue dipendenze siano state aggiornate.
flowchart TD
subgraph "t1: stato consistente"
v1["var1 = 1"] --> v2["var2 = var1 * 1 = 1"]
v1 --> v3["var3 = var1 + var2 = 2"]
v2 --> v3
end
flowchart TD
subgraph "t2: GLITCH! var1=2 ma var2 non ancora"
v1["var1 = 2"] -.-> v2["var2 = 1 (NON aggiornato!)"]
v1 -.-> v3["var3 = 2 + 1 = 3 (SBAGLIATO!)"]
v2 -.-> v3
style v3 fill:#fef2f2,stroke:#ef4444
end
flowchart TD
subgraph "t3: stato consistente"
v1["var1 = 2"] --> v2["var2 = 2"]
v1 --> v3["var3 = 2 + 2 = 4"]
v2 --> v3
end
La sequenza mostra il problema: quando var1 cambia da 1 a 2:
var1 viene aggiornato a 2. La propagazione potrebbe raggiungere var3 (via var1 + var2) prima che var2 venga ricalcolato. Risultato: var3 = 2 + 1 = 3. È un glitch: uno stato che non corrisponde a nessuna configurazione consistente delle relazioni.var2 viene aggiornato a 2, var3 viene ricalcolato a 4. Ora lo stato è consistente.I framework reattivi moderni implementano tecniche di glitch avoidance (tipicamente basate su topological ordering o livelli di propagazione) per garantire che uno stato inconsistente non sia mai osservabile dall'esterno. La glitch avoidance in contesti distribuiti è ancora un problema di ricerca aperto, a causa di latenze di rete, guasti e assenza di un clock globale, come discusso nei lavori di Salvaneschi et al. (2014) e Mogk et al. (2018).
Il lifting è il processo che converte una variabile o funzione normale in una reattiva. Quando una variabile viene assegnata con un'espressione che coinvolge behaviour o event stream, diventa a sua volta reattiva: qualsiasi cambiamento nei flussi da cui dipende si propaga automaticamente.
// timer behaviour
var timer = timerB(100);
// seconds è ottenuto "sollevando" (lifting) una funzione normale su timer
// La funzione Math.floor(time/1000) viene "liftata" a operare su behaviour
var seconds = liftB(function (time) {
return Math.floor(time / 1000);
}, timer);
Il framework costruisce automaticamente un grafo delle dipendenze tra le variabili reattive. Quando un behaviour cambia, tutte le variabili che dipendono da esso vengono ricalcolate. Se una variabile A dipende da B, e B dipende da C, quando C cambia, A viene ricalcolata solo dopo che B è stato aggiornato (se il framework implementa glitch avoidance).
Alcuni framework fanno lifting implicitamente (Bacon.js), altri richiedono che il programmatore lo esegua manualmente (React.js), altri offrono entrambe le modalità (Flapjax: come libreria vuole lifting esplicito, come compilatore trasforma il codice implicitamente).
La capacità di comporre flussi è ciò che permette di evitare il callback hell. Invece di avere tre callback separate per gestire click, movimento e rilascio del mouse, possiamo comporle in un unico flusso:
// Composizione di event stream in Flapjax
var saveTimer = timerE(10000); // event stream: ogni 10 secondi
var saveClicked = extractEventE('save-button', 'click'); // event stream: click
var save = mergeE(saveTimer, saveClicked); // merge dei due flussi
save.mapE(doSave); // per ogni evento nel flusso composito, chiama doSave
Il professore illustra gli operatori principali per la manipolazione dei flussi, come mostrato nelle slide:
map: trasforma ogni elemento del flusso applicando una funzione. Dato un flusso di numeri, ne produce uno di stringhe. È l'operatore fondamentale per trasformare i dati.
// Flapjax: da un flusso di numeri a un flusso di stringhe
var seqNum = Observable.range(1, 5);
var seqString = seqNum.map(n => new String('*').repeat(n));
seqString.subscribe(str => { console.log(str); });
filter: seleziona solo gli elementi che soddisfano un predicato booleano. Dato un flusso misto di cerchi e quadrati, produce un nuovo flusso con i soli cerchi.
// Flapjax: filtra solo i cerchi
var cerchi = forme.filter(f => f.tipo === 'cerchio');
flatMap: per ogni elemento genera un nuovo flusso, e tutti i flussi generati vengono appiattiti (flatten) in un unico flusso. È una combinazione di map e merge. Se per ogni palla generate due rombi, flatMap vi darà un unico flusso con tutti i rombi, indipendentemente dal colore della palla originale. Molto utile nelle applicazioni reali.
merge: combina due o più flussi in uno solo, alternando gli elementi nell'ordine in cui arrivano. Nel tempo, gli elementi dei due flussi vengono mescolati. Il flusso risultante completa quando tutti i flussi sorgente hanno completato.
zip: accoppia gli elementi di due flussi a due a due, in modo sincrono. Prende il primo elemento del primo flusso e lo accoppia con il primo elemento del secondo flusso, poi il secondo col secondo, e così via. È un operatore di sincronizzazione tra flussi.
Il professore commenta ironicamente che "alcuni programmi sono diventati illeggibili perché tutto è espresso come composizione di operatori" — ma questo è un problema di abuso, non del paradigma in sé.
Le Reactive Extensions (Rx) sono la concretizzazione più diffusa del paradigma reattivo. Nate su .NET negli anni 2000 come evoluzione del pattern Observer combinate con LINQ, oggi sono disponibili per tutti i linguaggi mainstream: RxJS, RxJava, RxScala, RxPython, e adottate da framework come Angular (sia frontend che backend).
Rx combina le idee migliori del pattern Observer, del pattern Iterator e della programmazione funzionale. Come dice il famoso articolo di Erik Meijer (2012): "Your mouse is a database" — eventi asincroni diventano dati che possono essere interrogati, filtrati e combinati come faremmo con un database. Un mouse non è più solo un dispositivo di input: è un database di click e movimenti.
Le tre proprietà fondamentali di Rx sono:
Al cuore di Rx ci sono due interfacce:
interface IObservable<T> {
IDisposable Subscribe(IObserver<T> observer);
}
interface IObserver<T> {
void OnNext(T value); // chiamato quando arriva un nuovo elemento
void OnError(Exception error); // chiamato in caso di errore
void OnCompleted(); // chiamato quando il flusso termina
}
Un Observable emette eventi a cui un Observer si sottoscrive. La sottoscrizione restituisce un IDisposable che permette di cancellare la sottoscrizione quando non serve più. Ogni chiamata di sottoscrizione può potenzialmente avvenire su un thread diverso da quello in cui la sequenza viene eseguita.
Esempio concreto in .NET:
IObservable<int> source = Observable.Range(1, 10);
IDisposable subscription = source.Subscribe(
x => Console.WriteLine("OnNext: {0}", x),
ex => Console.WriteLine("OnError: {0}", ex.Message),
() => Console.WriteLine("OnCompleted")
);
// ...
subscription.Dispose(); // cancella la sottoscrizione
Il professore nota che la chiamata a Subscribe è asincrona: il chiamante non viene bloccato fino al completamento della sequenza. Il thread che chiama Subscribe può essere diverso dal thread in cui la sequenza viene eseguita.
I flussi Observable possono essere trasformati e combinati con operatori ispirati a LINQ. Il professore mostra gli operatori principali con esempi concreti:
Concatena due sequenze in ordine: la seconda inizia solo dopo che la prima ha completato.
var source1 = Observable.Range(1, 3);
var source2 = Observable.Range(1, 3);
source1.Concat(source2)
.Subscribe(Console.WriteLine);
// Output: 1,2,3,1,2,3
Fonde due sequenze: gli elementi appaiono nell'ordine in cui vengono emessi. Le due sequenze sono attive contemporaneamente.
var source1 = Observable.Range(1, 3);
var source2 = Observable.Range(1, 3);
source1.Merge(source2)
.Subscribe(Console.WriteLine);
// Output: 1,1,2,2,3,3
Trasforma ogni elemento del flusso applicando una funzione di proiezione.
var seqNum = Observable.Range(1, 5);
var seqString = from n in seqNum
select new string('*', (int)n);
seqString.Subscribe(str => {
Console.WriteLine(str);
});
Accoppia gli elementi di due flussi a due a due, in ordine. È un operatore sincrono: aspetta che entrambi i flussi abbiano un elemento disponibile per produrre una coppia. Se un flusso è più veloce dell'altro, gli elementi in eccesso vengono bufferizzati.
Altri operatori importanti menzionati dal professore includono operatori di filtraggio (Where, Any), operatori temporali (Throttle, Debounce, Delay), e operatori per la gestione delle eccezioni (Catch, Retry, Finally).
La backpressure è la capacità del consumatore di segnalare al produttore che la velocità di emissione è troppo alta. Il professore usa l'analogia della catena di montaggio: se una postazione di lavoro lavora più lentamente della precedente, segnala a monte di limitare il flusso.
Un aspetto importante che il professore sottolinea: nulla accade fino a quando non ci si sottoscrive (subscribe). Prima della subscribe c'è solo la fase di configurazione in cui si descrive il flusso dichiarativamente, costruendo la pipeline di operatori. La subscribe attiva effettivamente il flusso, e lo fa tramite un segnale di request che si propaga all'indietro fino alla sorgente. È la stessa distinzione configurazione/esecuzione che abbiamo visto con le Promise.
Rx distingue due tipi di flussi reattivi:
Ogni subscriber ottiene un nuovo flusso indipendente, partendo dall'inizio. Come un DVD: ogni spettatore che lo avvia parte dal primo minuto. Tutti i dati vengono generati per ogni subscriber separatamente.
Il flusso esiste indipendentemente dai subscriber. I subscriber in ritardo ricevono solo gli elementi emessi dopo la sottoscrizione. È come una diretta TV: se vi sintonizzate a metà, vedete solo da quel momento in poi.
Alcuni flussi hot possono cache o replay la storia delle emissioni (totalmente o parzialmente). Un flusso hot può emettere eventi anche quando nessun subscriber è in ascolto — un'eccezione alla regola "nothing happens before you subscribe". Questa distinzione è fondamentale per scegliere il tipo di flusso giusto in base allo scenario applicativo.
Il professore conclude la lezione accennando alle direzioni di ricerca attuali nella programmazione reattiva:
I concetti chiave da portare all'esame: differenza tra hot e cold stream, backpressure e l'analogia della catena di montaggio, operatori principali (map, filter, merge, zip, concat, flatMap), la distinzione tra fase di configurazione e fase di esecuzione in Rx, e la differenza fondamentale tra programmazione reattiva (flussi di valori) e programmazione asincrona tradizionale (valori singoli).
Il professore chiude ricordando che i big data e le piattaforme di stream processing usano estensivamente questi concetti: specificare computazioni elaborate su più nodi in modo dichiarativo, dove la gestione della consistenza è ancora più complessa che in contesto single-node.
Una Promise rappresenta un singolo valore futuro (o errore), risolvibile una volta sola. Un Observable rappresenta un flusso di valori nel tempo, potenzialmente infinito. La Promise produce un risultato una tantum (resolved/rejected); l'Observable produce eventi OnNext multipli fino a OnCompleted o OnError. Inoltre, l'Observable supporta la cancellazione tramite dispose().
Gli event handler non devono mai bloccarsi e devono sempre terminare. Se un handler si blocca, blocca l'intero event loop, impedendo il processamento di qualsiasi altro evento successivo. Le operazioni lunghe vanno delegate a thread asincroni separati (pool) che, al termine, inseriranno un evento nella coda per notificare il completamento.
for (let i=0; i<3; i++) { delay(1000).then(() => print(i)); }Tutte e tre le Promise vengono create nello stesso ciclo dell'event loop, quindi partono concorrentemente (non in sequenza). Dopo 1 secondo, le tre callback vengono eseguite quasi simultaneamente. Il risultato non è una sequenza di stampe distanziate di 1 secondo, ma tre stampe ravvicinate dopo 1 secondo. Per ottenere la sequenza serve ricorsione o async/await.
Sono inconsistenze temporanee che si verificano durante la propagazione del cambiamento nel modello push. Ad esempio, se var3 = var1 + var2 e var2 = var1 * 1, al cambiamento di var1 da 1 a 2 si potrebbe osservare var3 = 3 (invece di 4) se var1 + var2 viene ricalcolato prima che var2 sia aggiornato. I framework moderni implementano tecniche di glitch avoidance (topological ordering).
Un cold stream crea un nuovo flusso indipendente per ogni subscriber, partendo dall'inizio. Ogni subscriber riceve tutti gli elementi. Un hot stream esiste indipendentemente dai subscriber: quelli in ritardo ricevono solo gli eventi successivi alla sottoscrizione. Un flusso hot può emettere eventi anche senza subscriber attivi. Alcuni hot stream possono cache/replay la storia parziale o totale.
La backpressure è la capacità del consumatore di segnalare al produttore che la velocità di emissione è troppo alta. L'analogia è una catena di montaggio: se una postazione lavora più lentamente di quella a monte, segnala di rallentare il flusso. Tecnicamente, il subscriber usa il request mechanism per chiedere al massimo n elementi, trasformando il modello push in un push-pull hybrid.
1) Eagerness e problema del parametro di then: .then(delay(1000)) invoca immediatamente invece di passare la funzione (serve lambda). 2) Impossibilità di usare loop/iterazioni: il for crea tutte le Promise nello stesso ciclo (serve ricorsione). 3) Impossibilità di cancellazione: una Promise non può essere cancellata una volta avviata. Tutti e tre sono risolti da async/await.
Le coroutine sono un costrutto a livello di linguaggio: una forma di controllo del flusso che permette sospensione e ripresa. Le fiber sono un costrutto a livello di sistema: thread leggeri che condividono lo spazio di indirizzi e usano cooperazione (non preemption). Le fiber possono implementare coroutine, o essere il substrato su cui le coroutine sono costruite.