Il Prof. Ricci apre la lezione con alcuni avvisi amministrativi. Oltre ai quattro assignment standard, quest'anno viene introdotta una novità pensata per agevolare gli studenti lavoratori: la possibilità di concordare un colloquio personalizzato in cui gli assignment vengono sostituiti o ritagliati su un progetto reale legato all'attività lavorativa dello studente.
Se sei uno studente lavoratore o stai seguendo un progetto significativo in altre materie, puoi contattare il docente per concordare un esame su misura: il contenuto della prova pratica viene adattato al progetto o all'attività lavorativa. L'obiettivo è evitare la sovrapposizione di impegni, mantenendo la verifica delle competenze.
Il docente ricorda che le lezioni vengono regolarmente registrate e messe a disposizione proprio per facilitare chi deve organizzarsi in modo flessibile, non solo gli studenti lavoratori.
Uno degli aspetti fondamentali quando si scrivono programmi concorrenti è capire se l'uso di più thread porta effettivamente a un miglioramento delle prestazioni. Il tempo assoluto di esecuzione è significativo, ma per valutare l'efficacia della parallelizzazione si usano due metriche principali: lo speedup e l'efficienza.
Lo speedup misura quanto più veloce è l'esecuzione parallela rispetto a quella sequenziale. L'efficienza misura quanto bene vengono sfruttati i processori disponibili.
Lo speedup S si definisce come il rapporto fra il tempo di esecuzione sequenziale T1 e il tempo di esecuzione parallelo TN con N processori:
S = T1 / TN
Uno speedup ideale è lineare: S = N. In pratica è quasi sempre inferiore a causa della parte sequenziale del programma e dei costi di coordinamento.
La legge di Amdahl pone un limite fondamentale allo speedup ottenibile. Se P è la proporzione del programma che può essere parallelizzata, la parte rimanente (1 − P) è intrinsecamente sequenziale. Lo speedup massimo teorico è:
S = 1 / ((1 - P) + P/N)
Al crescere di N, lo speedup tende a 1 / (1 − P), quindi è la parte sequenziale a dominare il limite massimo.
La parte sequenziale è spesso necessaria per la correttezza (ad esempio la sincronizzazione). La legge di Amdahl ci ricorda che migliorare solo la parte parallela non basta: bisogna ridurre anche la serializzazione.
L'efficienza E normalizza lo speedup rispetto al numero di processori:
E = S / N
L'efficienza ideale è 1 (tutti i processori usati al 100%). Valori inferiori indicano overhead di parallelizzazione, contention o parti sequenziali. In alcuni casi particolari si può osservare uno speedup super-lineare (S > N), ad esempio per effetto della cache.
Quando si misurano le performance di un programma concorrente, bisogna tenere conto di:
Il docente mostra un esempio pratico chiamato IO-Bound Test per illustrare come il comportamento dei thread cambi radicalmente a seconda del tipo di carico di lavoro.
Un worker CPU-bound esegue solo computazione pura: in questo esempio ordina ripetutamente un array per simulare lavoro intensivo sulla CPU. Lanciando più thread CPU-bound su una macchina con, diciamo, 10 core:
Il professore mostra il monitor con Mission Control (o JConsole): si osserva che con 1 thread la CPU usage resta bassa, mentre con più thread sale vicino al 100%, segno che tutti i core sono saturati.
Un worker IO-bound simula operazioni di input/output (es. sleep, attesa di rete, lettura file). In questo caso i thread passano gran parte del tempo bloccati in attesa. La CPU rimane relativamente scarica anche con molti thread (es. 124 thread mostrano solo 6-9% di CPU usage).
Nei workload IO-bound, aumentare il numero di thread oltre il numero di core è utile: mentre un thread è bloccato in I/O, un altro thread può usare la CPU. Nei workload CPU-bound, superare il numero di core peggiora le prestazioni per il context switch.
Il worker nel test è progettato con un ciclo while che alterna casualmente fasi di computazione CPU (ordinamento array) e fasi di I/O (sleep). Questo evita la sincronizzazione perfetta in cui tutti i thread farebbero I/O contemporaneamente, simulando uno scenario più realistico dove alcuni thread computano mentre altri attendono.
// Pseudo-struttura del worker
public void run() {
while (true) {
if (casuale()) {
// Fase CPU-bound: ordina array ripetutamente
ordinaArray();
} else {
// Fase IO-bound: attesa (sleep)
Thread.sleep(5); // 5 ms di attesa
}
}
}
Usando JConsole si può monitorare l'utilizzo della CPU in tempo reale. Con workload IO-bound si nota che la CPU rimane poco utilizzata anche con molti thread, mentre con workload CPU-bound si avvicina al 100%. Strumenti più sofisticati come JProfiler (non più open source) o VisualVM permettono di analizzare nel dettaglio il comportamento dei singoli thread, individuando lock contention, thread bloccati e bottleneck di performance.
Il docente introduce un tema importante per la programmazione concorrente moderna in Java: la differenza tra thread tradizionali e virtual thread, introdotti come feature preview nelle versioni recenti di Java (Project Loom).
I platform thread (chiamati anche thread nativi o OS thread) sono direttamente associati a thread del sistema operativo. Ogni thread Java corrisponde a un thread del SO (su Linux, Windows, macOS).
I virtual thread (Project Loom, Java 21+) sono thread logici gestiti interamente dalla JVM, non dal sistema operativo.
Thread viene riutilizzata: la creazione avviene con Thread.ofVirtual().I virtual thread separano il modello di concorrenza (logico, orientato al problema) dall'implementazione fisica (thread OS). Permettono di strutturare un programma con centinaia di migliaia di entità concorrenti senza preoccuparsi del numero di core disponibili.
Il docente sottolinea che questa distinzione permette di usare un approccio orientato al problema e al dominio, senza dover ottimizzare manualmente il numero di thread in base all'hardware sottostante.
In ingegneria del software, i modelli sono descrizioni rigorose della struttura e del comportamento di un programma a un adeguato livello di astrazione. Per i programmi concorrenti, definire modelli appropriati permette di:
Un buon modello include le informazioni rilevanti e astrae da quelle non rilevanti, permettendo di ragionare sul comportamento dinamico dei programmi concorrenti e sulla loro verifica.
Il modello classico per l'esecuzione concorrente si basa su due assunzioni fondamentali:
In pratica si immagina un singolo processore globale astratto che esegue tutte le azioni, una dopo l'altra, in un ordine non determinato a priori. Una computazione o scenario è una sequenza di esecuzione che può effettivamente verificarsi come risultato dell'interleaving.
La lezione richiama le basi hardware della programmazione concorrente, già introdotte nel modulo 1.1. L'evoluzione hardware ha reso la programmazione concorrente non più un'opzione ma una necessità.
Herb Sutter nel 2005 scrisse il famoso articolo «The Free Lunch Is Over»: la crescita della frequenza dei processori si era arrestata per limiti fisici (dissipazione termica), e l'incremento di potenza sarebbe venuto solo da più core. La programmazione sequenziale non avrebbe più beneficiato di speedup gratuiti.
La tassonomia di Flynn classifica i sistemi di calcolo in base al numero di flussi di istruzioni e di dati:
| Categoria | Descrizione |
|---|---|
| SISD | Single Instruction, Single Data — classico modello di Von Neumann, processore singolo. |
| SIMD | Single Instruction, Multiple Data — parallelismo fine, processori vettoriali, GPU. |
| MISD | Multiple Instruction, Single Data — nessun sistema noto corrisponde a questa categoria. |
| MIMD | Multiple Instruction, Multiple Data — ogni processore ha il proprio flusso di istruzioni e dati. È la categoria più diffusa. |
I sistemi MIMD si dividono ulteriormente in base all'organizzazione della memoria:
| Modello | Memoria | Comunicazione |
|---|---|---|
| Memoria condivisa | Singolo spazio di indirizzi | Lettura/scrittura di variabili condivise |
| Memoria distribuita | Ogni processo ha il proprio spazio | Message passing (send/receive) |
La memoria condivisa si distingue in SMP (Symmetric Multi-Processing, accesso uniforme) e NUMA (Non-Uniform Memory Access, accesso non uniforme). La memoria distribuita include MPP (Massively Parallel Processors), cluster e grid.
I processori moderni combinano core di diverso tipo:
Esistono inoltre sistemi dedicati all'AI (es. Cerebras CS-3), supercomputer come Fugaku (7,6 milioni di core), e infrastrutture cloud come AWS EC2, Microsoft Azure, Google App Engine.
Ogni programma concorrente non banale si basa su processi che interagiscono. Si distinguono tre tipi fondamentali di interazione:
| Tipo | Descrizione | Esempio |
|---|---|---|
| Cooperazione | Interazioni attese e desiderate, parte della semantica del programma. | Comunicazione (scambio di messaggi), sincronizzazione (relazioni temporali). |
| Competizione/Contention | Interazioni attese e necessarie ma non desiderate: accesso concorrente a risorse condivise. | Mutua esclusione, sezioni critiche. |
| Interferenza | Interazioni né attese né desiderate: producono effetti solo quando il rapporto fra le velocità dei processi assume valori specifici (errori tempo-dipendenti). | Race condition, heisenbug. |
Le interferenze sono il «incubo» della programmazione concorrente. Si manifestano solo in specifiche condizioni di scheduling (heisenbug) e sono difficilissime da riprodurre in debug perché il debugger stesso altera i tempi di esecuzione.
Una race condition si verifica quando due o più processi accedono e modificano risorse condivise contemporaneamente e il risultato finale dipende dall'ordine specifico in cui gli accessi avvengono.
Gli errori nei programmi concorrenti possono portare a tre situazioni critiche principali:
| Problema | Descrizione | Riguarda |
|---|---|---|
| Deadlock | Due o più processi aspettano ciascuno che l'altro rilasci una risorsa, nessuno procede. | Più processi |
| Starvation | Un processo è bloccato in attesa infinita, negato perpetuamente nell'accesso alle risorse necessarie. | Un singolo processo |
| Livelock | Simile al deadlock, ma gli stati dei processi cambiano continuamente senza che nessuno progredisca. | Più processi (caso speciale di starvation) |
Il docente (e le slide) riportano la classica analogia di Alice e Bob che devono comprare il latte quando finisce. Senza coordinamento, possono verificarsi scenari in cui:
Questa analogia illustra chiaramente il problema della sezione critica: servono protocolli di ingresso e uscita per garantire l'accesso esclusivo a una risorsa condivisa.
Il modello base per l'esecuzione concorrente considera ogni processo come una sequenza di azioni atomiche. L'esecuzione concorrente è ottenuta interlacciando arbitrariamente le azioni dei processi, come se ci fosse un unico processore globale che esegue tutto in sequenza.
p: q:
integer k1 := 1 integer k2 := 2
p1: n := k1 q1: n := k2
integer n := 0 (variabile globale)
Con due processi e un'azione ciascuno, ci sono solo 2 scenari possibili:
Il risultato finale dipende dall'ordine di esecuzione delle due azioni atomiche.
Questo esempio fondamentale mostra che lo stesso input può produrre output diversi a seconda dello scenario di interleaving. È questa la radice della difficoltà della programmazione concorrente.
L'esecuzione di un programma concorrente può essere rappresentata formalmente con stati e transizioni. Uno stato è definito da una tupla che include:
C'è una transizione tra due stati s1 e s2 se l'esecuzione di un'istruzione in s1 porta allo stato s2. Il diagramma di stato contiene tutti gli stati raggiungibili del programma. Gli scenari sono cammini orientati nel diagramma a partire dallo stato iniziale. I cicli rappresentano la possibilità di computazione infinita.
p: q:
p1: print("p1") q1: print("q1")
p2: print("p2") q2: print("q2")
Ci sono 6 scenari possibili. Il diagramma di stato ha 9 stati (considerando anche gli stati intermedi e finali).
Il numero di scenari cresce esplosivamente con il numero di azioni e di processi. La formula generale per m processi ciascuno con n azioni è:
Numero scenari = (m * n)! / (n!)^m
Ecco alcuni valori illustrativi:
| Processi | Azioni per processo | Scenari |
|---|---|---|
| 2 | 1 | 2 |
| 2 | 2 | 6 |
| 2 | 3 | 20 |
| 2 | 4 | 70 |
| 2 | 5 | 252 |
| 3 | 2 | 90 |
| 3 | 3 | 1680 |
Questa crescita esponenziale (chiamata esplosione degli scenari) è uno dei problemi fondamentali della verifica dei programmi concorrenti.
Una delle questioni più delicate nella modellazione concorrente è cosa si considera atomico. Il docente mostra la differenza cruciale tra un incremento atomico e uno non atomico.
p: n := n + 1
q: n := n + 1
n iniziale = 0
Se n := n + 1 è atomico, l'unico possibile risultato finale è n = 2. Non ci sono interleaving possibili all'interno dell'operazione.
p: q:
integer tmp integer tmp
p1: tmp := n q1: tmp := n
p2: n := tmp + 1 q2: n := tmp + 1
n iniziale = 0
Se l'incremento è scomposto in lettura e scrittura, possono verificarsi scenari in cui il valore finale è n = 1 (entrambi leggono 0, entrambi scrivono 1). Su 6 scenari possibili, solo 2 portano a n = 2.
Il diagramma ha 12 stati. Solo 2 scenari su 6 portano al valore finale corretto n = 2. La mutua esclusione sull'accesso a n è violata proprio perché l'operazione non è atomica.
Scegliere il giusto livello di atomicità è fondamentale nella modellazione. Se si considera atomica un'operazione che in realtà non lo è (dal punto di vista del supporto hardware/sistema), il modello potrebbe nascondere scenari errati che in pratica si verificano.
A livello di macchina, un incremento si scompone in più istruzioni:
In entrambi i casi, se c'è interleaving tra queste micro-istruzioni, si possono produrre valori inconsistenti.
Il concetto di atomicità si estende anche alle strutture dati. Un oggetto dati atomico può trovarsi in un numero finito di stati pari al numero di valori che può assumere. I tipi primitivi nei linguaggi concorrenti sono generalmente atomici (ma non sempre: es. double in Java non è atomico su tutte le piattaforme).
I tipi di dato astratti (ADT) composti da più oggetti semplici sono tipicamente non atomici (es. classi in linguaggi OO, struct in C). Per un ADT si distinguono:
La corrispondenza tra stati interni ed esterni è parziale: esistono stati interni che non hanno un corrispondente stato esterno (stati consistenti).
L'esecuzione di un'operazione su un ADT non atomico può attraversare stati inconsistenti. In programmazione sequenziale questo non è un problema (grazie all'information hiding), ma in programmazione concorrente si crea una situazione pericolosa: un processo potrebbe operare su un oggetto mentre un altro processo lo sta modificando, trovandolo in uno stato inconsistente.
Serve introdurre meccanismi che garantiscano che i processi lavorino sempre su oggetti dati in stati consistenti. È qui che entrano in gioco lock, semafori, monitor e altri meccanismi di sincronizzazione.
Quando i processi contengono cicli (ad esempio while basati su condizioni), il diagramma di stato può contenere cicli, rappresentando computazioni potenzialmente infinite.
p: q:
p1: while (n < 1) q1: while (n >= 0)
p2: n := n + 1 q2: n := n - 1
n iniziale = 1
Il diagramma di stato per questo esempio ha molti stati e cicli. Si possono costruire scenari in cui il ciclo in p esegue esattamente una iterazione oppure tre iterazioni, a seconda dell'interleaving con q.
I cicli nei processi concorrenti rendono il diagramma di stato potenzialmente infinito (o molto grande). La non-terminazione è una possibilità reale in molti sistemi concorrenti (sistemi di controllo, server, sistemi operativi).
La correttezza dei programmi concorrenti non può essere verificata con il testing tradizionale: lo stesso input può dare output diversi a seconda dello scenario. Serve un approccio basato su proprietà formali delle computazioni.
Una proprietà di safety deve essere sempre vera in ogni stato di ogni computazione. Viene espressa come invariante della computazione. Tipicamente specifica che «cose brutte non devono mai accadere»:
Una proprietà di liveness deve diventare vera prima o poi: in ogni computazione esiste uno stato in cui la proprietà è vera. Specifica che «cose buone devono eventualmente accadere»:
La fairness è una proprietà di liveness che richiede che qualcosa di buono accada infinitamente spesso. Si declina in tre livelli:
| Tipo | Definizione |
|---|---|
| Unconditional fairness | Ogni azione atomica incondizionata che è idonea viene eseguita prima o poi. |
| Weak fairness | Unconditional fairness + ogni azione condizionale la cui condizione diventa e rimane vera viene eseguita prima o poi. |
| Strong fairness | Unconditional fairness + ogni azione condizionale la cui condizione diventa vera infinite volte viene eseguita prima o poi. |
Un algoritmo può terminare o meno a seconda che si assumano scenari fair o unfair. Ad esempio, se un processo è in un ciclo che attende un flag, uno scenario unfair potrebbe non eseguire mai l'istruzione che setta il flag, portando a non-terminazione.
Introdotto da Dijkstra nel 1965, il Critical Section Problem è uno dei più importanti e studiati problemi della programmazione concorrente. Si colloca nel contesto della competizione fra processi.
Abbiamo N processi, ciascuno esegue un ciclo infinito di istruzioni divisibili in due sottosequenze:
P[1..n]:
loop {
<non-critical section>
entry (o pre-) protocol
<critical section>
exit (o post-) protocol
<non-critical section>
}
Il problema è progettare i protocolli di ingresso e uscita (entry/exit protocol) che garantiscano tre proprietà:
Si aggiunge la proprietà di bounded waiting (attesa limitata). Inoltre, una volta che un processo inizia la CS, deve completarla (progress property per la CS). La NCS non ha obbligo di progresso (può contenere cicli infiniti).
integer turn := 1
p: q:
loop forever loop forever
NCS NCS
await turn = 1 await turn = 2
CS CS
turn := 2 turn := 1
Il diagramma di stato comprende 16 stati. La mutua esclusione è soddisfatta (non esiste stato <p3,q3,_>). L'assenza di deadlock è soddisfatta. Tuttavia, la starvation è possibile: se un processo rimane indefinitamente nella sua NCS, il turn non cambia e l'altro processo non può mai entrare in CS. La soluzione non è corretta.
boolean wantp := false, wantq := false
Si introducono variabili separate per indicare l'intenzione di ogni processo di entrare in CS. Ma la mutua esclusione non è garantita: lo stato <p3,q3,true,true> è raggiungibile a causa della non-atomicità del pre-protocollo.
Spostando l'assegnamento di wantp := true prima dell'await, la mutua esclusione è risolta, ma si introduce un deadlock (più precisamente un livelock): entrambi i processi possono rimanere bloccati con want = true, nessuno dei due può entrare.
Si richiede a un processo di rinunciare alla sua intenzione se scopre che l'altro è in competizione. Il livelock è risolto, ma rimane la possibilità di starvation in caso di interleaving perfetto (alternanza continua).
Trovato dal matematico olandese T.J. Dekker nel 1965, è la prima soluzione corretta al problema della sezione critica per due processi. Combina il primo (turn) e il quarto tentativo: il diritto di insistere per entrare (non il diritto di entrare) viene passato esplicitamente tra i processi usando la variabile turn.
boolean wantp := false
boolean wantq := false
integer turn := 1
p: q:
loop forever loop forever
NCS NCS
wantp := true wantq := true
while wantq = true while wantp = true
if turn = 2 then if turn = 1 then
wantp := false wantq := false
await turn = 1 await turn = 2
wantp := true wantq := true
CS CS
turn := 2 turn := 1
wantp := false wantq := false
Dekker soddisfa mutua esclusione, assenza di deadlock e assenza di starvation. Funziona su qualsiasi architettura che fornisca solo load e store come istruzioni atomiche.
Proposto da Peterson nel 1981, è più conciso. Collassa due istruzioni await in una singola condizione composta:
boolean wantp := false
boolean wantq := false
integer turn := 1
p: q:
loop forever loop forever
NCS NCS
wantp := true wantq := true
turn := 2 turn := 1
await !wantq or turn = 1 await !wantp or turn = 2
CS CS
wantp := false wantq := false
Peterson è corretto e più semplice di Dekker. È spesso usato come esempio didattico di soluzione al problema della sezione critica.
Dekker funziona con solo load/store, ma se il calcolatore concorrente fornisce istruzioni atomiche più potenti, il problema della sezione critica si semplifica enormemente.
L'istruzione test-and-set è un'operazione atomica che esegue due operazioni senza possibilità di interleaving:
test-and-set(x, r):
< r := x, x := 1 >
Usando le parentesi angolari < > per denotare l'atomicità, la sezione critica con test-and-set diventa:
integer lock := 0
p: q:
integer was_locked integer was_locked
loop forever loop forever
NCS NCS
repeat repeat
test-and-set(lock, was_locked) test-and-set(lock, was_locked)
until was_locked = 0 until was_locked = 0
CS CS
lock := 0 lock := 0
Altre istruzioni atomiche importanti includono exchange, fetch-and-add, compare-and-swap (CAS).
Sfruttando istruzioni atomiche composte si può realizzare un meccanismo di lock elementare: due operazioni atomiche (acquire e release) proteggono la sezione critica.
lock sharedlock
p: q:
loop forever loop forever
NCS NCS
acquire(sharedlock) acquire(sharedlock)
CS CS
release(sharedlock) release(sharedlock)
In Java, blocchi synchronized sullo stesso oggetto lock garantiscono l'atomicità di sequenze di azioni, impedendo interleaving come a.c.b.d o c.a.d.b:
// Processo (thread) A
synchronized (lock) {
// statement a
// statement b
}
// Processo (thread) B
synchronized (lock) {
// statement c
// statement d
}
Per N processi, l'algoritmo del fornaio introduce biglietti numerati per stabilire il turno:
int num := 1
int next := 1
turn[1:n] := [0, 0, 0, ...]
P[i]:
loop forever
NCS
< turn[i] := num; num := num + 1 >
await turn[i] = next
CS
next := next + 1
Potenziali problemi: overflow aritmetico per turn e num (crescono monotonicamente).
| Attività | Descrizione |
|---|---|
| Testing | Ricerca di failure rivelando fault. Verifica una proprietà per alcuni scenari selezionati. Rivela la presenza di errori, non la loro assenza. |
| Verification | Verifica che una proprietà valga per tutti gli scenari possibili. Richiede tecniche formali. |
| Validation | Verifica che il sistema soddisfi le aspettative del cliente («abbiamo costruito il sistema giusto?»). |
Il testing tradizionale è insufficiente per programmi concorrenti: ogni esecuzione segue uno scenario diverso, e i bug concorrenti sono spesso heisenbug (scompaiono in debug). Servono metodi formali basati su modelli astratti.
Proposta da Amir Pnueli nel 1977, la logica temporale lineare aggiunge operatori temporali alla logica proposizionale per esprimere proprietà che devono valere in tutti gli scenari possibili.
| Operatore | Notazione | Significato |
|---|---|---|
| Always (Box) | □ p o G p | p è vera in tutti gli stati futuri |
| Eventually (Diamond) | ◇ p o F p | p diventa vera in qualche stato futuro |
| Next | ○ p o X p | p è vera nello stato successivo |
| Until | p U q | q diventa vera prima o poi, e p rimane vera fino ad allora |
| Weak Until | p W q | Come Until, ma q può anche non diventare mai vera (in tal caso p rimane vera per sempre) |
□ ¬(p3 ∧ q3)□ (p2 ⇒ ◇ p3)¬□ p ⇒ ◇ ¬pLa libertà da starvation può essere una proprietà debole: è vero che ◇ CSp, ma se q entra in CS 1000 volte prima di p, la proprietà è tecnicamente soddisfatta ma il comportamento è insoddisfacente. La proprietà di k-bounded overtaking richiede che un altro processo possa entrare al massimo k volte prima che il processo in attesa entri. Si esprime con l'operatore Weak Until:
tryp ⇒ (¬CSq) W (CSq W ((¬CSq) W CSp))
Per 1-bounded overtaking: da quando p cerca di entrare, q può entrare al massimo una volta prima di p.
Il model checking è la tecnica più importante per la verifica automatica di proprietà di correttezza nei sistemi concorrenti. La strategia è basata sull'esplorazione esaustiva di tutto lo spazio degli stati del sistema.
Applicazioni: verifica hardware (Intel dopo il Pentium Bug del 1994), sistemi software critici (NASA dopo l'incidente del Mars Polar Lander nel 1999), Amazon Web Services.
Il problema principale del model checking è la dimensione dello spazio degli stati. Tecniche allo stato dell'arte:
SPIN è un model checker ampiamente usato in ambito accademico e industriale. PROMELA è il linguaggio di modellazione usato da SPIN per descrivere sistemi concorrenti con un insieme limitato di costrutti.
// Esempio: Dekker in PROMELA (struttura)
proctype P() {
/* dichiarazioni e codice */
}
proctype Q() {
/* dichiarazioni e codice */
}
JPF è un model checker specializzato per la verifica di programmi Java, sviluppato dalla NASA e open source. È una JVM speciale che esegue i programmi teoricamente lungo tutti i possibili scenari (percorsi di esecuzione), verificando violazioni di proprietà come deadlock, eccezioni non catturate, violazioni di assert.
Tecnica complementare al model checking: si dimostra che una formula è un invariante (vera in ogni stato di ogni computazione) per induzione:
Esistono sistemi deduttivi che automatizzano questo processo.
TLA+ (Temporal Logic of Actions) è un linguaggio di specifica formale introdotto da Leslie Lamport, usato per specificare e verificare sistemi reali complessi (es. Amazon Web Services). Una specifica TLA+ descrive l'insieme di tutti i possibili comportamenti legali (trace di esecuzione) di un sistema.
Può descrivere sia le proprietà di correttezza desiderate (il «cosa») sia il progetto del sistema (il «come»). Il model checker TLC verifica le proprietà su specifiche TLA+.
PlusCal (ex +CAL) è un linguaggio algoritmico basato su TLA+ per scrivere algoritmi. Un algoritmo PlusCal viene tradotto in una specifica TLA+, che può essere verificata con TLC.
-- algorithm Peterson {
variables flag = [ i in {0,1} |-> false ], turn = 0;
process (proc in {0,1}) {
a0: while (true) {
a1: flag[self] := true;
a2: turn := Not(self);
a3a: if (flag[Not(self)]) { goto a3b } else { goto cs };
a3b: if (turn = Not(self)) { goto a3a } else { goto cs };
cs: skip; \* sezione critica
a4: flag[self] := false;
}
}
}
Con TLC si può verificare la proprieta di mutua esclusione: MutualExclusion = (pc[0] != "cs") or (pc[1] != "cs"). Per algoritmi con troppi stati, TLC non può verificare esaustivamente e si passa alla dimostrazione deduttiva.
Lo speedup è il rapporto <var>S = T<sub>1</sub> / T<sub>N</sub></var>, dove <var>T<sub>1</sub></var> è il tempo di esecuzione sequenziale e <var>T<sub>N</sub></var> il tempo parallelo con <var>N</var> processori. Misura quanto più veloce è l'esecuzione parallela.
In un workload CPU-bound i thread eseguono computazione pura: aumentare i thread oltre il numero di core peggiora le prestazioni (context switch). In un workload IO-bound i thread passano gran parte del tempo bloccati in attesa: avere più thread del numero di core è utile perché mentre uno è bloccato, un altro può usare la CPU.
I platform thread sono mappati 1:1 su thread del sistema operativo: sono pesanti da creare e il context switch è costoso. I virtual thread (Java 21+) sono gestiti dalla JVM su un pool di pochi OS thread: sono leggerissimi e se ne possono creare centinaia di migliaia senza overhead significativo.
20 scenari. La formula generale è <var>(m × n)! / (n!)<sup>m</sup></var>, dove m = processi e n = azioni per processo. Per 2 processi e 3 azioni: 6! / (3! × 3!) = 720 / 36 = 20.
Una proprietà di safety deve essere sempre vera in ogni stato di ogni computazione («cose brutte non devono mai accadere»). Esempi: mutua esclusione, assenza di deadlock. Si esprime in LTL con l'operatore <code>□</code> (always).
1) Mutua esclusione (mai due processi contemporaneamente in CS). 2) Assenza di deadlock (se qualcuno cerca di entrare, prima o poi qualcuno ci riesce). 3) Assenza di starvation individuale (se un processo cerca di entrare, prima o poi ci riesce). A queste si aggiunge il bounded waiting.
L'istruzione <code>test-and-set(x, r)</code> esegue atomicamente: <code>r := x; x := 1</code>. Restituisce il vecchio valore di <code>x</code> in <code>r</code> e setta <code>x</code> a 1. È utile per implementare lock: se il lock era 0 (libero), diventa 1 (acquisito); se era 1 (occupato), il processo deve riprovare.
Il testing verifica una proprietà per alcuni scenari selezionati (rivela la presenza di errori, non la loro assenza). Il model checking verifica ESAUSTIVAMENTE tutti gli scenari possibili (rivela l'assenza di errori, ma soffre di esplosione dello spazio degli stati).
Significa: «in ogni stato, se il controllo è a p2 (sto cercando di entrare in CS), allora prima o poi sarà a p3 (sarò nella CS)». È una proprietà di liveness (assenza di starvation).
TLA+ (Lamport) è un linguaggio di specifica formale per descrivere comportamenti di sistemi concorrenti e verificare proprietà di correttezza. PlusCal è un linguaggio algoritmico che si traduce in TLA+. Entrambi supportano il model checking con TLC.