Per comprendere il comportamento di un programma concorrente non possiamo affidarci all'esecuzione reale: il non-determinismo dell'interleaving fa si che uno stesso input possa produrre output diversi a seconda dello scenario. Servono modelli che astraggano dal tempo e dall'architettura specifica.
Un programma concorrente e modellato come un insieme di processi, ciascuno dei quali esegue una sequenza di azioni atomiche. L'esecuzione complessiva e una sequenza ottenuta interleaving arbitrario delle azioni dei processi, come se ci fosse un singolo processore globale astratto che le esegue una alla volta.
Assumiamo la speed independence: non facciamo assunzioni sulla velocita relativa dei processi. Questo rende il modello robusto rispetto a cambiamenti hardware e permette l'analisi formale.
Ogni processo ha un controll pointer (o instruction pointer) che indica la prossima istruzione eseguibile. Lo stato globale del sistema e definito da una tupla contenente il control pointer di ogni processo e il valore di tutte le variabili (sia globali condivise che locali private).
Due processi P e Q che assegnano valori a una variabile condivisa n:
integer k1 := 1
p1: n := k1
integer k2 := 2
q1: n := k2
integer n := 0
L'esecuzione puo seguire due scenari:
p1, q1 → n = 2q1, p1 → n = 1Il risultato finale dipende dall'ordine con cui le istruzioni vengono interleave: questo e il cuore del non-determinismo nei programmi concorrenti.
Un diagramma di stato (state diagram) rappresenta formalmente l'esecuzione del programma concorrente: ogni nodo e uno stato del sistema, ogni arco e una transizione causata dall'esecuzione di un'istruzione atomica da parte di un processo.
La tupla di stato contiene un elemento per ogni control pointer e per ogni variabile globale o locale rilevante. Partendo dallo stato iniziale, si applicano tutte le transizioni possibili in modo da generare il grafo completo degli stati raggiungibili.
Il diagramma degli stati si costruisce incrementalmente: per ogni stato, si considera ogni processo e si esegue la sua prossima istruzione atomica, generando un nuovo stato. Si continua fino a coprire tutti gli stati raggiungibili.
Il numero di scenari possibili cresce in modo combinatorio. Con 2 processi che hanno ciascuno m azioni atomiche, il numero di scenari possibili e dato da:
(2m)! / (m! m!)
| azioni per proc. | scenari (2 proc.) | scenari (3 proc.) |
|---|---|---|
| 1 | 2 | 6 |
| 2 | 6 | 90 |
| 3 | 20 | 1680 |
| 4 | 70 | 34650 |
| 5 | 252 | — |
| 6 | 924 | — |
Questa crescita esplosiva e la ragione per cui la verifica manuale dei programmi concorrenti e complessa e per cui servono strumenti automatici come il model checker.
Valori validi quando le azioni dei processi non hanno dipendenze. Con operazioni in competizione su variabili condivise, alcuni scenari sono equivalenti ma il numero totale resta elevato.
La scelta di cosa e atomico e fondamentale nel modello. Un'istruzione atomica viene eseguita fino al completamento senza possibilita di interleaving con altre istruzioni.
Consideriamo l'incremento di una variabile condivisa n:
{
Caso atomico:
p1: n := n + 1 q1: n := n + 1
Caso non-atomico (a livello macchina):
integer tmp;
p1: tmp := n q1: tmp := n
p2: n := tmp + 1 q2: n := tmp + 1
}
integer n := 0
Nel caso non-atomico, lo n := n + 1 viene tradotto in due istruzioni (load e store). Se i due processi si interleave tra load e store, si possono ottenere scenari in cui il valore finale di n e 1 invece di 2 — una race condition.
Nel caso non-atomico, solo 2 scenari su 6 producono il valore finale n = 2. Gli altri 4 scenari producono n = 1 perche entrambi i processi leggono il valore iniziale 0 prima che l'altro lo incrementi.
Il concetto di atomicita si applica anche alle strutture dati. Un oggetto dati atomico puo trovarsi solo in un numero finito di stati corrispondenti ai valori che puo assumere. I tipi primitivi nei linguaggi concorrenti sono tipicamente atomici (eccezione: double in Java).
Per un tipo di dato astratto (ADT) non-atomico, possiamo distinguere:
La corrispondenza fra stati interni ed esterni e parziale: esistono stati interni senza corrispondente esterno (stati inconsistenti). In programmazione sequenziale questo non e un problema grazie all'information hiding; in programmazione concorrente, un processo potrebbe osservare un oggetto in uno stato inconsistente mentre un altro processo lo sta modificando.
E necessario introdurre meccanismi che garantiscano che i processi lavorino sempre su oggetti in stati consistenti — da qui nasce l'esigenza della sezione critica.
La correttezza dei programmi concorrenti (anche non terminanti) si definisce in termini di proprieta sulle computazioni, condizioni che devono valere in ogni possibile scenario di esecuzione.
Una proprieta di safety deve essere sempre vera in ogni stato di ogni computazione. Esprime che "cose cattive non devono mai accadere". Si esprime come invariante della computazione.
Esempi:
Una proprieta di liveness (progress) deve prima o poi diventare vera. In ogni computazione esiste uno stato in cui la proprieta e verificata. Esprime che "cose buone alla fine accadono".
Esempi:
La verifica delle proprieta di safety e piu semplice: basta trovare un stato che violi la proprieta per concludere la non-correttezza. La verifica delle proprieta di liveness richiede di analizzare tutti i possibili scenari, non solo gli stati singoli.
La fairness e una proprieta di liveness per cui qualcosa di buono accade infinitamente spesso. In pratica, riguarda la politica di scheduling: un'azione che puo essere eseguita, prima o poi verra effettivamente eseguita.
Esistono tre livelli di fairness:
| Tipo | Descrizione | Esempio |
|---|---|---|
| Incondizionata | Ogni azione atomica incondizionata che e eleggibile viene prima o poi eseguita | n := n + 1 (nessuna guardia) |
| Debole | Incondizionata + ogni azione condizionale la cui condizione diventa e rimane vera viene eseguita | await turn = 1 con turn che diventa e resta 1 |
| Forte | Incondizionata + ogni azione condizionale la cui condizione diventa vera infinitamente spesso viene eseguita | await turn = 1 con turn che diventa 1 periodicamente |
Uno scenario e unconditionally fair se, in ogni stato, un'istruzione che e continuamente abilitata appare prima o poi nello scenario. Consideriamo il programma:
p1: while flag = false
p2: n := 1 - n
q1: flag := true
integer n := 0
boolean flag := false
Se permettiamo scenari non-fair, il processo P puo loopare all'infinito senza che Q esegua mai q1, e l'algoritmo non termina. Solo in scenari fair, Q viene prima o poi schedulato e la computazione termina.
Introdotto da Dijkstra nel 1965, il problema della sezione critica (Critical Section problem) e uno dei problemi piu importanti e studiati nella programmazione concorrente, nell'ambito della competizione tra processi.
Definizione: N processi, ciascuno in esecuzione in un loop infinito, eseguono una sequenza di istruzioni divisibile in due parti: la sezione critica (CS) e la sezione non critica (NCS).
P[1..n]:
loop forever
<non-critical section>
<entry (o pre-) protocol>
<critical section>
<exit (o post-) protocol>
<non-critical section>
Il compito e progettare i protocolli di entrata e uscita che soddisfino le seguenti proprieta:
| Proprieta | Descrizione |
|---|---|
| Mutua esclusione | Le sezioni critiche di due o piu processi non devono essere interleave |
| Assenza di deadlock | Se qualche processo cerca di entrare in CS, uno di loro deve prima o poi riuscirci |
| Assenza di starvation | Se un processo cerca di entrare in CS, deve prima o poi riuscirci |
| Bounded waiting | Esiste un limite superiore al numero di volte che altri processi possono entrare in CS prima che un processo in attesa ci riesca |
La sezione critica non e una zona di memoria: e una sezione di codice. Quando un processo e nella propria sezione critica, gli altri processi possono fare qualunque cosa — l'importante e che non siano anch'essi nella loro sezione critica.
Si assume la proprieta di progress per la CS: una volta che un processo inizia la CS, deve prima o poi terminarla. La NCS, invece, non deve necessariamente progredire: un processo puo rimanere in NCS indefinitamente o terminare.
turnIl primo tentativo usa una variabile condivisa turn che indica a chi spetta entrare in sezione critica:
integer turn := 1
p q
loop forever loop forever
p1: NCS q1: NCS
p2: await turn = 1 q2: await turn = 2
p3: CS q3: CS
p4: turn := 2 q4: turn := 1
Verifica di mutua esclusione: il diagramma di stato ha 16 stati (con NCS e CS espansi) o 4 stati (versione ridotta rimuovendo NCS e CS). Non esistono stati <p2,q2,_> nella versione ridotta (o <p3,q3,_> in quella completa) — la mutua esclusione e soddisfatta.
Assenza di deadlock: se entrambi vogliono entrare, uno dei due otterra il turno giusto. Anche questa e soddisfatta.
Assenza di starvation: NON soddisfatta. Se il processo P esegue la CS e poi si ferma nella NCS (o termina), Q rimane bloccato in q2: await turn = 2 per sempre, perche turn e rimasto a 2. turn funziona come un permesso di accesso: se chi detiene il permesso non lo rilascia (perche e bloccato in NCS), l'altro processo non potra mai entrare.
La variabile turn crea una dipendenza rigida: il testimone passa avanti e indietro. Se un processo si ferma dopo aver usato la CS, l'intero sistema si blocca per l'altro processo.
want separateIntroduciamo variabili booleane separate per indicare l'intenzione di entrare in CS:
boolean wantp := false, wantq := false
p: q:
p1: NCS q1: NCS
p2: while wantq skip q2: while wantp skip
p3: wantp := true q3: wantq := true
p4: CS q4: CS
p5: wantp := false q5: wantq := false
Il problema e che il pre-protocol (test + set) non e atomico: esiste uno scenario in cui entrambi i processi passano il test (while) prima che uno dei due setti la propria variabile, portando allo stato <p3,q3,true,true> dove entrambi entrano in CS. Mutua esclusione violata.
Spostiamo l'assegnamento prima dell'await, rendendo l'intenzione immediata:
boolean wantp := false, wantq := false
p: q:
p1: wantp := true q1: wantq := true
p2: await wantq = false q2: await wantp = false
p3: CS q3: CS
p4: wantp := false q4: wantq := false
La mutua esclusione ora e soddisfatta. Tuttavia, se entrambi settano la propria variabile a true prima di eseguire l'await, si trovano in deadlock: P aspetta che wantq sia false, ma wantq e true e Q non puo cambiarlo perche e bloccato ad aspettare wantp false. Piu precisamente, e un livelock (entrambi sono attivi ma nessuno progredisce).
Se un processo scopre che l'altro e in competizione, rinuncia temporaneamente alla sua intenzione:
p: q:
p1: wantp := true q1: wantq := true
p2: while wantq do q2: while wantp do
p3: wantp := false q3: wantq := false
p4: wantp := true q4: wantq := true
p5: CS q5: CS
p6: wantp := false q6: wantq := false
Il livelock e risolto: i processi ora rilasciano l'intenzione quando c'e contesa. Tuttavia, in caso di interleaving perfetto (scenario simmetrico), puo verificarsi starvation: entrambi continuano a settare e resettare la propria variabile all'infinito senza mai entrare in CS.
| Tentativo | Idea | Problema |
|---|---|---|
| 1 (turn) | Variabile turn che alterna il permesso | Starvation se l'altro si ferma in NCS |
| 2 (want) | Flag booleani di intenzione | Violazione mutua esclusione (non atomicita del pre-protocol) |
| 3 (want + await) | Set prima dell'await | Deadlock / livelock se entrambi settano contemporaneamente |
| 4 (while loop) | Rinuncia in caso di contesa | Starvation per interleaving perfetto |
Dekker combina il primo e il quarto tentativo: introduce il turn per risolvere la contesa, ma lo usa solo quando c'e effettiva competizione. Quando l'altro processo non vuole entrare, si procede senza attendere il turno.
L'algoritmo e corretto: soddisfa mutua esclusione, assenza di deadlock e assenza di starvation su architetture che forniscono solo load e store come istruzioni atomiche.
Peterson propone una soluzione piu concisa, unendo due 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
La condizione await (!wantq or turn = 1) dice: "se Q non vuole entrare, passa; se Q vuole entrare, aspetta il tuo turno". La variabile turn e l'arbitro che decide in caso di contesa simultanea.
Se la macchina concorrente fornisce istruzioni atomiche piu potenti del semplice load/store, il problema della sezione critica si semplifica enormemente. Le principali sono:
| Istruzione | Descrizione |
|---|---|
| test-and-set (x, r) | <r := x; x := 1> — atomico |
| exchange (x, r) | Scambia il valore di x con r in modo atomico |
| fetch-and-add (x, v) | <tmp := x; x := x + v; return tmp> — atomico |
| compare-and-swap (x, e, v) | <if x == e then x := v; return true else return false> |
Con test-and-set possiamo realizzare un lock semplice:
integer lock := 0
integer was_locked
loop forever
NCS
repeat
test_and_set(lock, was_locked)
until was_locked = 0
CS
lock := 0
La test_and_set e atomica e fa due cose contemporaneamente: copia il vecchio valore di lock in was_locked e setta lock a 1. Se was_locked risultava 0, significa che il lock era libero e il processo puo entrare. Se era 1, qualcun altro ha gia preso il lock e bisogna riprovare (busy-wait).
Questa soluzione funziona con N processi, non solo 2.
La caratteristica fondamentale di test_and_set e che modifica una variabile condivisa e ne restituisce il valore precedente in un unico passo atomico. Non e possibile interleaving tra la lettura e la scrittura.
synchronizedIl costrutto synchronized in Java implementa sezioni atomiche usando un lock condiviso:
// processo (thread) A
synchronized (lock) {
<statement a>
<statement b>
}
// processo (thread) B
synchronized (lock) {
<statement c>
<statement d>
}
Non possono esistere scenari con sequenze come a.c.b.d o c.a.d.b — i blocchi synchronized sullo stesso oggetto lock sono eseguiti in mutua esclusione.
L'algoritmo del fornaio (bakery algorithm) e una soluzione per N processi che introduce il concetto di biglietto numerato, come in una panetteria: ogni cliente (processo) prende un numero e viene servito in ordine crescente.
int num := 1
int next := 1
turn[1:n] := [0, 0, 0, ...]
p[i]:
loop forever
NCS
<turn[i] := num; num := num + 1> { atomico }
await turn[i] = next
CS
next := next + 1
Problema: overflow aritmetico. I contatori num e next crescono monotonamente e con interi a dimensione finita possono arrivare a overflow, causando il reset a 0 e la violazione della proprieta di ordinamento.
Il bakeri algorithm e un esempio importante perche mostra come risolvere la sezione critica per un numero arbitrario di processi, ma evidenzia anche i limiti pratici degli interi finiti.
Il test cerca la presenza di bug eseguendo il programma su scenari specifici e osservando i risultati. Riguarda alcuni scenari selezionati. Rivela la presenza di errori, non la loro assenza.
Nel contesto concorrente, il test e particolarmente problematico per il fenomeno degli heisenbug: il codice di test puo introdurre artefatti di temporizzazione che mascherano i bug.
La verifica chiede: "abbiamo costruito il sistema correttamente?" (have we built the system right?). Deve stabilire se una proprieta di correttezza vale in tutti i possibili scenari di esecuzione.
Serve una tecnica formale: o la generazione esaustiva degli stati (model checking) o la dimostrazione induttiva (theorem proving).
La validazione chiede: "abbiamo costruito il sistema giusto?" (have we built the right system?). Verifica che il sistema soddisfi le aspettative dello stakeholder, partendo dai requisiti. E un'attivita diversa dalla verifica: un sistema puo essere verificato come corretto ma non validato perche non fa cio che ci si aspetta.
Fault, error, failure: un fault e la manifestazione di un errore (un'azione umana che produce un risultato incorretto). Un failure si verifica quando un fault viene incontrato durante l'esecuzione. Un failure puo essere causato da molti fault, e un fault puo causare molti failure.
Per esprimere proprieta di correttezza in modo rigoroso servono linguaggi formali. La LTL (Linear Temporal Logic), introdotta da Amir Pnueli (1977), estende la logica proposizionale con operatori temporali che permettono di ragionare sull'evoluzione degli stati nel tempo.
Le proposizioni atomiche riguardano il valore delle variabili e dei control pointer. Ad esempio, l'etichetta p3 diventa una proposizione che significa "il control pointer del processo P e a p3".
| Operatore | Notazione | Significato |
|---|---|---|
| Always (box) | []p o G p | p e vera in tutti gli stati da ora in poi |
| Eventually (diamond) | <>p o F p | p diventa vera prima o poi in futuro |
| Next | O p o X p | p e vera nello stato immediatamente successivo |
| Until | p U q | q diventa vera prima o poi, e p e vera fino a quel momento |
| Weak Until | p W q | Come U, ma q non e obbligata a diventare vera; se non accade, p resta vera per sempre |
Mutua esclusione per la sezione critica: lo stato "cattivo" e quando entrambi i processi sono in CS. Usiamo l'operatore [] per dire che non deve mai accadere:
[]! (p3 ∧ q3)
In generale: []p dove p = !q e q descrive lo stato "cattivo".
Progress property (assenza di starvation): se un processo e in attesa (stato p2), deve prima o poi entrare in CS (p3):
[](p2 → <>p3)
La formula dice: "per ogni stato, se il processo P e in p2, allora prima o poi (eventualmente) sara in p3".
Attenzione all'implicazione: P → Q equivale a !P ∨ Q. Se la premessa e falsa (P non e in p2), l'implicazione e vera a prescindere da Q. E la formula cattura correttamente che non ci interessa la starvation se P non ha mai richiesto di entrare.
[]p → p e p → <>p![]p → <>!p e !<>p → []!p<>[]p (eventualmente sempre vero) vs []<>p (sempre eventualmente vero)Teorema importante: ([]<>p ∧ []<>q) → []<>(p ∧ q) e falso. P e Q possono essere veri in infiniti stati, ma mai contemporaneamente. E un errore comune assumere il contrario.
La k-bounded overtaking cattura l'idea che un processo non possa essere "superato" piu di k volte da un altro processo mentre aspetta di entrare in CS. Usando il weak-until:
tryp → (!CSq) W (CSq W ((!CSq) W CSp))
Questa formula esprime il 1-bounded overtaking: dopo il tentativo di P, Q puo entrare in CS al massimo una volta prima che P entri.
Il model checking e la tecnica piu importante per la verifica automatica di proprieta di correttezza nei sistemi concorrenti. La strategia: esplorare esaustivamente l'intero spazio degli stati del sistema e verificare se certe proprieta (espresse come formule LTL) sono soddisfatte.
Il model checking non si applica solo al software, ma anche all'hardware (Intel lo adotto dopo il Pentium Bug del 1994) e ai sistemi mission-critical (NASA dopo il Mars Polar Lander incident del 1999).
SPIN e il model checker piu importante storicamente, usato sia in ambito accademico che industriale.
PROMELA e il linguaggio di modellazione per SPIN, con costrutti limitati e pensati per descrivere modelli di sistemi concorrenti.
Esempio di PROMELA per Dekker:
/* Dekker in PROMELA — struttura */
byte turn;
bool wantp, wantq;
active proctype P() {
do :: skip -> /* NCS */
wantp = true;
do :: wantq ->
turn = 1;
wantp = false;
(turn == 1);
wantp = true;
:: else -> break od;
/* CS */
wantp = false
od
}
JPF (Java Path Finder) e un model checker specializzato per la verifica di programmi Java, sviluppato dalla NASA.
E una JVM speciale che esegue i programmi lungo tutti i possibili percorsi di esecuzione, verificando la violazione di proprieta come deadlock, eccezioni non catturate, violazioni di bound.
Se trova un errore, riporta l'intera esecuzione che ha portato all'errore.
TLA+ (Temporal Logic of Actions, Leslie Lamport) e un linguaggio formale di specifica basato su matematica discreta (teoria degli insiemi e predicati).
Usato per specificare e verificare sistemi reali complessi — es.: Amazon Web Services lo ha adottato per i suoi sistemi cloud.
Una specifica TLA+ descrive l'insieme di tutti i possibili comportamenti legali (trace) del sistema.
PlusCal (ex +CAL) e un linguaggio algoritmico basato su TLA+: si scrive un algoritmo come in un linguaggio di programmazione, viene tradotto in TLA+, e poi verificato con il model checker TLC.
Il grande problema del model checking e la dimensione dello spazio degli stati. Tecniche allo stato dell'arte includono:
I semafori, introdotti da Dijkstra nel 1968, sono un costrutto semplice ma potente per risolvere problemi di mutua esclusione e sincronizzazione. Funzionano come un semaforo stradale: bloccano e sbloccano l'esecuzione dei processi.
Un semaforo S e un tipo di dato composto con due campi:
S.V: un intero ≥ 0S.L: un insieme di identificatori di processoSi inizializza con S = (k, {}) dove k ≥ 0.
Fornisce due operazioni atomiche:
wait(S) =
<if (S.V > 0)
S.V := S.V - 1
else
S.L := S.L + {p}
p.state := blocked>
Se il valore del semaforo e > 0 (verde), viene decrementato. Se e 0 (rosso), il processo viene bloccato e aggiunto alla coda S.L.
signal(S) =
<if (S.L = {})
S.V := S.V + 1
else
let q := arbitrary element of S.L
S.L := S.L - {q}
q.state := ready>
Se nessun processo e in attesa, il valore viene incrementato. Altrimenti, un processo in attesa viene sbloccato (senza incrementare S.V).
Dato k valore iniziale di S.V:
S.V ≥ 0
S.V = k + #signal(S) - #wait(S)
Dove #wait(S) conta solo i wait completati (i processi che si bloccano non hanno completato il wait).
| Tipo | Valori | Uso tipico |
|---|---|---|
| Mutex (binario) | 0, 1 | Mutua esclusione |
| General (counting) | ≥ 0 | Gestione risorse multiple |
| Event | Inizializzato a 0 | Sincronizzazione (segnale) |
S.L e una coda FIFO. Garanzia di assenza di starvation per qualsiasi N.S.L e un insieme (selezione arbitraria). Starvation possibile.S.L, il processo continua a testare in busy-wait. Non c'e garanzia di starvation freedom.binary semaphore S := (1, {})
p: q:
loop forever loop forever
NCS NCS
wait(S) wait(S)
CS CS
signal(S) signal(S)
E la soluzione e corretta: mutua esclusione, assenza di deadlock, assenza di starvation (con strong semaphore).
Il diagramma di stato include stati bloccati (p1^B e q1^B) e lo stato del semaforo nella tupla {S.V, S.L}.
Con N processi e weak semaphore, la soluzione con semaforo binario non garantisce piu assenza di starvation. I processi potrebbero essere sempre scavalcati nella selezione da S.L.
Il problema del Produttore-Consumatore (Producer-Consumer) e un esempio classico di sincronizzazione. Due tipi di processi:
Pattern ubiquitari: browser + communication line, tastiera + OS, word processor + stampante.
Con buffer infinito, l'unica sincronizzazione necessaria e: il consumatore non deve prelevare da un buffer vuoto.
UnboundedQueue<Item> buffer := empty queue
semaphore availItems := (0, {})
producer: consumer:
loop forever loop forever
Item el := produce wait(availItems)
append(buffer, el) Item el := take(buffer)
signal(availItems) consume(el)
availItems e un resource semaphore, e vale sempre il numero di elementi nel buffer.
Con buffer limitato, serve sincronizzare anche il produttore: non deve scrivere se il buffer e pieno.
BoundedQueue<Item> buffer := empty queue
semaphore availItems := (0, {})
semaphore availPlaces := (N, {})
producer: consumer:
loop forever loop forever
Item el := produce wait(availItems)
wait(availPlaces) Item el := take(buffer)
append(buffer, el) signal(availPlaces)
signal(availItems) consume(el)
availItems + availPlaces = N (split semaphores).
Se le operazioni append e take non sono atomiche, serve un semaforo mutex aggiuntivo:
BoundedQueue<Item> buffer
semaphore availItems := (0, {})
semaphore availPlaces := (N, {})
binary semaphore mutex := (1, {})
producer: consumer:
loop forever loop forever
Item el := produce wait(availItems)
wait(availPlaces) wait(mutex)
wait(mutex) Item el := take(buffer)
append(buffer, el) signal(mutex)
signal(mutex) signal(availPlaces)
signal(availItems) consume(el)
Attenzione all'ordine: il wait sul semaforo di sincronizzazione (availPlaces / availItems) viene prima del wait sul mutex. Invertire l'ordine puo portare a deadlock.
Un deadlock puo verificarsi solo se tutte e quattro le seguenti condizioni valgono simultaneamente (Coffman, 1971):
Il caso piu semplice: thread A tiene il lock L e cerca di acquisire M, mentre thread B tiene M e cerca di acquisire L. Entrambi aspettano per sempre (deadly embrace).
In Java non esiste un meccanismo automatico di rilevazione/recovery dai deadlock. Se i thread si bloccano, "that's all, folks!" — l'unica possibilita e arrestare l'applicazione e analizzare post-mortem.
Un classico problema di sincronizzazione introdotto da Dijkstra (1971) e reso celebre da Hoare. Cinque filosofi siedono a un tavolo con 5 piatti e 5 forchette. Ogni filosofo alterna pensiero e mangiare; per mangiare servono due forchette (quella a sinistra e quella a destra).
Primo tentativo: ogni forchetta e modellata come un semaforo binario. Il filosofo prende prima la sinistra, poi la destra.
semaphore array[0..4] fork := [1, 1, 1, 1, 1]
philosopher i:
loop forever
think
wait(fork[i]) { forchetta sinistra }
wait(fork[(i+1) % 5]) { forchetta destra }
eat
signal(fork[i])
signal(fork[(i+1) % 5])
Problema: deadlock. Se tutti i filosofi prendono la forchetta sinistra contemporaneamente, nessuno puo prendere la destra. E un classico circular wait.
Limitare il numero di filosofi che possono mangiare simultaneamente a N-1 (4 su 5), introducendo un semaforo ticket inizializzato a 4:
semaphore ticket := (4, {})
loop forever
think
wait(ticket)
wait(fork[i])
wait(fork[(i+1) % N])
eat
signal(fork[i])
signal(fork[(i+1) % N])
signal(ticket)
Questa soluzione soddisfa tutte le proprieta.
Rimuovere la condizione di circular wait facendo prendere le forchette in un ordine totale (es., prima la forchetta con indice minore, poi quella con indice maggiore):
integer first = min(i, (i+1) % N)
integer second = max(i, (i+1) % N)
loop forever
think
wait(fork[first])
wait(fork[second])
eat
signal(fork[first])
signal(fork[second])
L'ultimo filosofo (indice 4) prendera prima la forchetta 0 e poi la 4, invertendo l'ordine rispetto agli altri — il wait-for chain si spezza.
Per evitare deadlock con lock multipli: assegnare un ordine totale ai lock e acquisirli sempre nello stesso ordine. Questo rende impossibile il circular wait.
Il problema dei Lettori-Scrittori (Courtois, Heymans, Parnas, 1971) e simile al problema della sezione critica, ma divide i processi in due classi:
E un'astrazione dell'accesso a database: non c'e pericolo in letture concorrenti, ma la scrittura richiede mutua esclusione totale.
Invarianti:
nR ≥ 0 ∧ nW = 0 ∨ nW = 1
(nR > 0 → nW = 0) ∧ (nW = 1 → nR = 0)
binary semaphore mutexR := (1, {})
binary semaphore rw := (1, {})
int nr := 0
reader: writer:
loop forever loop forever
wait(mutexR) wait(rw)
if (nr == 0) Item el := create_record
wait(rw) write(dbase, el)
nr := nr + 1 signal(rw)
signal(mutexR)
Item el := read(dbase)
wait(mutexR)
nr := nr - 1
if (nr == 0)
signal(rw)
signal(mutexR)
Come funziona: mutexR protegge l'aggiornamento del contatore nr. Il primo lettore acquisisce rw (bloccando gli scrittori). Gli altri lettori incrementano solo nr. L'ultimo lettore rilascia rw. Gli scrittori usano rw direttamente per la mutua esclusione totale.
Una soluzione piu semplice ma over-constrained userebbe un singolo semaforo per serializzare tutto, impedendo anche la lettura concorrente — sacrificando le prestazioni.
I semafori sono potenti ma di basso livello: portano a programmi error-prone. I monitor (Brinch Hansen, 1973; Hoare, 1974) offrono un'astrazione piu alta. Un monitor e un'astrazione dati che incapsula:
Per la sincronizzazione esplicita, i monitor forniscono le variabili condizione con due operazioni:
| Operazione | Effetto |
|---|---|
waitC(cond) | Sospende il processo, rilascia il lock del monitor, lo aggiunge alla coda della condizione |
signalC(cond) | Se c'e un processo in attesa sulla condizione, ne sblocca uno (dalla testa della coda FIFO) |
emptyC(cond) | Controlla se la coda della condizione e vuota |
signalAllC(cond) | Sblocca tutti i processi in attesa sulla condizione |
| Disciplina | Ordine di precedenza | Comportamento |
|---|---|---|
| Signal & Continue | E < W < S | Il segnalante continua; il risvegliato compete con gli altri |
| Signal & Wait | E = S < W | Il risvegliato esegue subito; il segnalante aspetta |
| Signal & Urgent Wait | E < S < W | Il risvegliato esegue subito; il segnalante ha priorita sugli altri in attesa del lock |
Legenda: S = segnalante, W = processi in waitC, E = processi bloccati sull'entry del monitor.
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
}
}
| Caratteristica | Semaforo | Variabile condizione (monitor) |
|---|---|---|
wait | Potrebbe non bloccare (se S.V > 0) | Blocca sempre |
signal | Ha sempre effetto (incrementa o sblocca) | Nessun effetto se la coda e vuota |
| Processo sbloccato | Arbitrario (o FIFO) | Testa della coda FIFO |
| Ripresa | Immediata | Dipende dalla disciplina di segnalazione |
monitor Semaphore {
integer s := <InitValue>
cond notZero
procedure wait() {
if s = 0
waitC(notZero)
s := s - 1
}
procedure signal() {
s := s + 1
signalC(notZero)
}
}
I monitor possono essere implementati con semafori: un semaforo mutex per la mutua esclusione, e per ogni variabile condizione un semaforo condsem e un contatore condcount.
Il test cerca la presenza di bug eseguendo il programma su scenari selezionati, rivela la presenza di errori ma non la loro assenza. La verifica (verification) vuole stabilire se una proprieta di correttezza vale in tutti i possibili scenari di esecuzione, usando tecniche formali come il model checking o la dimostrazione induttiva di invarianti.
Un processo soffre di starvation quando, pur cercando di entrare nella sua sezione critica (esegue il pre-protocol), non riesce mai ad accedervi perche altri processi continuano a entrare prima di lui. Nel primo tentativo (con turn), la starvation si verifica quando il processo che detiene il permesso (turn) si ferma nella NCS, impedendo all'altro di entrare per sempre.
1. Mutua esclusione (una risorsa non puo essere usata da piu processi contemporaneamente); 2. Hold and wait (un processo che gia detiene risorse puo richiederne altre); 3. No preemption (le risorse non possono essere rimosse forzatamente); 4. Circular wait (due o piu processi formano una catena circolare di attesa). Tutte e quattro devono valere simultaneamente perche si verifichi deadlock.
Con la formula []!(p3 ∧ q3) (o equivalentemente G!(p3 ∧ q3)): "sempre non (p3 e q3 contemporaneamente)". Dove p3 e q3 sono proposizioni atomiche che indicano che il control pointer del processo P o Q si trova nella sezione critica.
Tre differenze fondamentali: (1) wait su semaforo puo non bloccare (se S.V > 0), waitC blocca sempre; (2) signal su semaforo ha sempre effetto (incrementa o sblocca), signalC non fa nulla se la coda e vuota; (3) signal sblocca un processo arbitrario, signalC sblocca dalla testa della coda FIFO; (4) un processo sbloccato da signal riprende immediatamente, mentre con signalC deve attendere che il segnalante esca dal monitor (dipende dalla disciplina).
Esprime che, dal momento in cui un processo P tenta di entrare nella sezione critica, un altro processo Q puo entrare in CS al massimo k volte prima che P entri a sua volta. E una proprieta piu forte della semplice assenza di starvation, che garantisce non solo che P prima o poi entri, ma che lo faccia entro un numero limitato di "sorpassi".
Il problema principale e la state-space explosion: il numero di stati cresce esponenzialmente con il numero di processi e di variabili. Viene affrontato con tecniche come: (a) riduzione del modello eliminando dettagli irrilevanti; (b) costruzione incrementale del grafo fermandosi al primo stato falsificante; (c) model checking simbolico, che lavora con insiemi di stati descritti intensivamente (BDD) invece che singoli stati.
Peterson combina l'uso di variabili di intenzione (wantp, wantq) con una variabile turn che funge da arbitro in caso di contesa simultanea. La condizione composta await (!wantq or turn = 1) garantisce: (1) se Q non vuole entrare, P passa direttamente; (2) se entrambi vogliono entrare, turn decide chi passa. L'ultimo che setta turn "perde" e deve aspettare, prevenendo sia la violazione di mutua esclusione che la starvation.