Programmazione Concorrente e Distribuita — Prof. Alessandro Ricci

Semafori, Deadlock e Monitor

2026-03-06175 minregistrazione originale

In questa lezione

1. Modellare l'esecuzione concorrente

Prima di introdurre i meccanismi di coordinamento, e importante avere un modello formale di cosa significa eseguire un programma concorrente. Il professore sottolinea che questo modulo — Modeling Concurrent Program Execution — e il piu teorico del corso, ma costituisce il nucleo concettuale per comprendere davvero la programmazione concorrente, non solo dal punto di vista dello sviluppo ma anche della modellazione.

Idea chiave

Un programma concorrente non ha un unico scenario di esecuzione: ne ha molti, potenzialmente esponenziali. Il nondeterminismo e intrinseco, e la correttezza non puo essere verificata con il solo testing tradizionale.

Modello a interleaving arbitrario

Ogni processo viene modellato come una sequenza di azioni atomiche. L'esecuzione concorrente e rappresentata come un interleaving arbitrario delle azioni dei vari processi, eseguite da un singolo processore astratto globale. Questa astrazione — detta speed independence assumption — ignora il tempo reale e si concentra sugli ordini parziali tra le azioni. E robusta rispetto a cambiamenti hardware e software perche indipendente da variazioni di velocita e tempistiche.

flowchart LR
  subgraph P["Processo P"]
    direction LR
    p1("p1: n := k1") --> p2("p2: ...")
  end
  subgraph Q["Processo Q"]
    direction LR
    q1("q1: n := k2") --> q2("q2: ...")
  end
  p1 --> q1
  p1 --> p2
  q1 --> q2
  q1 --> p2

Numero di scenari

Con due processi che hanno m azioni ciascuno, il numero di scenari possibili e dato dal coefficiente binomiale C(2m, m). Con 3 processi la crescita e ancora piu rapida: bastano 3 azioni per processo per avere 1680 scenari diversi. Questo rende il testing esaustivo sostanzialmente impossibile per programmi reali.

Azioni per processo2 processi3 processi
126
2690
3201680
47034650

Importanza dell'atomicita

Il professore mostra un esempio cruciale: un incremento n := n + 1 sembra un'operazione singola, ma a livello macchina si scompone in load R1, n; add R1, #1; store n, R1. Se due processi eseguono questa sequenza in parallelo, il risultato finale puo essere 1 invece di 2 — una race condition. L'atomicita delle operazioni e fondamentale: se un'operazione non e atomica, processi concorrenti possono osservare stati intermedi inconsistenti.

Per l'esame

Scegliere il giusto livello di atomicita e cruciale nella modellazione. Operazioni su tipi di dato astratti (classi, struct) sono tipicamente non atomiche a meno di sincronizzazione esplicita. Gli stati interni inconsistenti sono invisibili in programmazione sequenziale ma diventano problematici in contesto concorrente.

2. Proprieta di correttezza: safety e liveness

Per un programma concorrente, la correttezza si definisce in termini di proprieta delle computazioni — condizioni che devono valere in tutti gli scenari possibili, non solo in alcuni. Il professore distingue due categorie fondamentali.

Una proprieta di safety deve essere sempre vera: specifica che "cose cattive non accadono mai". Esempi:

  • Mutua esclusione: mai due processi in sezione critica contemporaneamente
  • No deadlock: nessun processo resta bloccato in attesa di un evento che non puo verificarsi
  • Assenza di race condition su dati condivisi

Le proprieta di safety si esprimono come invarianti: formule che devono valere in ogni stato di ogni computazione. Sono piu facili da verificare perche basta trovare un singolo stato che le viola per confutarle.

Una proprieta di liveness deve prima o poi diventare vera: specifica che "cose buone accadono eventualmente". Esempi:

  • No starvation: un processo che richiede una risorsa ottiene prima o poi la risorsa
  • No dormancy: un processo in attesa viene prima o poi risvegliato
  • Comunicazione affidabile: un messaggio inviato viene prima o poi ricevuto

Le proprieta di liveness sono piu difficili da verificare perche richiedono di analizzare scenari completi, non singoli stati.

La fairness e una proprieta di liveness che richiede che qualcosa di buono accada infinite volte. Esistono tre livelli, in ordine crescente di forza:

  • Unconditional fairness: ogni azione atomica non condizionale idonea viene eseguita prima o poi
  • Weak fairness: come sopra, e ogni azione condizionale la cui condizione diventa e rimane vera viene eseguita
  • Strong fairness: come sopra, e ogni azione condizionale la cui condizione diventa vera infinite volte viene eseguita

Logica Temporale Lineare (LTL)

Per specificare formalmente queste proprieta si usa la LTL (introdotta da Amir Pnueli nel 1977, Premio Turing), che aggiunge operatori temporali alla logica proposizionale:

Proprieta utili: [] p → p (riflessivita), <> [] p (alla fine stabilizza), [] <> p (infinitamente spesso). Inoltre, <> p = true U p e [] p = ¬ <> ¬ p.

Per l'esame

La proprieta di mutua esclusione per il problema della sezione critica si scrive in LTL come [] ¬(p3 ∧ q3). La proprieta di progress (assenza di starvation) si scrive [](p2 → <> p3) — se un processo e in attesa, prima o poi entrera nella sezione critica.

K-bounded overtaking

A volte la semplice assenza di starvation e una proprieta debole: un processo potrebbe essere superato 1000 volte prima di entrare in CS. La proprieta di k-bounded overtaking garantisce che un processo puo essere superato al massimo k volte. Si esprime in LTL con l'operatore weak until: tryp → (¬ CSq) W (CSq W ((¬ CSq) W CSp)) per 1-bounded overtaking.

3. Semafori: introduzione e tipo di dato

Introdotti da Edsger Dijkstra nel 1968, i semafori sono un costrutto semplice ma estremamente potente per risolvere quasi ogni problema di mutua esclusione e sincronizzazione. Il professore li introduce con un'analogia: "e qualcosa che a un certo punto vedo che se e rosso mi fermo, se e verde passo. Se mi fermo perche e rosso, probabilmente qualcun altro deve passare." Come un semaforo stradale, blocca e sblocca i processi.

Rispetto ai lock visti nel modulo precedente (che facevano solo mutua esclusione), i semafori sono piu generali: permettono anche la sincronizzazione. Il professore sottolinea che e la stessa identica struttura dati che serve a scopi diversi, semplicemente cambiando il valore di inizializzazione.

Struttura del semaforo

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

CampoDescrizione
S.VIntero ≥ 0 — il "valore" del semaforo
S.LInsieme (o coda) di identificatori di processo bloccati

Viene inizializzato specificando un valore k ≥ 0 per S.V e l'insieme vuoto per S.L. Notazione: semaphore S = (k, {}). Fornisce due operazioni atomiche fondamentali:

Nota del redattore

I nomi originali P e V scelti da Dijkstra derivano dal nederlandese e sono ancora usati in molti testi classici (ad esempio nel libro di Ben-Ari). Nella pratica moderna si preferiscono i nomi inglesi wait/signal o acquire/release.

4. Operazioni atomiche: wait e signal

Operazione wait(S)

wait(S) =
  < if (S.V > 0)
      S.V := S.V - 1     { semaforo verde: passa e decrementa }
    else
      S.L := S.L + {p}   { semaforo rosso: processo p bloccato }
      p.state := blocked >

Se il valore del semaforo e maggiore di zero ("verde"), viene semplicemente decrementato e il processo prosegue senza bloccarsi. Se invece e zero ("rosso"), il processo viene bloccato e il suo identificatore viene aggiunto all'insieme S.L. L'intera operazione e atomica: non puo essere interrotta tra il controllo della condizione e l'eventuale modifica di S.V o S.L.

Operazione signal(S)

signal(S) =
  < if (S.L = {})
      S.V := S.V + 1       { nessuno in attesa: incrementa il valore }
    else
      let q := elemento arbitrario di S.L  { sceglie un processo }
      S.L := S.L - {q}     { lo rimuove dalla coda }
      q.state := ready >   { e lo rende pronto per l'esecuzione }

Se non ci sono processi in attesa, il valore del semaforo viene incrementato — il semaforo "ricorda" la signal. Altrimenti, viene selezionato un processo dalla coda S.L (l'arbitrarieta della scelta e importante: in un semaforo debole e un elemento qualunque, in uno forte e il primo in coda FIFO) e portato nello stato ready.

Idea chiave

Signal "ricorda": se un processo fa signal(S) quando nessuno e in attesa, il valore di S.V viene incrementato. Un successivo wait(S) da parte di un altro processo trovera il semaforo verde e passera senza bloccarsi. Questa proprieta distingue i semafori da altri meccanismi piu semplici.

5. Invariante e tipologie di semafori

L'invariante fondamentale

Dato un valore iniziale k per S.V, vale il seguente invariante che costituisce la base per tutte le dimostrazioni di correttezza:

S.V ≥ 0
S.V = k + #signal(S) - #wait(S)

Dove #signal(S) e il numero di operazioni signal completate con successo e #wait(S) il numero di operazioni wait completate (un processo che si blocca durante la wait NON ha completato l'operazione, quindi non viene contato).

Tipologie principali

Il professore distingue tre usi principali, che corrispondono a tre valori di inizializzazione diversi:

TipoValori di S.VInizializzazioneUso tipico
Mutex (binary)0, 1k = 1Mutua esclusione (come un lock): un permesso, un processo per volta
Counting (general/resource)≥ 0k = NGestione risorse multiple: ticket per limitare l'accesso a N processi
Event0k = 0Sincronizzazione: un processo segnala un evento, un altro lo aspetta

Il professore sottolinea: "Fin qui e competizione [mutex]. L'ultimo [event] e quello che fa fare invece sincronizzazione. Non stiamo competendo, ma stiamo cooperando, dobbiamo sincronizzare le azioni. La cosa incredibile per me e lo stesso semaforo. Semplicemente deve essere inizializzato a zero."

Attenzione

Rispetto ai lock visti nei moduli precedenti, i semafori sono piu generali: un lock puo fare solo mutua esclusione. Con un semaforo si puo fare sia mutua esclusione che sincronizzazione. Pero proprio per questa generalita, sono anche piu difficili da usare correttamente.

6. Semafori forti, deboli e busy-wait

Esistono diverse definizioni del tipo semaforo, che differiscono per le proprieta di liveness (non per quelle di safety, che seguono dall'invariante). La scelta influenza se il sistema garantisce o meno assenza di starvation.

In un semaforo forte, S.L non e un insieme ma una coda FIFO. La wait inserisce in coda, la signal preleva dalla testa. Proprieta importante: no starvation — per qualsiasi numero N di processi, la starvation e impossibile perche i processi vengono serviti in ordine di arrivo.

wait(S) =
  < if (S.V > 0) S.V := S.V - 1
    else append(S.L,p); p.state := blocked >
signal(S) =
  < if (S.L = empty_queue) S.V := S.V + 1
    else q := take(S.L); q.state := ready >

In un semaforo debole, S.L e un insieme (non ordinato). La signal seleziona un processo arbitrario dall'insieme. Questo significa che non c'e garanzia di assenza di starvation: in teoria, uno scenario sfortunato potrebbe far saltare sempre lo stesso processo e favorire sempre gli altri.

I semafori busy-wait (o spin semaphore) non hanno S.L: non esiste una coda di processi bloccati. Il processo rimane in esecuzione in un ciclo di attesa attiva (spin-loop). Le operazioni sono comunque atomiche.

wait(S) = < await (S.V > 0); S.V := S.V - 1 >
signal(S) = < S.V := S.V + 1 >

Non c'e garanzia di freedom from starvation: quando un processo incrementa S.V, non e possibile prevedere quale dei processi in attesa nel ciclo while riuscira per primo a passare. Sono appropriati in sistemi multiprocessore con bassa contesa, dove il costo di un context switch (e quindi di coinvolgere il sistema operativo) e superiore al breve spin-loop.

Attenzione

Il professore avverte: con busy-wait non ho nessuna garanzia di freedom from starvation. Quando incremento, se ci sono piu processi in while loop, non posso prevedere chi passera per primo. E soggetto a starvation.

7. Sezione critica con semafori

Usare un semaforo come lock per la sezione critica e immediato: si inizializza un semaforo binario (mutex) a 1 e si segue il protocollo wait/signal prima e dopo la sezione critica. Il professore commenta che questa soluzione vale per un numero arbitrario N di processi, non solo 2.

semaphore S ← (1,{})     { permesso unico: inizializzato a 1 }

loop forever              { per ogni processo }
  p1: NCS                 { sezione non critica }
  p2: wait(S)             { pre-protocollo: acquisisce il lock }
  p3: CS                  { sezione critica }
  p4: signal(S)           { post-protocollo: rilascia il lock }

Verifica di correttezza con diagramma di stato

Costruendo il diagramma di stato ridotto, che include lo stato del semaforo {S.V, S.L} e gli stati bloccati (p1B, q1B), si verifica che la soluzione e corretta per 2 processi:

flowchart TD
  subgraph "Diagramma di stato ridotto"
    s0["p1, q1, {1,{}}"] --> s1["p2, q1, {0,{}}"]
    s0 --> s2["p1, q2, {0,{}}"]
    s1 --> s3["p2, q1B, {0,{Q}}"]
    s1 --> s4["p1B, q2, {0,{P}}"]
    s2 --> s3
    s2 --> s4
  end
Idea chiave

La soluzione con semafori per la sezione critica vale per N processi (non solo 2). Tuttavia, con semaforo debole e N > 2, si perde la garanzia di freedom from starvation: un processo potrebbe essere sempre scavalcato.

8. Sincronizzazione con semafori evento

I semafori non servono solo per la mutua esclusione. Inizializzando il semaforo a 0 (event semaphore), si trasformano in un meccanismo di segnalazione: un processo segnala un evento, un altro lo attende. Il professore sottolinea che cambiano anche le parole: non si parla piu di "permesso" o "lock", ma di "evento" e "segnale".

Esempio: Merge Sort concorrente

Vogliamo ordinare un array dividendolo in due meta, ordinarle concorrentemente con due processi separati, e poi fare il merge. Abbiamo bisogno di sapere quando entrambe le meta sono state ordinate. Servono due semafori evento:

semaphore S1 ← (0,{})   { evento: prima meta ordinata }
semaphore S2 ← (0,{})   { evento: seconda meta ordinata }
integer array A          { array da ordinare }

sort1:                   sort2:                   merge:
  ordina 1a meta di A      ordina 2a meta di A      wait(S1)
  signal(S1)               signal(S2)               wait(S2)
                                                     merge delle due meta

Il processo merge si blocca su S1 e S2 fino a quando entrambi i processi di sorting non hanno segnalato il completamento. Il professore nota che i nomi S1, S2 sono criptici ("piacciono tanto agli informatici") e suggerisce di usare nomi significativi come firstHalfSorted e secondHalfSorted — un consiglio di buona pratica di programmazione.

Attenzione — un bug subdolo

Durante la lezione, uno studente identifica un bug interessante: se il comportamento e ciclico (cioe sort1, sort2 e merge si ripetono), e se sort1 e sort2 eseguono signal due volte in un ciclo, allora merge ha "diritto" di passare due volte. Questo puo causare un disallineamento che porta a situazioni di blocco. Il professore commenta che e un bug "terribile, nel senso da identificare, che nasce proprio dalle ot..." (dal comportamento ciclico).

9. Producer-Consumer con buffer infinito

Il problema Producer-Consumer e un esempio classico di sincronizzazione, onnipresente in informatica. Due tipi di processi cooperano: i produttori creano dati, i consumatori li elaborano. Esempi concreti:

ProduttoreConsumatore
Browser web (richiede pagine)Linea di comunicazione (le trasmette)
Tastiera (genera tasti)Sistema operativo (li elabora)
Word processor (prepara pagine)Stampante (le stampa)
Programma di gioco (rende frame)Schermo (li visualizza)

Buffer infinito

La comunicazione puo essere sincrona (mittente e destinatario devono essere pronti contemporaneamente) o asincrona (un buffer intermedio disaccoppia temporalmente i partecipanti). Con un buffer infinito, l'unica sincronizzazione necessaria e che il consumatore non tenti di prelevare da un buffer vuoto.

semaphore availItems ← (0,{})
UnboundedQueue<Item> buffer ← empty queue

producer:                     consumer:
  loop forever                  loop forever
    Item el ← produce             wait(availItems)
    append(buffer,el)             Item el ← take(buffer)
    signal(availItems)            consume(el)

L'invariante e: availItems.V = #buffer — il valore del semaforo corrisponde al numero di elementi presenti nel buffer. Il semaforo availItems e detto resource semaphore perche conta una risorsa disponibile (gli item).

Il professore nota che questo schema realizza un disaccoppiamento temporale: produttori e consumatori non devono conoscersi ne coordinarsi direttamente, operano a velocita diverse, e il set di processi puo essere dinamico.

10. Bounded buffer e split semaphore

Nella realta i buffer sono finiti. Con un buffer limitato, anche il produttore deve poter essere bloccato: quando il buffer e pieno, non puo produrre ulteriori elementi. Il professore usa l'analogia dello scolapiatti: "chi lava i piatti [produttore] si ferma quando lo scola piatti e pieno, indipendentemente da chi asciuga [consumatore]."

La risorsa che serve al produttore e un posto libero nel buffer. Introduciamo un secondo semaforo availPlaces, il duale di availItems.

semaphore availItems ← (0,{})
semaphore availPlaces ← (N,{})
BoundedQueue<Item> buffer ← empty queue

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 e availPlaces sono detti split semaphore. L'invariante e: availItems + availPlaces = N (la somma dei due valori del semaforo e sempre uguale alla dimensione del buffer).

Idea chiave

Il pattern del semaforo counting inizializzato a N per implementare un numero di ticket e applicabile in molti contesti. "Ogni volta che devo implementare una situazione in cui voglio fare in modo che qualcuno prenda un ticket per poter fare qualcosa, perche voglio limitare il numero di processi, penso a un semaforo counting o resource, inizializzato al numero di ticket: quando ti serve devi fare wait, quando hai finito lo devi restituire con signal."

11. Semafori combinati: mutex + sincronizzazione

Se le operazioni sul buffer non sono atomiche (ad esempio, se ci sono piu produttori e piu consumatori), serve anche un semaforo per la mutua esclusione sull'accesso al buffer condiviso. Il professore mostra la soluzione completa che combina split semaphore e mutex:

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)
Per l'esame — errore fatale

Ordine delle wait: prima si fa wait sul semaforo di sincronizzazione (availPlaces/availItems), e poi sul mutex. Invertire questo ordine porta a deadlock: un produttore potrebbe tenere il mutex mentre e bloccato in attesa di un posto libero, impedendo al consumatore di entrare per svuotare il buffer. Il professore sottolinea inoltre: "Se vi dimenticate una signal, si blocca tutto. E molto delicato."

12. Dining Philosophers e Deadlock

Il problema dei Filosofi a Cena fu introdotto da Dijkstra nel 1971 per illustrare il problema del deadlock (che chiamava deadly embrace). Originariamente erano cinque computer che competevano per cinque drive a nastro; Tony Hoare lo rese popolare come la storia dei filosofi. Oggi e considerato un classico veicolo didattico per confrontare formalismi di programmazione concorrente.

Cinque filosofi siedono a un tavolo con 5 piatti e 5 forchette. Alternano due attivita: pensare (la loro sezione non critica) e mangiare. Per mangiare, servono due forchette — quella a sinistra e quella a destra. La sfida e che le forchette sono condivise tra filosofi adiacenti: la forchetta i e usata dai filosofi i e (i+1)%N.

Primo tentativo (incorretto: deadlock!)

semaphore array[0..4] fork ← [1,1,1,1,1]

philosopher[i]:   { per i = 0..4 }
  loop forever
    think
    wait(fork[i])             { prendi forchetta sinistra }
    wait(fork[(i+1)%N])       { prendi forchetta destra }
    eat
    signal(fork[i])
    signal(fork[(i+1)%N])

Questo tentativo e corretto per la mutua esclusione (nessuna forchetta e mai tenuta da due filosofi contemporaneamente, perche ogni forchetta e un semaforo binario), ma soffre di deadlock: se tutti i filosofi prendono contemporaneamente la forchetta sinistra prima che qualcuno provi a prendere la destra, nessuno puo mangiare e tutti restano bloccati per sempre in attesa della forchetta destra.

13. Condizioni di Coffman

Un deadlock e una situazione in cui due o piu azioni in competizione aspettano che l'altra finisca, e quindi nessuna finisce mai. Il professore lo chiama anche deadly embrace, il termine originale di Dijkstra. Nel 1971, Coffman identifico le quattro condizioni necessarie affinche un deadlock possa verificarsi in un sistema:

  1. 1. Mutua esclusione — la risorsa coinvolta non puo essere usata da piu di un processo alla volta
  2. 2. Hold and wait — i processi che gia detengono risorse possono richiederne di nuove, senza rilasciare le prime
  3. 3. No preemption — una risorsa non puo essere rimossa forzatamente dal processo che la detiene; solo il processo stesso puo rilasciarla esplicitamente
  4. 4. Circular wait — esiste una catena circolare di processi, dove ciascuno attende una risorsa detenuta dal successivo nella catena

Il deadlock puo verificarsi solo in sistemi dove tutte e quattro le condizioni sono vere contemporaneamente. Rimuovendone anche una sola, il deadlock e prevenuto.

Deadlock con i lock (deadly embrace)

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. Nei database, questo scenario viene gestito automaticamente: il DBMS analizza il grafo delle dipendenze, identifica il ciclo, sceglie una vittima e abortisce la transazione, rilasciando i lock. Nella JVM, invece, non esiste alcun meccanismo automatico di rilevamento e recovery: "if threads deadlock, that's all, folks!" — l'unica opzione pratica e spegnere l'applicazione e fare una diagnosi post-mortem.

Attenzione

Nei database i deadlock vengono rilevati e gestiti automaticamente (si analizza il grafo is-waiting, si cerca un ciclo, si sceglie una vittima). Nella JVM non c'e nulla di tutto questo: se i thread vanno in deadlock, basta. Si puo solo fare post-mortem diagnosis.

14. Soluzioni per i Filosofi: ticket e ordinamento

Il professore presenta due soluzioni classiche al problema dei Filosofi, entrambe valide e che soddisfano tutte le proprieta (mutua esclusione, no deadlock, no starvation, comportamento efficiente in assenza di contesa).

Soluzione con ticket (N-1)

Limitiamo il numero di filosofi che possono mangiare contemporaneamente a N-1 (quindi 4 su 5). L'idea e che se solo 4 filosofi possono tentare di prendere le forchette, almeno uno riuscira sempre a prendere entrambe. Si introduce un semaforo counting ticket inizializzato a 4:

semaphore array[0..4] fork ← [1,1,1,1,1]
semaphore ticket ← (4,{})

philosopher[i]:
  loop forever
    think
    wait(ticket)              { entra nella sala da pranzo }
    wait(fork[i])
    wait(fork[(i+1)%N])
    eat
    signal(fork[i])
    signal(fork[(i+1)%N])
    signal(ticket)            { esce dalla sala da pranzo }

Il professore commenta: "Se hai cinque filosofi che vogliono mangiare tutti insieme, metti quattro permessi, fai mangiare solo quattro alla volta. Se metti quattro permessi, come se dovessero prendere un ticket prima di iniziare a mangiare, si risolve il problema."

Soluzione con ordinamento (breaking the wait-for chain)

Si osserva che non c'e deadlock se si impone un ordine totale nell'acquisizione delle forchette. L'idea e che ogni filosofo prende prima la forchetta con indice minore e 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])

Il professore spiega l'intuizione: "Se lui cerca di prendere prima la forchetta con indice minore, e questa e gia stata presa da un altro, si blocca subito senza avere l'altra forchetta. Quindi l'altra forchetta puo essere presa dal filosofo vicino. Non si crea mai un ciclo di attesa."

15. Regola generale per evitare il deadlock

Dall'esperienza dei Filosofi si generalizza una regola semplice ma potentissima per evitare deadlock in sistemi con lock multipli. Il professore la presenta come forse la cosa piu importante da ricordare quando si scrive codice concorrente:

Regola d'oro
  1. Assegnare un ordine totale a tutti i lock del sistema
  2. Acquisire i lock sempre nello stesso ordine, in ogni punto del codice

Perche funziona?

Rende impossibile la condizione di circular wait, che e una delle quattro condizioni necessarie di Coffman. Se ogni processo acquisisce i lock in ordine crescente, non si puo formare un ciclo: se P aspetta una risorsa tenuta da Q, significa che P ha lock con indici piu piccoli e Q con indici piu grandi, quindi Q non puo a sua volta aspettare una risorsa di P (perche la richiederebbe in ordine crescente, ma ha gia lock con indice maggiore).

Piuttosto che dimostrarlo formalmente, il professore ne da l'intuizione: "Notate che cosa succede se lui cerca di prendere prima la forchetta con indice minore. L'ha gia presa? Se e gia stata presa si blocca in attesa senza avere il possesso dell'altra. Quindi sostanzialmente il fatto di imporre l'ordine permette di fare in modo che, quando si crea una situazione che va verso l'essere ciclico, un processo non riesce a prendere neanche la prima."

Per l'esame

La regola "acquisisci sempre i lock nello stesso ordine" e forse il singolo concetto piu importante da ricordare per scrivere codice concorrente corretto. Il deadlock e stato ampiamente studiato, e sono state identificate le condizioni necessarie: romperne anche solo una e sufficiente.

16. Readers-and-Writers

Il problema Readers and Writers (Courtois, Heymans, Parnas, 1971) e una variante del problema della mutua esclusione, con due classi di processi che competono per l'accesso a una risorsa condivisa (tipicamente un database):

Gli invarianti da rispettare sono: nR ≥ 0, nW = 0 || nW = 1, e (nR > 0 → nW = 0) ∧ (nW = 1 → nR = 0).

Primo tentativo (troppo vincolante)

Usare un singolo semaforo rw come lock esclusivo per reader e writer e una soluzione over-constrained: serializza anche l'accesso dei reader, impedendo la lettura concorrente e quindi vanificando il vantaggio.

Soluzione ottimizzata

binary semaphore mutexR ← (1,{})
int nr ← 0
binary semaphore rw ← (1,{})

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)

mutexR protegge la variabile condivisa nr (numero di reader attivi). rw e il lock per i writer: il primo reader lo acquisisce, l'ultimo reader lo rilascia. I writer competono sempre su rw.

Il professore spiega il flusso: "Se ci sono solo reader, arrivano, prendono il mutex per un attimo, incrementano nr, escono, leggono. Tutti concorrenti. Quando arriva un writer, si blocca su rw finche l'ultimo reader non lo rilascia." Poi nel dettaglio: il primo reader vede nr == 0, prende rw, incrementa nr. Il secondo reader arriva, prende mutexR, vede nr > 0 (il primo ha gia preso rw), salta il wait(rw), incrementa nr. Entrambi leggono concorrentemente. L'ultimo reader, quando esce, decrementa nr, vede nr == 0 e fa signal(rw) per sbloccare eventuali writer in attesa.

Per l'esame

Questa soluzione da priorita ai reader: se arrivano reader continuamente, i writer potrebbero soffrire di starvation (non riescono mai a ottenere rw). Esistono varianti con priorita ai writer o eque (alternate), ma la struttura di base e questa. E importante capire il ruolo di nr: e il contatore che permette ai reader di condividere rw.

17. Oltre i semafori: i Monitor

I semafori sono potenti ma di basso livello: il professore sottolinea che sono espressivi e permettono di risolvere qualsiasi problema di interazione, ma il loro uso non e banale perche sono un meccanismo comunque di basso livello, facile da sbagliare. Da qui nasce l'esigenza di costrutti di piu alto livello: i Monitor.

Introdotti da Brinch Hansen (1973) e generalizzati da Tony Hoare (1974), un Monitor e un'astrazione di dati per la programmazione concorrente che incapsula in un'unica entita:

monitor MonitorName {
  { dichiarazione di variabili permanenti }
  { inizializzazione }
  procedure opName(params) { ... }
  ...
}

Proprieta fondamentali

Il professore nota che il monitor e una generalizzazione del concetto di kernel dei sistemi operativi: invece di avere un unico kernel centralizzato che gestisce tutte le sezioni critiche, ogni monitor e un "mini-kernel" decentralizzato specializzato per un dato condiviso. E anche una generalizzazione del concetto di oggetto nella OOP: una classe che incapsula dati + operazioni + politica di sincronizzazione/mutua esclusione.

Idea chiave

L'esempio piu semplice e un contatore thread-safe: monitor Counter { int count; procedure inc() { count := count + 1; } }. Senza alcun lock esplicito, il monitor garantisce che due thread non eseguano mai inc() contemporaneamente. La mutua esclusione e implicita, fornita dall'implementazione del monitor.

18. Variabili condizione e signaling discipline

Le variabili condizione sono un tipo di dato primitivo che i monitor mettono a disposizione per la sincronizzazione esplicita. Ogni variabile condizione e associata a una coda FIFO di processi bloccati su quella condizione.

Operazioni principali:

OperazioneDescrizione
waitC(cond)Sospende il processo chiamante e rilascia il lock del monitor (fondamentale: senza questo rilascio, nessun altro processo potrebbe entrare per cambiare lo stato)
signalC(cond)Sblocca il primo processo in coda sulla condizione; se la coda e vuota, non ha effetto
emptyC(cond)Verifica se la coda della condizione e vuota
signalAllC(cond)Sblocca tutti i processi in attesa sulla condizione (utile quando non si sa quale risvegliera)
waitC(cond, rank)Attesa con priorita: il processo viene inserito in coda in ordine crescente di rank
minrank(cond)Restituisce il rank del processo in testa alla coda

Esempio: SynchCell

monitor SynchCell {
  int value;
  boolean available := false;
  cond isAvail;

  procedure set(int v) {
    value := v;
    available := true;
    signalC(isAvail);
  }

  procedure get(): int {
    if (!available)
      waitC(isAvail);
    return value;
  }
}

Un processo chiama get() ma se il valore non e ancora disponibile (available = false), si sospende su isAvail. Quando un altro processo chiama set(), imposta il valore e segnala isAvail, risvegliando il processo in attesa. Il professore sottolinea che il waitC rilascia automaticamente il lock del monitor, permettendo cosi ad altri processi di chiamare set() per fornire il dato.

Semaphore vs. Condition Variable

SemaforoVariabile condizione (monitor)
wait puo non bloccare (se S.V > 0)waitC blocca sempre (rilascia il lock e si sospende)
signal ha sempre effetto (incrementa S.V o sblocca)signalC non ha effetto se la coda e vuota
signal sblocca un processo arbitrariosignalC sblocca il processo in testa alla coda FIFO
Il processo sbloccato puo proseguire subitoDipende dalla signaling discipline: puo dover attendere che il signaller esca dal monitor

Signaling disciplines

Quando un processo esegue signalC, sia il signaller che il signalled potrebbero essere pronti a proseguire nel monitor, ma solo uno puo avere accesso esclusivo. Le tre discipline classiche differiscono per l'ordine di priorita tra signaller (S), waiting (W) ed external processes (E — processi bloccati sulle procedure del monitor):

Il signaller continua la sua esecuzione; il signalled verra eseguito in un momento successivo, competendo con gli altri processi. Disciplina non-preemptive. Ordine di priorita: E < W < S.

Il signalled viene eseguito immediatamente; il signaller si mette in attesa, competendo ad armi pari con i processi esterni (E). Ordine: E = S < W. Preemptive.

Come Signal & Wait, ma il signaller ha priorita sui processi esterni quando puo riprendere. E la disciplina classica per i monitor, quella definita da Hoare. Ordine: E < S < W.

Il professore nota che la scelta della disciplina ha implicazioni pratiche: con Signal & Continue, dopo un waitC(cond) e consigliabile usare while (!condizione) invece di if (!condizione) per gestire possibili risvegli spuri (il signalled potrebbe trovare la condizione ancora falsa perche un altro processo l'ha cambiata nel frattempo).

19. Producer-Consumer con Monitor

Ecco l'implementazione del bounded buffer come monitor, che incapsula automaticamente la mutua esclusione sull'accesso al buffer e la sincronizzazione tramite variabili condizione:

monitor BoundedBuffer {
  ElemType buffer := <EmptyBuffer>
  cond notFull, notEmpty;

  procedure put(ElemType elem) {
    if (buffer is full)
      waitC(notFull);
    append(buffer, elem);
    signalC(notEmpty);
  }

  procedure take(): ElemType {
    if (buffer is empty)
      waitC(notEmpty);
    ElemType el := head(buffer);
    signalC(notFull);
    return el;
  }
}

producer:
  loop
    ElemType el := produce
    BoundedBuffer.put(el)

consumer:
  loop
    ElemType el := BoundedBuffer.take()
    consume(el)

Il codice produttore e consumatore diventa estremamente semplice: chiamano rispettivamente put e take sul monitor, senza preoccuparsi di lock espliciti o semafori. La variabile condizione notFull blocca il produttore se il buffer e pieno; notEmpty blocca il consumatore se il buffer e vuoto. Quando un produttore aggiunge un elemento, segnala notEmpty per sbloccare un eventuale consumatore in attesa; quando un consumatore preleva, segnala notFull per sbloccare un eventuale produttore.

Il professore commenta: "Questa soluzione e molto piu semplice e chiara rispetto a quella con semafori. Il monitor gestisce automaticamente la mutua esclusione sul buffer, e le variabili condizione notFull/notEmpty gestiscono la sincronizzazione."

Nota del redattore

Nell'implementazione con array circolare (dimensione fissa MAX_ELEMS), lo spazio utile e MAX_ELEMS-1 per distinguere buffer pieno da buffer vuoto. Con disciplina Signal & Continue, il controllo if dovrebbe essere while per gestire risvegli spuri, e si puo usare una variabile di stato esplicita (come un contatore) invece di controllare direttamente se il buffer e pieno/vuoto.

20. Readers-and-Writers con Monitor

L'implementazione del RWLock come monitor mostra la potenza delle variabili condizione per gestire politiche di sincronizzazione complesse. Ecco la versione con disciplina Signal & Continue:

monitor RWLock {
  int nr, nw = 0;
  cond okToRead, okToWrite;

  procedure request_read() {
    while (nw > 0)
      waitC(okToRead);
    nr := nr + 1;
  }

  procedure release_read() {
    nr := nr - 1;
    if (nr = 0)
      signalC(okToWrite);
  }

  procedure request_write() {
    while (nr > 0 or nw > 0)
      waitC(okToWrite);
    nw := nw + 1;
  }

  procedure release_write() {
    nw := nw - 1;
    signalC(okToWrite);
    signalAllC(okToRead);
  }
}

L'invariante: (nr == 0 or nw == 0) and (nw <= 1) — o ci sono solo reader, o solo un writer (o nessuno). release_write() fa due segnalazioni: signalC(okToWrite) per sbloccare un writer in attesa (se c'e), e signalAllC(okToRead) per risvegliare tutti i reader in attesa.

Il professore mostra anche una seconda versione, piu compatta, che usa il pattern "cascading signal": dopo startRead(), il primo reader sbloccato chiama a sua volta signalC(okToRead) per risvegliare il successivo, creando una catena. Questa versione elimina il while e torna a usare if per il controllo delle condizioni.

procedure startRead() {
  if writers != 0
    waitC(okToRead);
  readers := readers + 1;
  signalC(okToRead);    { risveglia il prossimo reader in cascata }
}

procedure endWrite() {
  writers := writers - 1;
  if emptyC(okToRead)
    then signalC(okToWrite)   { se nessun reader aspetta, sblocca un writer }
    else signalC(okToRead);   { altrimenti sblocca il primo reader }
}

21. Implementare i Monitor con semafori

I monitor possono essere realizzati usando semafori come building blocks di livello piu basso. Per ogni monitor servono:

Le due discipline si implementano cosi:

Prologo per ogni operazione:
  wait(mutex)
Epilogo per ogni operazione:
  signal(mutex)

waitC(cond) =
  condcount++;
  signal(mutex);     { rilascia il lock del monitor }
  wait(condsem);     { si blocca sulla condizione }
  wait(mutex);       { quando si risveglia, riacquisisce il lock }

signalC(cond) =
  if (condcount > 0) {
    condcount--;
    signal(condsem); { sblocca un processo in attesa }
  }
Prologo per ogni operazione:
  wait(mutex)
Epilogo per ogni operazione:
  signal(mutex)

waitC(cond) =
  condcount++;
  signal(mutex);
  wait(condsem);     { si blocca — verra sbloccato da signalC }

signalC(cond) =
  if (condcount > 0) {
    condcount--;
    signal(condsem); { sblocca il processo in attesa }
    wait(mutex);     { il signaller si mette in attesa del lock }
  }

Implementare un semaforo con un monitor

Chiudendo il cerchio, si puo anche implementare un semaforo usando un monitor, dimostrando l'equivalenza espressiva dei due costrutti:

monitor Semaphore {
  integer s := <InitValue>
  cond notZero;

  procedure wait() {
    if s = 0
      waitC(notZero);
    s := s - 1;
  }

  procedure signal() {
    s := s + 1;
    signalC(notZero);
  }
}

Il professore mostra anche una versione alternativa che separa il caso in cui s > 0 (decrementa senza bloccare) dal caso s = 0 (si blocca). Entrambe le versioni sono corrette.

22. Model Checking e verifica formale

Data l'esplosione del numero di scenari nei programmi concorrenti, il testing tradizionale non basta. Il professore introduce due approcci principali di verifica formale, anticipando che nella prossima lezione mostrera esempi pratici con Java Path Finder.

Model Checking

Il model checking e una tecnica automatica che esplora esaustivamente tutto lo spazio degli stati di un sistema per verificare se certe proprieta sono soddisfatte. Se la proprieta e violata, produce un controesempio — una traccia di esecuzione completa che porta alla violazione, utilissima per identificare e correggere bug.

Strumenti principali:

Per l'esame

Safety property: basta trovare UNO stato che viola la proprieta per confutarla. Liveness property: non basta controllare stati uno per uno, bisogna analizzare tutti gli scenari completi. La verifica di liveness e piu complessa sia in teoria che in pratica.

Inductive Proof of Invariants

Un approccio alternativo, puramente matematico: invece di esplorare tutti gli stati, si dimostra che una proprieta (invariante) vale per induzione sugli stati di tutte le computazioni:

  1. Base case: l'invariante e vero nello stato iniziale
  2. Passo induttivo: assumendo che l'invariante valga in uno stato S (ipotesi induttiva), si dimostra che vale in tutti i possibili stati successivi a S

Questo approccio e supportato da sistemi deduttivi automatici (theorem provers) e da linguaggi di specifica formale come TLA+ di Leslie Lamport, usato ad esempio da Amazon Web Services per verificare sistemi reali. TLA+ permette di specificare sia le proprieta di correttezza (il "cosa") sia il progetto del sistema (il "come"), e supporta model checking tramite TLC. Include PlusCal, un linguaggio algoritmico che viene tradotto automaticamente in TLA+.

Tecniche per gestire l'esplosione degli stati

Il problema principale del model checking e la dimensione dello spazio degli stati. Le tecniche moderne includono:

Il professore conclude che capire il senso di queste cose — perche un algoritmo funziona, perche e stato progettato in un certo modo — e essenziale per un ingegnere, ancora di piu in un'epoca in cui l'AI genera codice. Saper riconoscere se la soluzione proposta da uno strumento automatico e corretta o meno e una competenza fondamentale.

Verifica le tue conoscenze

Qual e la differenza fondamentale tra un lock e un semaforo?

Un lock serve solo per la mutua esclusione. Un semaforo e piu generale: puo essere usato sia per mutua esclusione (semaforo binario inizializzato a 1) sia per sincronizzazione (event semaphore inizializzato a 0) sia per gestire risorse multiple (counting semaphore inizializzato a N). I lock sono un sottoinsieme dei semafori, non viceversa: "un lock si puo fare con i semafori".

Qual e l'invariante fondamentale di un semaforo?

Dato un valore iniziale k per S.V: S.V = k + #signal(S) - #wait(S) e S.V ≥ 0. Dove #signal(S) e il numero di operazioni signal completate e #wait(S) il numero di wait completate con successo (un processo che si blocca durante la wait non viene contato).

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

1. Mutua esclusione: la risorsa non puo essere usata da piu processi contemporaneamente.
2. Hold and wait: i processi che detengono risorse possono richiederne altre.
3. No preemption: le risorse non possono essere rimosse forzatamente.
4. Circular wait: esiste una catena circolare di processi che attendono risorse detenute da altri nella catena.
Tutte e quattro devono essere vere perche si verifichi deadlock.

Come si risolve il problema dei Filosofi a Cena con i semafori?

Due soluzioni classiche: 1) Ticket (N-1): si introduce un semaforo counting inizializzato a N-1 (4 per 5 filosofi) che limita il numero di filosofi che possono tentare di mangiare contemporaneamente. 2) Ordinamento totale: ogni filosofo acquisisce le forchette in ordine crescente di indice (first = min(i, i+1); second = max(i, i+1)). Entrambe rompono la condizione di circular wait.

Differenza tra semafori e variabili condizione nei monitor?

wait su semaforo puo non bloccare (se S.V > 0); waitC su variabile condizione blocca sempre (dopo aver rilasciato il lock del monitor). signal su semaforo ha sempre effetto (incrementa S.V o sblocca); signalC non ha effetto se la coda e vuota. signal sblocca un processo arbitrario; signalC sblocca il processo in testa alla coda FIFO. Un processo sbloccato da signal puo proseguire subito; con signalC la ripresa dipende dalla signaling discipline.

Cosa si intende per "split semaphore"?

Due semafori che insieme mantengono un invariante fisso. L'esempio classico e il bounded buffer: availItems + availPlaces = N. availItems conta gli elementi disponibili (inizializzato a 0), availPlaces conta i posti liberi (inizializzato a N). Quando il produttore aggiunge un elemento: wait(availPlaces), signal(availItems). Quando il consumatore preleva: wait(availItems), signal(availPlaces).

Qual e la regola generale per evitare il deadlock con lock multipli?

Assegnare un ordine totale a tutti i lock e acquisirli sempre nello stesso ordine in tutti i punti del codice. Questo rende impossibile la condizione di circular wait, una delle quattro condizioni necessarie di Coffman. E una regola semplice ma estremamente efficace: se ogni processo acquisisce i lock in ordine crescente, non si puo formare un ciclo di attesa.

Differenza tra safety e liveness in LTL?

Safety (operatore [], "sempre"): "le cose cattive non accadono mai". Esempio: [] ¬(p3 ∧ q3) — mai due processi contemporaneamente nella sezione critica. Liveness (operatore <>, "eventualmente"): "le cose buone prima o poi accadono". Esempio: [](p2 → <> p3) — se un processo vuole entrare in CS, prima o poi ci riuscira. Le safety si verificano trovando un singolo stato violatore; le liveness richiedono l'analisi di scenari completi.

Qual e la differenza tra Signal & Continue e Signal & Wait nei monitor?

Signal & Continue (non-preemptive): il signaller continua la sua esecuzione, il signalled riprendera in un momento successivo. Priorita: processi esterni (E) < processi in attesa su condizione (W) < signaller (S). Signal & Wait (preemptive): il signalled viene eseguito immediatamente, il signaller si mette in attesa e competera ad armi pari con i processi esterni. Priorita: E = S < W. Signal & Urgent Wait: come Signal & Wait, ma il signaller ha priorita sui processi esterni (E < S < W).

Qual e la differenza tra un semaforo forte e uno debole?

In un semaforo forte, l'insieme S.L dei processi bloccati e organizzato come coda FIFO: la wait inserisce in coda, la signal preleva dalla testa. Questo garantisce no starvation per qualsiasi numero N di processi. In un semaforo debole, S.L e un insieme non ordinato e la signal seleziona un processo arbitrario — non c'e garanzia di assenza di starvation.