I semafori sono il piu classico dei meccanismi di coordinazione tra processi. Introdotti da Edsger Dijkstra nel 1968, rappresentano un costrutto di programmazione generale che permette di risolvere virtualmente ogni problema di mutua esclusione e sincronizzazione. Il nome deriva dall'analogia con i semafori stradali: bloccano e sbloccano l'esecuzione dei processi (il "traffico") a seconda delle necessita.
Un semaforo S e un tipo di dato composito con due campi:
Può essere inizializzato con un valore k >= 0 per S.V e l'insieme vuoto per S.L. Fornisce due operazioni atomiche fondamentali: wait(S) (detta anche P(S)) e signal(S) (detta anche V(S)).
L'atomicita delle operazioni wait e signal e il pilastro su cui si regge l'intero meccanismo. Senza atomicita, due processi potrebbero interferire durante il test e la modifica del valore del semaforo, rompendo ogni garanzia.
Se il valore S.V e maggiore di 0 (semaforo "verde"), viene semplicemente decrementato. Altrimenti, se S.V = 0 (semaforo "rosso"), il processo chiamante viene bloccato e aggiunto a S.L.
wait(S) =
if (S.V > 0)
S.V = S.V - 1
else
S.L = S.L + {p}
p.state = blocked
Se nessun processo e in attesa, il valore del semaforo viene incrementato. Altrimenti, viene selezionato un processo arbitrario dalla coda S.L e sbloccato.
signal(S) =
if (S.L = {})
S.V = S.V + 1
else
q = arbitrary_element(S.L)
S.L = S.L - {q}
q.state = ready
Ricordate la differenza fondamentale: wait blocca il processo se il semaforo e a zero e lo accoda; signal o incrementa il contatore (se nessuno e in attesa) oppure sblocca un processo dalla coda (senza incrementare). Questa asimmetria e cruciale per capire il comportamento dei semafori come meccanismo di sincronizzazione.
Il componente intero può assumere solo due valori: 0 e 1. Tipicamente usato per implementare la mutua esclusione, come un lucchetto (lock). Inizializzato a 1, protegge l'accesso a una sezione critica: wait acquisisce, signal rilascia.
Il componente intero può assumere qualsiasi valore >= 0. Usato per gestire risorse multiple identiche (es. buffer con N slot). Il valore iniziale rappresenta il numero di risorse disponibili. E anche detto resource semaphore.
Inizializzato a 0, usato esclusivamente per sincronizzazione tra processi (non per mutua esclusione). Serve a inviare/ricevere un segnale temporale: un processo si blocca in wait finche un altro non fa signal per segnalare che un evento e accaduto.
Sia k il valore iniziale, #signal(S) il numero di signal eseguiti, e #wait(S) il numero di wait completati (un processo bloccato in wait non conta come eseguito). L'invariante fondamentale che ogni semaforo soddisfa e:
S.V = k + #signal(S) - #wait(S)
Da cui deriva immediatamente S.V >= 0. Questo teorema e alla base della verifica di correttezza dei programmi che usano semafori.
Nei semafori forti, S.L non e un insieme ma una coda FIFO: i processi vengono sbloccati nell'ordine in cui si sono bloccati, garantendo l'assenza di starvation. Nei semafori deboli, S.L e un insieme: la starvation e possibile. Esistono anche busy-wait semaphores, senza S.L, in cui il processo attende in un ciclo di busy waiting fino a quando il valore non diventa positivo; appropriati solo in sistemi multiprocessore con bassa contesa.
Usando un semaforo come lucchetto, la soluzione del problema della sezione critica per due processi diventa banale. Un semaforo binario S inizializzato a 1 protegge l'accesso: ogni processo esegue wait(S) prima di entrare nella sezione critica e signal(S) dopo averla lasciata.
P: Q:
loop forever loop forever
NCS NCS
wait(S) wait(S)
CS CS
signal(S) signal(S)
Il diagramma di stato ridotto (che elimina le sezioni non critiche e critiche, mantenendo solo i protocolli di ingresso/uscita) evidenzia le proprieta di correttezza. Con un semaforo binario, la mutua esclusione e garantita: non esiste uno stato in cui entrambi i processi siano contemporaneamente in CS.
La dimostrazione formale si basa sulla costruzione del grafo degli stati ridotto: lo stato del semaforo fa parte del tuple di stato. Si parte da (p1, q1, S.V=1, S.L={}) e si esplorano tutte le transizioni possibili. La mutua esclusione e verificata se nessuno stato raggiungibile contiene entrambi i processi in CS.
Usate i pulsanti per avanzare i processi P e Q. Il semaforo S protegge l'ingresso nella sezione critica. Verificate che non sia mai possibile avere entrambi i processi in CS contemporaneamente.
La soluzione vale anche per N processi, ma attenzione: con semafori deboli la proprieta di assenza di starvation non e garantita. Un processo potrebbe essere continuamente scavalcato nella competizione per il lock.
Il problema produttore-consumatore e un classico esempio di problema di ordinamento dell'esecuzione. Due tipi di processi cooperano: i produttori creano dati, i consumatori li elaborano. E uno dei pattern piu ubiquitari in informatica (browser-server, sistema operativo-stampante, input-output).
Con un buffer infinito, l'unica sincronizzazione necessaria e che il consumatore non provi a prelevare da un buffer vuoto. Si usa un semaforo availItems inizializzato a 0, che funge da resource semaphore contando gli elementi disponibili.
Produttore: Consumatore:
loop forever loop forever
Item el = produce wait(availItems)
append(buffer, el) Item el = take(buffer)
signal(availItems) consume(el)
Con un buffer di dimensione N, serve anche impedire al produttore di inserire in un buffer pieno. Si introducono due semafori "split": availItems (elementi disponibili) e availPlaces (posti disponibili), inizializzato a N. L'invariante e: availItems + availPlaces = N.
Produttore: Consumatore:
loop forever loop forever
Item el = produce wait(availItems)
wait(availPlaces) Item el = take(buffer)
append(buffer, el) signal(availPlaces)
signal(availItems) consume(el)
Se le operazioni sul buffer (append/take) non sono atomiche, serve un semaforo mutex aggiuntivo per proteggere l'accesso alla struttura dati condivisa. L'ordine e importante: prima si attende il semaforo di sincronizzazione (availPlaces/availItems), poi quello di mutua esclusione (mutex).
Produttore: Consumatore:
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)
Problema classico della programmazione concorrente, introdotto da Dijkstra nel 1971 (originariamente come problema di sincronizzazione per 5 computer in competizione per 5 unita a nastro) e reso celebre da Tony Hoare nella versione dei filosofi a cena.
Descrizione: Cinque filosofi trascorrono la vita alternando due attivita: pensare e mangiare. I pasti sono consumati a un tavolo con 5 piatti e 5 forchette. Al centro c'e una scodiglia di spaghetti sempre piena. Ogni filosofo ha bisogno di due forchette per mangiare — quella a sinistra e quella a destra — ma può raccoglierle solo una alla volta.
L'obiettivo e progettare protocolli pre- e post- che garantiscano: mutua esclusione sulle forchette, assenza di deadlock, assenza di starvation, ed efficienza in assenza di contesa.
Il primo tentativo — modellare ogni forchetta come semaforo binario e far prendere a ogni filosofo prima la sinistra poi la destra — porta a deadlock. Se tutti i filosofi prendono la forchetta sinistra contemporaneamente, nessuno potra mai prendere quella destra. E l'esempio didattico perfetto di circular wait.
// PRIMO TENTATIVO (deadlock!)
semaphore fork[0..4] = [1,1,1,1,1]
Philosopher(i):
loop forever
think
wait(fork[i]) // sinistra
wait(fork[(i+1) % N]) // destra
eat
signal(fork[i])
signal(fork[(i+1) % N])
Un deadlock e una situazione in cui due o piu processi sono in attesa che gli altri terminino, e nessuno può proseguire. Le condizioni necessarie di Coffman (1971) per il verificarsi di un deadlock sono quattro, e devono valere tutte simultaneamente:
In Java non esiste un meccanismo automatico di rilevamento e recupero deadlock: se i thread si bloccano, l'unica opzione e terminare l'applicazione. I database, invece, adottano tecniche di deadlock detection and recovery: analizzano il grafo delle dipendenze in attesa e, se trovano un ciclo, selezionano una "vittima" e abortiscono la transazione.
Per garantire liveness, si limita il numero di filosofi che possono mangiare contemporaneamente: si introduce un semaforo ticket inizializzato a N-1. Solo N-1 filosofi su N possono competere per le forchette, rompendo la circolarita. Questa soluzione soddisfa tutte le proprieta.
semaphore fork[0..4] = [1,1,1,1,1]
semaphore ticket = (4, {})
Philosopher(i):
loop forever
think
wait(ticket) // entra nella sala
wait(fork[i])
wait(fork[(i+1) % N])
eat
signal(fork[i])
signal(fork[(i+1) % N])
signal(ticket) // esce dalla sala
Si osserva che il deadlock non si verifica se l'ultimo filosofo prende prima la forchetta destra e poi la sinistra. In generale, la regola e: acquisire sempre i lock in un ordine totale prestabilito. Questo rende impossibile la condizione di circular wait.
semaphore fork[0..4] = [1,1,1,1,1]
int first = min(i, (i+1) % N)
int second = max(i, (i+1) % N)
Philosopher(i):
loop forever
think
wait(fork[first])
wait(fork[second])
eat
signal(fork[first])
signal(fork[second])
Per N processi che acquisiscono piu lock: (1) assegnare un ordinamento totale ai lock; (2) acquisire sempre i lock nello stesso ordine. Questa strategia e sufficiente a eliminare la possibilita di circular wait.
Problema formulato da Courtois, Heymans e Parnas nel 1971. I processi si dividono in due classi: lettori (possono leggere contemporaneamente senza escludersi) e scrittori (devono escludere sia altri scrittori che i lettori). E l'astrazione dell'accesso concorrente a database o risorse condivise.
Gli invarianti da rispettare:
nR >= 0
nW = 0 oppure nW = 1
(nR > 0 => nW = 0) AND (nW = 1 => nR = 0)
I lettori usano un lock mutex per aggiornare il contatore nR. Il primo lettore acquisisce il lock rw (escludendo scrittori); gli altri lettori incrementano solo il contatore. L'ultimo lettore rilascia rw.
Lettore: Scrittore:
wait(mutexR) wait(rw)
if (nR == 0) write(dbase)
wait(rw) signal(rw)
nR++
signal(mutexR)
read(dbase)
wait(mutexR)
nR--
if (nR == 0)
signal(rw)
signal(mutexR)
Notate la sottigliezza: se si usasse un unico semaforo per serializzare tutto (una soluzione over-constraining), si perderebbe il vantaggio della lettura concorrente. Il vero problema dei lettori-scrittori e bilanciare il throughput dei lettori con l'equita verso gli scrittori.
I semafori sono potenti ma di basso livello: programmi soggetti a errori, difficili da usare in programmi concorrenti complessi. I monitor sono l'evoluzione naturale: un'astrazione di dati che incapsula stato, operazioni e politiche di sincronizzazione/mutua esclusione. Introdotti da Brinch Hansen (1973) e generalizzati da Hoare (1974).
Definizione: Un monitor e un modulo che incapsula l'accesso concorrente a uno stato condiviso, fornendo:
Un monitor e una generalizzazione del concetto di kernel in un sistema operativo (dove il kernel centralizza le sezioni critiche) e una generalizzazione del concetto di oggetto in OOP (classi che incapsulano dati + operazioni + politica di sincronizzazione).
monitor Counter {
int count;
procedure inc() { count = count + 1; }
procedure getValue() : int { return count; }
}
L'esempio del contatore mostra la potenza dell'astrazione: non c'e alcun wait/signal esplicito per la mutua esclusione. Il monitor garantisce automaticamente che due processi non eseguano mai inc() contemporaneamente.
Le variabili di condizione sono tipi primitivi che permettono di sospendere (waitC) e risvegliare (signalC) processi all'interno di un monitor. Ogni variabile di condizione e associata a una coda FIFO di processi bloccati.
waitC(cond) = signalC(cond) =
append p to cond.queue if cond.queue != empty
p.state = blocked q = remove head of cond.queue
monitor.lock = release q.state = ready
waitC rilascia il lock del monitor quando sospende il processo (essenziale per non bloccare l'intero monitor). signalC non ha effetto se la coda e vuota — a differenza di signal su semaforo, che invece incrementa sempre il contatore.
Il segnalante continua l'esecuzione; il processo risvegliato dovrà riconquistare il lock (non-preemptive). Priorita: E < W < S — i processi in attesa sul lock hanno priorita minore dei risvegliati, che hanno priorita minore del segnalante.
Il processo risvegliato parte immediatamente; il segnalante si mette in attesa (preemptive). Priorita: E = S < W — i risvegliati hanno priorita massima.
Come S&W, ma il segnalante ha priorita sui processi in attesa del lock (E < S < W). E la semantica classica per i monitor (Hoare). Java adotta una variante di S&C con E = W < S.
La differenza tra S&C e S&W ha implicazioni pratiche. Con S&C (come in Java), dopo signal il thread risvegliato deve competere per il lock con chiunque altro sia in attesa. Con S&W, il risveglio e immediato. Questo influenza come si scrivono i cicli di controllo delle condizioni.
Java offre due approcci per implementare monitor. Il primo, di piu basso livello, sfrutta i meccanismi nativi della JVM: synchronized, wait, notify, notifyAll.
Quando un metodo e dichiarato synchronized, la JVM genera automaticamente le istruzioni bytecode monitorenter e monitorexit. Ogni oggetto Java ha un intrinsic lock (o monitor lock) associato.
Un errore frequente: chiamare wait() o notify() fuori da un blocco synchronized causa IllegalMonitorStateException. Il thread chiamante deve essere il proprietario del lock sull'oggetto. Il professore ha mostrato questo errore in aula con un esempio dal vivo.
Regole per implementare il monitor pattern:
synchronizedwait lancia InterruptedExceptionnotify rimuove un thread arbitrario dal wait set (nessuna garanzia su quale)notifyAll opera su tutti i thread nel wait set (ma riacquisiranno il lock uno alla volta)La documentazione Java avverte: "A thread can also wake up without being notified, interrupted, or timing out, a so-called spurious wakeup." Per questo il pattern corretto e sempre wrappare wait in un ciclo while che riverifica la condizione:
synchronized void get() {
while (!available) { // SEMPRE while, mai if!
try { wait(); } catch (InterruptedException ex) {}
}
return value;
}
Un'altra limitazione del meccanismo di base: piu predicati di condizione diversi devono essere associati alla stessa unica variabile di condizione (l'oggetto). Questo forza l'uso di notifyAll invece di notify, e il ciclo while permette a ogni thread di riverificare la propria condizione specifica al risveglio.
Implementiamo il bounded buffer produttori-consumatori usando il monitor pattern in Java, con synchronized/wait/notifyAll.
public class BoundedBuffer<Item> {
private int first, last, count;
private Item[] buffer;
public BoundedBuffer(int size) {
first = last = count = 0;
buffer = (Item[]) new Object[size];
}
public synchronized void put(Item item) throws InterruptedException {
while (isFull()) { wait(); }
last = (last + 1) % buffer.length;
count++;
buffer[last] = item;
notifyAll();
}
public synchronized Item get() throws InterruptedException {
while (isEmpty()) { wait(); }
first = (first + 1) % buffer.length;
count--;
notifyAll();
return buffer[first];
}
public synchronized boolean isEmpty() { return count == 0; }
public synchronized boolean isFull() { return count == buffer.length; }
}
Domanda del professore: e davvero necessario usare notifyAll invece di notify? Se ci sono sia produttori che consumatori in attesa sulla stessa variabile di condizione, notify potrebbe svegliare un thread "sbagliato" (es. un produttore invece di un consumatore), che si rimetterebbe in attesa. notifyAll sveglia tutti, introducendo un overhead ma garantendo il progresso. Con ReentrantLock e Condition separate, questo problema si risolve elegantemente.
La libreria java.util.concurrent fornisce classi per implementare monitor con variabili di condizione multiple, superando le limitazioni del meccanismo synchronized.
La classe ReentrantLock e un'implementazione rientrante di lock mutex. Da un lock si possono creare multiple Condition, che rappresentano variabili di condizione distinte.
public class SynchCell2 {
private int value;
private boolean available;
private Lock mutex;
private Condition isAvail;
public SynchCell2() {
available = false;
mutex = new ReentrantLock();
isAvail = mutex.newCondition();
}
public void set(int v) {
try {
mutex.lock();
value = v;
available = true;
isAvail.signalAll();
} finally {
mutex.unlock();
}
}
public int get() {
try {
mutex.lock();
if (!available) {
try { isAvail.await(); } catch (InterruptedException ex) {}
}
return value;
} finally {
mutex.unlock();
}
}
}
Note importanti: i metodi non sono synchronized; il lock e gestito esplicitamente con lock()/unlock(); e essenziale il blocco finally per garantire il rilascio anche in caso di eccezioni; Condition offre await(), signal(), signalAll() al posto di wait/notify/notifyAll.
Con ReentrantLock possiamo finalmente avere notFull e notEmpty come variabili di condizione separate, permettendo segnalazioni selettive:
Notate: nel put si fa notEmpty.signal() (sveglia solo consumatori), nel get si fa notFull.signal() (sveglia solo produttori). Nessun overhead di risvegli inutili!
Il professore ha dedicato una parte significativa della lezione a mostrare un comportamento curioso della semantica di segnalazione in Java. La semantica adottata (Signal-and-Continue con E = W < S) può portare a ordini di esecuzione controintuitivi.
Scenario dimostrativo: Tre thread competono per un monitor. T1 entra, esegue await() e si sospende. T2 entra, esegue signal() e rilascia. T3 sta aspettando di entrare. Chi ottiene il lock dopo T2?
Con E = W < S, il thread risvegliato (T1) compete ad armi pari con i thread in attesa sul lock (T3). Dipende dall'implementazione della JVM e dallo scheduler del sistema operativo chi entra per primo. Nell'esempio mostrato, T3 e entrato prima di T1.
OUTPUT OSSERVATO:
First thread started.
First thread inside, going to wait
Second thread started.
Second thread inside
Third thread started.
Second thread inside, going to signal
Second thread inside signaled.
Third thread inside. // <- T3 entra prima di T1!
First thread unblocked. // <- T1 solo dopo T3
Questo comportamento non e un bug di Java, ma una conseguenza della semantica S&C. Non bisogna mai assumere che dopo un signal il thread risvegliato sia il prossimo a eseguire. E per questo che tutte le attese devono essere in ciclo while (non if) e la logica deve essere robusta indipendentemente dall'ordine di ripresa.
Errore comune: Confondere wait/notify (meccanismi di base su Object) con await/signal (da Condition). Il professore ha mostrato il caso in cui, usando ReentrantLock, si chiama erroneamente wait() invece di await() o notify invece di signal, cosa che causa IllegalMonitorStateException o comportamenti errati.
Il professore ha dedicato la parte finale della lezione a mostrare due componenti di coordinazione riutilizzabili implementati come monitor: la barriera e il latch (countdown).
Una barriera e un punto di sincronizzazione in cui N thread si incontrano. Ogni thread chiama await() e rimane bloccato finche tutti gli N partecipanti non sono arrivati. L'ultimo thread che arriva sblocca tutti gli altri.
Implementazione con synchronized:
public class CyclicBarrierMonitor {
private int nParticipants;
private int nArrived;
public CyclicBarrierMonitor(int n) {
nParticipants = n;
nArrived = 0;
}
public synchronized void await() throws InterruptedException {
nArrived++;
while (nArrived < nParticipants) {
wait(); // aspetta che arrivino tutti
}
notifyAll(); // sblocca tutti
}
}
Esercizio del professore: pensate a come specificare la correttezza di una barriera in Java usando le proprieta di safety e liveness. La proprieta di safety e: "nessun thread procede oltre la barriera prima che tutti gli N thread siano arrivati". La liveness e: "se tutti gli N thread sono in attesa, qualcuno procede".
Versione con ReentrantLock + Condition: nella seconda implementazione, si usa una condition variable allArrived con await() e signalAll(). Attenzione: notifyAll chiamato sulla condition variable non equivale a signalAll — un errore subdolo che il professore ha mostrato in aula.
Un latch permette a thread di attendere (await()) finche non viene raggiunto un conteggio zero tramite chiamate countDown(). A differenza della barriera, non e ciclico e il conteggio e monotono decrescente.
public class CountDownLatchMonitor {
private int count;
public CountDownLatchMonitor(int count) { this.count = count; }
public synchronized void await() throws InterruptedException {
while (count > 0) { wait(); }
}
public synchronized void countDown() {
if (count > 0) {
count--;
if (count == 0) { notifyAll(); }
}
}
}
Nella barriera, tutti i thread aspettano tutti gli altri — e simmetrica. Nel latch, un thread (o piu) aspettano che un contatore si azzeri — e asimmetrica. Usi tipici del latch: aspettare che un insieme di servizi sia pronto prima di procedere.
Il secondo modulo della lezione introduce la modellazione dell'esecuzione concorrente. Modellare significa creare una descrizione rigorosa del comportamento del programma al giusto livello di astrazione, includendo le informazioni rilevanti e astraendo da quelle non rilevanti.
Modello per l'esecuzione concorrente:
Questo modello e una astrazione: nella realta, i sistemi non hanno uno stato globale (questione di fisica). Ma l'astrazione e giustificata perche permette l'analisi formale, indipendente da tempi e velocita dei processori.
L'esecuzione di un programma concorrente può essere rappresentata formalmente con stati e transizioni. Lo stato e definito da una tupla che include il control pointer di ogni processo e i valori di tutte le variabili.
Esempio base: due processi che assegnano a una variabile condivisa n:
P: int k1 = 1; Q: int k2 = 2;
n = k1; n = k2;
int n = 0; // globale
Il diagramma di stato ha 5 stati e 2 scenari: (p1, q1) e (q1, p1). Solo 2 esecuzioni possibili. Ma quando i processi hanno piu azioni, il numero di scenari cresce drammaticamente.
Esplosione combinatoria: con 2 processi di m azioni ciascuno, gli scenari sono (2m)!/(m! m!). Con 3 processi di m azioni: (3m)!/(m! m! m!). Per m=3 e 3 processi: 1680 scenari. Per m=8 e 2 processi: 12820.
Il professore ha enfatizzato: la scelta di cosa e atomico e fondamentale. Un'incremento (n = n + 1) e atomico? Se si, uno scenario. Se e scomposto in (tmp = n; n = tmp + 1), ci sono 6 scenari e in 4 di questi il risultato finale non e 2 ma 1. Questa e la radice di tutti i bug di concorrenza.
Interagite con il diagramma di stato del problema dell'incremento non atomico. Cliccate sugli stati per vedere le transizioni possibili.
La correttezza dei programmi concorrenti (potenzialmente non terminanti) e definita in termini di proprieta delle computazioni — condizioni che devono essere verificate in ogni scenario possibile.
"Le cose brutte non devono mai accadere." La proprieta deve essere sempre vera in ogni stato di ogni computazione. Si esprimono come invarianti. Esempi: mutua esclusione (non piu di un processo in CS), assenza di deadlock.
"Le cose buone devono prima o poi accadere." La proprieta deve diventare vera in ogni computazione. Esempi: assenza di starvation (un processo che richiede una risorsa alla fine la ottiene), assenza di dormancy (un processo in attesa viene prima o poi risvegliato), comunicazione affidabile.
Verifica vs Testing:
La fairness (equita) e una proprieta di liveness per cui qualcosa di buono accade infinitamente spesso. Dipende dalla politica di scheduling del sistema.
| Tipo | Definizione |
|---|---|
| Unconditional fairness | Ogni azione incondizionata che e eligible viene prima o poi eseguita |
| Weak fairness | Unconditional fairness + ogni azione condizionale la cui condizione diventa e rimane vera viene eseguita |
| Strong fairness | Unconditional fairness + ogni azione condizionale la cui condizione diventa vera infinitamente spesso viene eseguita |
Esempio: due processi, uno con ciclo while flag = false e uno che setta flag = true. Se la schedulazione non e fair, il primo processo potrebbe ciclare all'infinito senza mai vedere flag = true — uno scenario non fair (non terminante) che e possibile in assenza di garanzie di fairness.
Introdotta da Amir Pnueli (1977), la logica temporale lineare e un formalismo per esprimere proprieta di programmi concorrenti che evolvono nel tempo. Aggiunge operatori temporali alla logica proposizionale.
| Operatore | Notazione | Significato |
|---|---|---|
| Always (box) | p | p e vera in tutti gli stati futuri (safety) |
| Eventually (diamond) | p | p diventa vera in qualche stato futuro (liveness) |
| Next | p | p e vera nello stato immediatamente successivo |
| Until | p U q | q diventa vera, e p e vera finche cio non accade |
| Weak Until | p W q | Come Until ma q può non diventare mai vera |
Mutua esclusione in LTL: (p3 q3) — in ogni stato, non entrambi i processi in CS.
Assenza di starvation: (p2 p3) — sempre, se un processo vuole entrare in CS, prima o poi ci riuscira.
Overtaking limitato (k-bounded): specifica che un processo può essere superato al massimo k volte prima di entrare in CS. Per 1-bounded: tryp ( CSq) W (CSq W (( CSq) W CSp))
Duality: e . Sequence: p significa che da un certo punto in poi p sara sempre vera; p significa che p e vera infinitamente spesso.
Il model checking e la tecnica piu importante per la verifica automatica di proprieta di correttezza di sistemi concorrenti. Strategia: esplorare esaustivamente l'intero spazio degli stati del sistema e verificare se certe proprieta sono soddisfatte.
Se il sistema soddisfa la proprieta, il model checker genera una conferma. Altrimenti, produce un contresempio (trace) — utilissimo per identificare bug, non solo per provare correttezza.
SPIN e un model checker ampiamente usato in ambito accademico e industriale. Promela e il linguaggio di modellazione per SPIN, con costrutti limitati pensati per costruire modelli di sistemi concorrenti. SPIN e stato usato dalla NASA (dopo l'incidente del Mars Polar Lander del 1999) e da Intel (dopo il bug Pentium del 1994).
// Esempio: algoritmo di Dekker in Promela
bool wantp = false, wantq = false;
byte turn = 1;
active proctype P() {
do :: true ->
wantp = true;
do :: !wantq -> break;
:: else ->
if :: turn == 1 -> skip;
:: else -> wantp = false;
(turn == 1);
wantp = true;
fi;
od;
CS: skip; // sezione critica
turn = 2;
wantp = false;
od
}
JPF e un model checker specializzato per la verifica di programmi Java, sviluppato dalla NASA e open-source. E una JVM speciale che esegue il programma lungo tutti i possibili scenari (execution paths), verificando proprieta come assenza di deadlock, eccezioni non catturate, violazioni di invarianti.
Leslie Lamport ha sviluppato TLA+ (Temporal Logic of Actions), un linguaggio di specifica formale usato per verificare sistemi reali complessi (es. Amazon Web Services). PlusCal (ex +CAL) e un linguaggio algoritmico basato su TLA+ che permette di scrivere algoritmi in uno stile simile a un linguaggio di programmazione, tradotti poi in TLA+ per la verifica con il model checker TLC.
Il professore conclude con una riflessione fondamentale: i programmi concorrenti non si possono debuggare nel modo tradizionale. Ogni esecuzione produce probabilmente uno scenario diverso. Un test che passa oggi potrebbe fallire domani sulla stessa macchina, o su una macchina diversa.
La verifica formale (model checking, dimostrazioni induttive) non e un lusso accademico, ma una necessita ingegneristica. Progetti critici (NASA, Amazon, Intel) usano queste tecniche non solo per trovare bug, ma per certificare l'assenza di determinate classi di errori.
Metodi formali principali:
Safety properties sono piu facili da verificare (basta trovare uno stato che viola la proprieta per dimostrare che non vale). Liveness properties sono piu complesse (bisogna analizzare tutti gli scenari possibili, non solo stati isolati). All'esame, distinguiti sempre tra safety e liveness — e una domanda classica.
wait su un semaforo e waitC su una variabile di condizione?wait(S) su semaforo può non bloccare (se S.V > 0 si limita a decrementare). waitC(cond) su variabile di condizione blocca sempre e rilascia il lock del monitor. Inoltre, signal su semaforo ha sempre effetto (incrementa o sblocca), mentre signalC non ha effetto se la coda e vuota.
Mutua esclusione, hold and wait, no preemption, circular wait. Devono valere tutte simultaneamente. Rimuovendone anche solo una, il deadlock e impossibile.
while e non if per il controllo della condizione dopo wait()?Per due ragioni: (1) spurious wakeup — un thread può svegliarsi senza notify/interrupt/timeout; (2) signaling semantics — in Java (S&C) il thread risvegliato deve competere per il lock e potrebbe passare del tempo prima di riottenere l'esecuzione, durante il quale la condizione potrebbe cambiare. Il ciclo while garantisce che la condizione sia riverificata prima di proseguire.
Significa che il valore corrente del semaforo e uguale al valore iniziale piu il numero di signal eseguiti meno il numero di wait completati (quelli che non hanno bloccato). Questo invariante formalizza il comportamento del semaforo ed e la base per dimostrare la correttezza dei programmi che lo usano.
Safety: "le cose brutte non accadono mai" — es. "nessun filosofo mangia con un solo forchetta in mano" (invarianza). Si esprime con . Liveness: "le cose buone prima o poi accadono" — es. "ogni filosofo che ha fame prima o poi mangia" (assenza di starvation). Si esprime con .
Nei semafori weak, S.L e un insieme — quando si fa signal, il processo sbloccato e scelto arbitrariamente. Un processo potrebbe non essere mai selezionato se lo scheduler e sfortunato (o se altri processi continuano a entrare e uscire dalla CS). Nei semafori strong (coda FIFO), l'ordine di arrivo garantisce che ogni processo prima o poi venga servito.