Il professor Ricci apre la lezione ricordando che le reti di Petri sono un formalismo già introdotto in altri corsi, ma che in questo contesto vengono utilizzate con un obiettivo molto specifico: modellare sistemi di processi concorrenti per analizzarne il comportamento, verificare proprietà di safety e liveness, e progettare la coordinazione.
Una rete di Petri è composta da place (piazze, che rappresentano stati) e transizioni (che rappresentano azioni). I token forniscono una "fotografia runtime" dello stato del sistema: ogni token rappresenta un flusso di controllo, cioè un'istanza di un processo che segue lo schema di comportamento definito dalla rete.
Quando modellate un processo con una rete di Petri, le piazze rappresentano lo stato del processo, il token rappresenta il suo flusso di controllo. Le transizioni sono le azioni che il processo può compiere. Una transizione scatta se per ogni arco in ingresso c'è almeno un token nel place sorgente.
La semantica delle reti di Petri è semplice ma potente: una transizione consuma un token da ogni place in ingresso e produce un token in ogni place in uscita. Due token in uno stesso place rappresentano due processi distinti con la stessa struttura comportamentale.
Il consiglio del professore è di pensare sempre a cosa si vuole modellare: se il token rappresenta un flusso di controllo, consumare un token significa "il flusso prosegue"; se invece il token rappresenta una risorsa (come un permesso), la semantica cambia. Le reti di Petri unificano entrambi i concetti in un unico formalismo.
Il professore mostra come modellare costrutti di programmazione sequenziale con le reti di Petri. Consideriamo un programma che esegue A, poi valuta una condizione C e, a seconda del risultato, esegue B oppure C (if-then-else), e infine D.
La modellazione introduce una transizione che rappresenta la valutazione della condizione. Da questa partono due archi: uno verso il ramo B (condizione vera), l'altro verso il ramo C (condizione falsa). Non esiste un unico modo corretto di modellare: il livello di astrazione lo definite voi.
flowchart LR
subgraph "Processo P"
direction TB
start((i)) --> A[A]
A --> cond{C?}
cond -->|true| B[B]
cond -->|false| C[C]
B --> D[D]
C --> D[D]
D --> loop(( ))
end
La trascrizione originale dice: "Le azioni le modello sempre come transizione. Poi introduco una transizione che rappresenta la valutazione della condizione. C'è un solo modo? No. E questo vale per tutti i formalismi. Siete voi che definite il livello di astrazione che volete."
Il professore applica l'approccio metodologico per modellare il problema classico dei produttori e consumatori con buffer. La strategia è sempre: prima rappresentare lo schema di comportamento dei singoli processi, poi aggiungere la "colla" della coordinazione.
Il produttore è un ciclo che produce un item e lo inserisce nel bounded buffer (transizioni produce e insert). Il consumatore prende un item dal buffer (transizione get) e poi lo consuma (transizione consume). N token per N produttori, M token per M consumatori.
La coordinazione emerge introducendo place che rappresentano i permessi: un place per i posti disponibili nel buffer (inizializzato con N token) e uno per gli item disponibili (inizializzato a 0). Questi place "risorsa" sono concettualmente diversi dai place che rappresentano il flusso di controllo, ma il formalismo li unifica.
flowchart LR
subgraph Produttore[i-esimo Producer]
p1[produce] --> p2[insert]
p2 --> p1
end
subgraph Consumatore[j-esimo Consumer]
q1[get] --> q2[consume]
q2 --> q1
end
p2 -- item --> buffer[(Buffer)]
buffer -- item --> q1
places[(N posti)] -- permesso --> p2
q1 -- rilascio --> places
La bellezza delle reti di Petri è che con un unico formalismo catturano sia il flusso di controllo (token = istanza del processo) sia le risorse di coordinazione (token = permesso). I place hanno lo stesso aspetto ma significato diverso a seconda del contesto.
Il problema Readers-Writers introduce una sfida più sottile: i lettori non devono escludersi tra loro, ma devono escludere gli scrittori (e viceversa). Nella modellazione con reti di Petri, l'approccio naive che usa un singolo permesso porta a una sezione critica pura: se un lettore prende il permesso, blocca anche gli altri lettori, il che non è desiderabile.
La soluzione mostrata dal professore introduce N token nel place dei permessi per i lettori: ogni lettore consuma un token per entrare, ma se ci sono N token iniziali, fino a N lettori possono entrare simultaneamente. Finché c'è almeno un lettore attivo, gli scrittori sono bloccati.
Se usate un singolo token (semaforo binario) per il problema Readers-Writers, ottenete una soluzione over-constrained che serializza anche i lettori, perdendo il vantaggio del parallelismo in lettura. Il professore sottolinea: "Se consumo il token per poter entrare, il reader passa ma blocca tutti gli altri — sto serializzando i readers che non va bene."
Il professore apre la seconda parte della lezione (e il modulo 2.1) introducendo la programmazione asincrona come un paradigma sempre più importante e pervasivo, supportato da tutti i framework e le piattaforme moderne (Java, .NET, iOS, Android, Node.js).
La programmazione asincrona è un approccio che astrae dai thread tradizionali, focalizzandosi sulla gestione di computazioni, richieste e processi asincroni. Non si sostituisce ai thread, ma offre un modo diverso di pensare alla concorrenza, spesso più adatto a scenari I/O-bound e reattivi.
L'evoluzione storica mostra una ricerca costante di pattern migliori: Microsoft è passata dall'Asynchronous Programming Model (APM) all'Event-based Asynchronous Pattern (EAP) con .NET 2.0, fino al Task-based Asynchronous Pattern (TAP) con .NET 4.5.
Il panorama della programmazione asincrona include:
Oggi la programmazione asincrona è supportata nativamente da tutti i linguaggi moderni: JavaScript (Promise, async/await), Python (asyncio, coroutine), C# (Task, async/await), Java (CompletableFuture, virtual thread), Kotlin (coroutine), Dart, Swift, Rust, e molti altri.
Il dibattito storico "events vs threads" ha attraversato decenni, con posizioni forti da entrambi i lati (Ousterhout, Lee, von Behren). La vera sfida è capire come metterli insieme.
Il modello di programmazione event-driven è alla base di gran parte del software moderno: un programma è composto da routines (event handler) che vengono chiamate (eseguite, attivate) quando si verificano eventi. Gli eventi possono riferirsi a qualsiasi cambiamento di stato o input dall'ambiente rilevante per il programma: azioni utente (click, pressioni di tasti), output di sensori, messaggi, risposte ricevute.
Una caratteristica fondamentale è che il flusso di controllo dipende dagli eventi, introducendo non-determinismo. Si parla anche di "programming without a call stack": dopo ogni handler lo stack è vuoto.
L'event loop è il cuore del modello:
loop {
Event ev = waitForEvent(eventQueue)
Handler handler = selectHandler(ev)
execute(handler)
}
Un singolo thread di controllo attende gli eventi e, quando arrivano, esegue l'handler corrispondente. Gli eventi vengono accodati in una coda degli eventi, generati sia dall'ambiente sia dagli handler stessi. L'esecuzione degli handler è atomica: se un evento arriva mentre un handler è in esecuzione, viene accodato e gestito solo quando l'handler corrente termina (niente concorrenza).
Esplorate gli stati dell'Event Loop nel modello astratto:
Il Reactor pattern (Schmidt et al., Pattern-Oriented Software Architecture Volume 2) è il pattern architetturale che formalizza l'event loop. I partecipanti principali sono:
Il flusso di collaborazione: quando un Handle diventa "pronto", l'Initiation Dispatcher lo rileva, usa l'Handle come chiave per localizzare l'Event Handler appropriato, e chiama il metodo hook corrispondente.
Il Reactor pattern è alla base di framework come Node.js, Netty, Vert.x, e di gran parte dei web server moderni. La sua potenza sta nel separare la gestione degli eventi (dispatcher) dalla logica applicativa (handler).
Il modello di esecuzione dell'event loop porta a una regola fondamentale: gli event handler non devono mai bloccarsi (non devono contenere chiamate bloccanti) e devono sempre terminare (non devono contenere cicli infiniti). Questa regola garantisce la reattivita' del sistema: una chiamata bloccante o un ciclo infinito bloccherebbero l'event loop, impedendo l'elaborazione degli eventi in coda.
Cosa fare quando serve un'operazione che sarebbe bloccante (come leggere un file o fare una richiesta di rete)? La risposta è sostituirla con una richiesta o computazione asincrona, che genererà un evento in futuro (risultato o errore). Queste richieste asincrone sono servite da altri thread (indipendenti dall'event loop), che interagiscono con l'event loop inserendo eventi nella coda.
flowchart LR
subgraph "Event Loop Thread"
el[Event Loop] --> h[Handler]
h --> |delega task bloccante| Pool
h --> |torna in attesa| el
end
subgraph "Background Thread Pool"
Pool[Worker Pool]
Pool --> |task completato| coda[(Event Queue)]
end
coda --> |evento pronto| el
Il professore spiega: "Non appena io devo fare un'operazione che so che è bloccante, non mi blocco: do il compito, delego il task a un worker pool. Ogni procedura bloccante viene trasformata in una procedura non bloccante async."
Data la regola del never-blocking, sorge il problema centrale: come elaborare i risultati o gli errori generati asincronamente dall'esecuzione di un task/funzione asincrona? La risposta è il modello a callback.
Una callback è una funzione specificata come argomento (tipicamente l'ultimo) di una chiamata a funzione/task asincrono. La callback viene invocata dall'event loop quando viene processato l'evento relativo al risultato o errore generato dall'esecuzione asincrona. In questo modo la callback definisce una continuazione della computazione, da cui il nome Continuation-Passing Style (CPS).
function loadUserPic(userId) {
let user = findUserById(userId);
return loadPic(user.picId);
}
Versione sincrona e bloccante: ogni chiamata attende il risultato prima di procedere.
function loadUserPic(userId, ret) {
findUserById(userId, (user) => {
loadPic(user.picId, ret);
});
}
loadUserPic('john', (pic) => {
ui.show(pic);
});
Versione asincrona in CPS: findUserById e loadPic sono funzioni asincrone, l'ultimo argomento è la callback che riceve il risultato. Non c'è attesa bloccante.
Le callback sono spesso implementate come closure, una tecnica per il name binding lessicale in linguaggi con funzioni di prima classe. Una closure memorizza la funzione insieme all'ambiente (mapping delle variabili libere) presente al momento della creazione. Questo permette agli handler di accedere al contesto originale anche quando vengono eseguiti in un momento successivo.
Nonostante i benefici, il CPS e la programmazione event-driven soffrono di problemi ben noti, spesso chiamati collettivamente callback hell:
Il professore cita un commento emblematico di uno sviluppatore sul Google Group di Node.js: "I love async, but I can't code like this." La necessità di un meccanismo migliore era evidente.
Le Promise, proposte originariamente nel 1976 da Daniel Friedman e D. Wise, sono un meccanismo che risolve in parte il callback hell. Una Promise è un oggetto proxy che rappresenta un risultato sconosciuto che deve ancora essere calcolato (simile ai future).
Una Promise incapsula azioni asincrone, comportandosi come un valore restituito da una computazione, solo che il valore potrebbe non essere disponibile al momento. Una proprietà chiave è che una Promise può essere risolta o rifiutata una e una sola volta (immutabile una volta risolta).
L'API fondamentale: myPromise.then(onComplete, onError) dove onComplete è la callback chiamata se/quando la funzione asincrona termina correttamente, e onError (opzionale) è la callback chiamata in caso di errore.
let promisedPic = loadUserPic('john');
promisedPic.then((pic) => {
ui.show(pic);
});
Le Promise permettono di appiattire l'annidamento delle callback. Invece di passare una callback dentro l'altra (pyramid), si incatenano i .then() uno dopo l'altro in sequenza lineare. Questo è il vantaggio fondamentale rispetto al CPS puro.
La caratteristica che rende le Promise così potenti è che il metodo then restituisce a sua volta una Promise, permettendo di creare catene (chaining) che appiattiscono la pyramid of doom.
let promise = new Promise((resolve, reject) => {
resolve(1);
});
promise.then((val) => {
console.log(val); // 1
return val + 2;
}).then((val) => {
console.log(val); // 3
});
Le Promise supportano due pattern fondamentali di flusso asincrono:
| Pattern | Codice | Descrizione |
|---|---|---|
| Sequenziale | asyncFunc1().then(() => asyncFunc2()).then(() => asyncFunc3()) | Ogni operazione asincrona attende la precedente. |
| Parallelo | Promise.all([f1(), f2()]) | Operazioni indipendenti eseguite concorrentemente, con join point quando tutte completano. |
| Race | Promise.race([f1(), f2()]) | Come all, ma completa quando la prima operazione termina (risolta o rifiutata). |
Ricordate la differenza tra Promise.all e Promise.race: all attendere tutte le Promise (fallisce se una fallisce), race termina appena la prima Promise si risolve o viene rifiutata. all restituisce un array con tutti i valori, race restituisce un singolo valore.
Le Promise migliorano significativamente la gestione degli errori rispetto alle callback pure. Le regole sono chiare: un handler che restituisce un valore e non lancia eccezioni passa al prossimo then; un handler che lancia un'eccezione fa passare la Promise allo stato rejected, propagandosi lungo la catena fino a trovare un handler di errore.
L'evoluzione più recente della programmazione asincrona è rappresentata da async/await, un'estensione del linguaggio introdotta in ES2017 (ECMAScript 8 edizione) e implementata in molti linguaggi tra cui C#, Python, Dart, Kotlin.
L'obiettivo è fornire uno stile di programmazione sincrono mantenendo un cuore asincrono. Con async/await, il codice asincrono appare e si comporta come codice sincrono, ma senza blocco.
async function waitFor(t) {
console.log("waitFor - pre");
await delay(t);
console.log("waitFor - post");
}
async function main() {
console.log("Before");
for (let i = 0; i < 3; i++) {
await waitFor(1000);
console.log("Step " + i);
}
console.log("After");
}
L'operatore await può essere applicato a qualsiasi Promise e sospende l'esecuzione della funzione fino a quando la Promise non viene risolta. Non blocca il flusso di controllo: il controllo viene ceduto (yield), salvando lo stato corrente della computazione. L'operatore await converte una Promise in un risultato o un'eccezione, esattamente come se fosse il risultato di una funzione sincrona.
Il professore sottolinea alcune criticità di async/await:
{...} non sono più garantiti atomici — l'esecuzione di un singolo blocco può estendersi su più iterazioni dell'event loop aprendo la strada a race condition.Un aspetto critico sottolineato dal professore è il design clash tra stile sincrono e asincrono: serve una forte disciplina nel mescolare i due stili per evitare programmi con comportamento difficile da comprendere. C'è bisogno di astrazioni di livello più alto che integrino naturalmente prospettiva sincrona e asincrona.
Le coroutine sono una generalizzazione del concetto di subroutine, che permette all'esecuzione di essere sospesa e ripresa (Conway, 1963). Rappresentano il meccanismo di basso livello alla base sia di async/await sia dei thread leggeri. A differenza dei thread tradizionali (preemptive), le coroutine usano scheduling cooperativo: una coroutine cede volontariamente il controllo (yield) ad un'altra.
flowchart LR
subgraph "Coroutine Producer"
P1[crea item] --> P2[yield to Consumer]
P2 --> P1
end
subgraph "Coroutine Consumer"
C1[prende item] --> C2[yield to Producer]
C2 --> C1
end
L'esempio classico mostra un sistema produttore-consumatore cooperativo a singolo flusso, dove produce e consume si alternano tramite yield.
Il professore introduce anche il concetto di fiber (o virtual thread in Java, da JDK 19): un thread leggero che, come i thread, condivide lo spazio di indirizzi, ma a differenza dei thread usa cooperazione invece di preemption per il multitasking. Le fiber sono implementabili con un singolo thread OS.
La differenza fondamentale: le coroutine sono un costrutto a livello di linguaggio (controllo di flusso), mentre le fiber sono un costrutto a livello di sistema (viste come thread che non eseguono in parallelo). Il dibattito "virtual threads vs async programming" è ancora aperto, come discusso da Brian Goetz in un articolo del 2022.
Kotlin è un esempio moderno di linguaggio che integra le coroutine come costrutto di primo livello, con dispatcher, builder (async, launch, runBlocking) e funzioni suspending. Vedi slide 63-68 del modulo 2.1 per i dettagli.
Ricollegandosi al modulo 1.3, il professore riprende i semafori come meccanismo fondamentale per la coordinazione tra processi. Introdotti da Dijkstra nel 1968, i semafori sono un costrutto semplice ma potente che permette di risolvere quasi ogni problema di mutua esclusione e sincronizzazione.
Un semaforo S è un tipo di dato composto con due campi:
Due operazioni atomiche fondamentali:
| Tipo | Range di V | Uso tipico |
|---|---|---|
| Mutex (binario) | {0, 1} | Mutua esclusione, lock |
| Counting (risorsa) | >= 0 | Gestione risorse multiple |
| Evento | Inizializzato a 0 | Sincronizzazione, segnalazione |
Esiste anche la distinzione tra semafori strong (con coda FIFO, nessuno starvation) e weak (con insieme, possibile starvation). I semafori busy-wait non hanno la componente S.L e usano attesa attiva.
La soluzione del problema della sezione critica per N processi è elegante:
// qualsiasi processo i
semaphore S = (1, {}); // mutex binario
loop forever {
p1: wait(S);
p2: /* sezione critica */
p3: signal(S);
p4: /* resto */
}
L'invariante del semaforo: S.V = k + #signal(S) - #wait(S) e S.V >= 0 (dove k è il valore iniziale). Questo teorema è fondamentale per la verifica formale dei programmi concorrenti con semafori.
Il deadlock è una situazione in cui due o più azioni in competizione attendono che le altre finiscano, e quindi nessuna procede mai. Il professore richiama le condizioni necessarie di Coffman (1971) per il verificarsi di un deadlock:
Il caso classico è il deadly embrace: thread A tiene il lock L e cerca M mentre thread B tiene M e cerca L. Nessuno dei due progredisce.
Le regole generali per evitare il deadlock: assegnare un ordine totale ai lock e acquisirli sempre nello stesso ordine. Questo rende impossibile la circular wait.
I semafori sono potenti ma di basso livello, portando a programmi soggetti a errori e difficili da usare in programmi concorrenti complessi. I monitor, introdotti da Brinch Hansen (1973) e generalizzati da Hoare (1974), rappresentano un'astrazione di livello più alto.
Un monitor è un'astrazione dati concorrente che incapsula lo stato, le operazioni e le politiche di sincronizzazione/mutua esclusione per l'accesso. Come un modulo OOP che include il costrutto di base per garantire la correttezza dell'accesso concorrente.
Proprietà fondamentali dei monitor:
| Semaforo | Condition Variable (monitor) |
|---|---|
| wait può non bloccare (se V > 0) | waitC blocca sempre |
| signal ha sempre effetto (incrementa o sblocca) | signalC non ha effetto se la coda è vuota |
| signal sblocca un processo arbitrario | signalC sblocca in testa alla coda FIFO |
| Il processo sbloccato riprende subito | Il processo sbloccato deve aspettare che chi ha fatto signal esca |
Tre discipline di segnalazione per il signalC:
Dove S = processi segnalanti, W = processi in attesa sulla condizione, E = processi bloccati sull'ingresso del monitor.
Il professore presenta due casi di studio classici risolti con i monitor, mostrando come l'astrazione permetta di incapsulare in modo pulito la sincronizzazione.
monitor BoundedBuffer {
int[] elems := new int[MAX_ELEMS];
int first := 0, last := 0;
cond notFull, notEmpty;
procedure put(int elem) {
if ((last + 1) % MAX_ELEMS == first)
waitC(notFull);
elems[last] = elem;
last := (last + 1) % MAX_ELEMS;
signalC(notEmpty);
}
procedure take(): int {
if (first == last)
waitC(notEmpty);
int elem = elems[first];
first = (first + 1) % MAX_ELEMS;
signalC(notFull);
return elem;
}
}
Il buffer circolare usa due variabili di condizione: notFull blocca il produttore se il buffer è pieno, notEmpty blocca il consumatore se il buffer è vuoto. signalC risveglia il processo appropriato dopo ogni operazione.
La soluzione usando un monitor con due variabili di condizione (okToRead, okToWrite):
monitor RWLock {
int nr, nw := 0;
cond okToRead, okToWrite;
procedure request_read() {
while (nw > 0) waitC(okToRead);
nr := nr + 1;
}
procedure release_read() {
nr := nr - 1;
if (nr == 0) signalC(okToWrite);
}
procedure request_write() {
while (nr > 0 or nw > 0) waitC(okToWrite);
nw := nw + 1;
}
procedure release_write() {
nw := nw - 1;
signalC(okToWrite);
signalAllC(okToRead);
}
}
L'invariante fondamentale: (nr == 0 or nw == 0) and (nw <= 1). Questa soluzione garantisce che i lettori non si escludano tra loro ma escludano gli scrittori, e viceversa.
La rete di Petri aggiunge il concetto di token, che fornisce una "fotografia runtime" dello stato in cui si trova il processo e le possibili evoluzioni. Con un token si rappresenta un flusso di controllo (un'istanza del processo), mentre con più token si rappresentano più istanze concorrenti. Inoltre, il token può anche rappresentare risorse (come permessi), unificando la modellazione di flusso di controllo e risorse di coordinazione.
Nell'event-driven programming c'è un singolo thread (event loop) che esegue handler atomici senza concorrenza. Non ci sono race condition a basso livello (nessuno stato condiviso). I thread tradizionali usano invece multitasking preemptive con memoria condivisa, che introduce il rischio di interferenze, deadlock, e race condition. L'event loop segue la regola del never-block e delega le operazioni bloccanti a worker thread in background.
Il CPS è uno stile di programmazione dove il controllo viene passato esplicitamente sotto forma di continuazione (callback). La callback viene invocata dall'event loop quando il risultato asincrono è pronto. Il problema principale è il callback hell (Pyramid of Doom): l'annidamento delle callback porta a codice illeggibile, difficile da mantenere, estendere e riutilizzare. Le Promise risolvono questo problema appiattendo l'annidamento grazie al chaining con .then().
Promise.all prende un array di Promise e restituisce una nuova Promise che si risolve quando tutte le Promise nell'array sono risolte, oppure si rifiuta appena una di esse si rifiuta. Restituisce un array con tutti i valori. È ideale quando si devono eseguire operazioni asincrone indipendenti in parallelo e serve attendere che tutte completino (pattern fork-join, join point). Promise.race, invece, si risolve o rifiuta appena la prima Promise si risolve/rifiuta, restituendo un singolo valore.
1) Mutua esclusione: risorsa non condivisibile; 2) Hold and wait: un processo tiene risorse mentre ne richiede altre; 3) No preemption: le risorse non possono essere revocate; 4) Circular wait: esiste una catena circolare di processi che attendono risorse detenute da altri. Per evitare il deadlock, basta rimuovere una qualsiasi delle quattro condizioni. La strategia pratica più comune è assegnare un ordine totale ai lock e acquisirli sempre in quell'ordine (rompe la circular wait).
Differenze chiave: wait su semaforo può non bloccare (se V > 0) mentre waitC su condition variable blocca sempre. signal su semaforo ha sempre effetto (incrementa o sblocca), mentre signalC non ha effetto se la coda è vuota. Inoltre, signalC rilascia il lock del monitor e il processo risvegliato deve attendere che il segnalante esca, mentre signal su semaforo fa riprendere immediatamente il processo sbloccato. signalC sblocca in testa alla coda FIFO, mentre signal su semaforo sblocca un processo arbitrario (nei weak semaphore).
1) await può essere usato solo dentro funzioni async, non al livello top-level. 2) Una funzione async non può riprendersi finché il thread è occupato con altri handler (la semantica dell'event loop rimane). 3) I blocchi {...} non sono più garantiti atomici: l'esecuzione di un singolo blocco può estendersi su più iterazioni dell'event loop, aprendo a race condition. 4) C'è un "design clash" tra stile sincrono e asincrono che richiede forte disciplina per evitare codice difficile da comprendere.