Programmazione Concorrente e Distribuita — Prof. Alessandro Ricci — UNIBO

Modellazione, Sezione Critica, Verifica Formale, Semafori e Monitor

2026-02-27 169 min registrazione originale Moduli 1.2 e 1.3

In questa lezione

1. Modellare l'esecuzione concorrente

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.

Idea chiave

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).

Primo esempio

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:

Il risultato finale dipende dall'ordine con cui le istruzioni vengono interleave: questo e il cuore del non-determinismo nei programmi concorrenti.

2. Diagrammi di stato

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.

Per l'esame

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.)
126
2690
3201680
47034650
5252
6924

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.

Nota del redattore

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.

3. Atomicita e non-atomicita

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.

Incremento atomico vs non-atomico

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.

Attenzione

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.

Strutture dati non-atomiche

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.

4. Correttezza: safety e liveness

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:

  • Mutua esclusione: mai piu di un processo nella sezione critica
  • Assenza di deadlock: nessun processo rimane bloccato in attesa di un evento che non puo verificarsi

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:

  • Assenza di starvation: un processo alla fine ottiene la risorsa che richiede
  • Assenza di dormienza: un processo in attesa viene prima o poi risvegliato
  • Comunicazione affidabile: un messaggio inviato viene prima o poi ricevuto

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.

5. Fairness

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.

6. Il problema della sezione critica

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:

ProprietaDescrizione
Mutua esclusioneLe sezioni critiche di due o piu processi non devono essere interleave
Assenza di deadlockSe qualche processo cerca di entrare in CS, uno di loro deve prima o poi riuscirci
Assenza di starvationSe un processo cerca di entrare in CS, deve prima o poi riuscirci
Bounded waitingEsiste un limite superiore al numero di volte che altri processi possono entrare in CS prima che un processo in attesa ci riesca
Idea chiave

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.

Per l'esame

Ricordate la differenza: la violazione della mutua esclusione si cerca nel diagramma di stato cercando stati <p3,q3,_> (entrambi in CS). La starvation riguarda la possibilita che un processo aspetti per sempre in p2 (pre-protocol) mentre l'altro e in NCS.

7. Primo tentativo — variabile turn

Il 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.

Attenzione

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.

8. Tentativi 2, 3, 4

Variabili want separate

Introduciamo 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.

Await prima del test

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).

Rinuncia all'intenzione

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.

Sintesi dell'evoluzione

TentativoIdeaProblema
1 (turn)Variabile turn che alterna il permessoStarvation se l'altro si ferma in NCS
2 (want)Flag booleani di intenzioneViolazione mutua esclusione (non atomicita del pre-protocol)
3 (want + await)Set prima dell'awaitDeadlock / livelock se entrambi settano contemporaneamente
4 (while loop)Rinuncia in caso di contesaStarvation per interleaving perfetto

9. Algoritmi di Dekker e Peterson

Algoritmo di Dekker (1965)

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.

Algoritmo di Peterson (1981)

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
Idea chiave

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.

10. Istruzioni atomiche composte

Se la macchina concorrente fornisce istruzioni atomiche piu potenti del semplice load/store, il problema della sezione critica si semplifica enormemente. Le principali sono:

IstruzioneDescrizione
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.

Idea chiave

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.

In Java: synchronized

Il 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.

11. Bakery algorithm (ticket)

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.

Attenzione

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.

12. Verifica vs test

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.

13. Logica Temporale Lineare (LTL)

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".

Operatori principali

OperatoreNotazioneSignificato
Always (box)[]p o G pp e vera in tutti gli stati da ora in poi
Eventually (diamond)<>p o F pp diventa vera prima o poi in futuro
NextO p o X pp e vera nello stato immediatamente successivo
Untilp U qq diventa vera prima o poi, e p e vera fino a quel momento
Weak Untilp W qCome U, ma q non e obbligata a diventare vera; se non accade, p resta vera per sempre

Specificare safety con LTL

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".

Specificare liveness con LTL

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.

Proprieta derivate

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.

Bounded overtaking

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.

14. Model checking

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.

Applicazioni

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.

State-space explosion

Il grande problema del model checking e la dimensione dello spazio degli stati. Tecniche allo stato dell'arte includono:

15. Semafori

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.

Definizione

Un semaforo S e un tipo di dato composto con due campi:

Si 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).

Invariante del semaforo

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).

Tipi di semafori

TipoValoriUso tipico
Mutex (binario)0, 1Mutua esclusione
General (counting)≥ 0Gestione risorse multiple
EventInizializzato a 0Sincronizzazione (segnale)

Semafori forti vs deboli

Sezione critica con semaforo (2 processi)

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}.

Attenzione

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.

16. Produttore-Consumatore

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.

Buffer infinito

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.

Buffer limitato

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).

Con mutua esclusione sul buffer

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.

17. Deadlock e Filosofi a cena

Condizioni di Coffman

Un deadlock puo verificarsi solo se tutte e quattro le seguenti condizioni valgono simultaneamente (Coffman, 1971):

  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 — Una risorsa non puo essere rimossa forzatamente dal processo che la detiene
  4. Circular wait — Due o piu processi formano una catena circolare in cui ciascuno attende una risorsa detenuta dal successivo

Deadlock con lock

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.

Il problema dei Filosofi a cena (Dining Philosophers)

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.

Soluzioni

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.

Regola generale

Per evitare deadlock con lock multipli: assegnare un ordine totale ai lock e acquisirli sempre nello stesso ordine. Questo rende impossibile il circular wait.

18. Lettori-Scrittori

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)

Soluzione con semafori

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.

Nota del redattore

Una soluzione piu semplice ma over-constrained userebbe un singolo semaforo per serializzare tutto, impedendo anche la lettura concorrente — sacrificando le prestazioni.

19. Monitor

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:

Proprieta fondamentali

Variabili condizione

Per la sincronizzazione esplicita, i monitor forniscono le variabili condizione con due operazioni:

OperazioneEffetto
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

Discipline di segnalazione

DisciplinaOrdine di precedenzaComportamento
Signal & ContinueE < W < SIl segnalante continua; il risvegliato compete con gli altri
Signal & WaitE = S < WIl risvegliato esegue subito; il segnalante aspetta
Signal & Urgent WaitE < S < WIl 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.

Produttore-Consumatore con 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
  }
}

Semafori vs variabili condizione

CaratteristicaSemaforoVariabile condizione (monitor)
waitPotrebbe non bloccare (se S.V > 0)Blocca sempre
signalHa sempre effetto (incrementa o sblocca)Nessun effetto se la coda e vuota
Processo sbloccatoArbitrario (o FIFO)Testa della coda FIFO
RipresaImmediataDipende dalla disciplina di segnalazione

Implementazione di un semaforo con monitor

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.

Verifica le tue conoscenze

Qual e la differenza tra test e verifica di un programma concorrente?

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.

Cosa significa "starvation" nel contesto del problema della sezione critica?

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.

Quali sono le quattro condizioni necessarie per il deadlock (Coffman)?

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.

Come si esprime la proprieta di mutua esclusione in LTL?

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.

Qual e la differenza tra un semaforo e una variabile condizione in un monitor?

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).

Cosa si intende per "k-bounded overtaking" nel problema della sezione critica?

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".

Qual e il problema principale del model checking e come viene affrontato?

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.

Perche l'algoritmo di Peterson risolve il problema della sezione critica?

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.