Programmazione Concorrente e Distribuita — Prof. Alessandro Ricci

Semafori, Monitor, Modellazione e Verifica di Programmi Concorrenti

2026-03-13165 min registrazione originale

In questa lezione

1. Modellare l'esecuzione concorrente

Il primo passo per comprendere un programma concorrente e costruirne un modello. Un modello e una descrizione rigorosa della struttura e del comportamento del sistema a un adeguato livello di astrazione: include le informazioni rilevanti e tralascia gli aspetti secondari. I modelli servono sia per la progettazione (rappresentazioni diagrammatiche) sia per la verifica formale.

Il modello standard per l'esecuzione concorrente si basa su tre assunzioni fondamentali:

In questo modello, il puntatore di controllo (control pointer) di un processo indica la prossima istruzione eseguibile. Una computazione (o scenario) e una sequenza di esecuzione che puo verificarsi come risultato dell'interleaving. Uno stato e definito da una tupla composta da: un'etichetta per ogni processo (la prossima istruzione da eseguire) e un valore per ogni variabile globale o locale.

Idea chiave Il diagramma di stato e un grafo che contiene tutti gli stati raggiungibili del programma. Gli scenari sono percorsi orientati attraverso il diagramma a partire dallo stato iniziale. I cicli rappresentano la possibilita di computazione infinita in un grafo finito.

Numero di scenari

Il numero di possibili scenari cresce in modo combinatorio. Per due processi con n azioni ciascuno, il numero di scenari possibili e il coefficiente binomiale centrale C(2n, n). La tabella seguente mostra la crescita:

Azioni per processoNumero di scenari (2 processi)
12
26
320
470
5252
6924

Con 3 processi il numero esplode ancora di piu: con 2 azioni ciascuno si hanno gia 90 scenari possibili. Questo rende evidente perche il testing tradizionale non basta per programmi concorrenti: ogni esecuzione puo seguire uno scenario diverso.

flowchart LR
  subgraph P[Processo P]
    p1["p1: n := k1"]-->p2["p2: (fine)"]
  end
  subgraph Q[Processo Q]
    q1["q1: n := k2"]-->q2["q2: (fine)"]
  end
  INIT["
n=0, k1=1, k2=2
P=p1, Q=q1"]-->p1
  INIT-->q1
  p1-->|"n=1"|Q
  q1-->|"n=2"|P
  Q-->q2
  P-->p2
Nota del redattore Il diagramma Mermaid illustra i due possibili scenari del primo esempio presentato a lezione: le variabili locali k1=1 e k2=2 vengono assegnate a n. Se esegue prima P, n=1; se esegue prima Q, n=2. Lo stato finale dipende dall'ordine di interleaving.

2. Atomicita e la sua importanza

Il concetto di atomicita e fondamentale. Un'azione atomica viene eseguita fino al completamento senza possibilita di interleaving con altre azioni. Il professore sottolinea che la scelta di cosa considerare atomico e cruciale per la correttezza del modello.

Incremento atomico vs non-atomico

Consideriamo due processi che eseguono n := n + 1 in concorrenza, con n = 0 iniziale.

Se l'istruzione n := n + 1 e atomica, gli unici scenari possibili sono: P poi Q (n=2), oppure Q poi P (n=2). Il risultato finale e sempre 2. C'è un solo risultato possibile.

Se l'incremento viene decomposto in tre passi — tmp := n, tmp := tmp + 1, n := tmp — il numero di scenari sale a 6 e in alcuni di essi il valore finale di n e 1 (non 2). Per esempio: P legge n=0, Q legge n=0, Q scrive n=1, P scrive n=1. Due incrementi concorrenti producono un solo incremento effettivo. Ecco la race condition classica.

Attenzione L'atomicita riguarda non solo le azioni ma anche le strutture dati. Un oggetto dati e atomico se puo trovarsi in un numero finito di stati uguale al numero di valori che puo assumere, e le operazioni cambiano atomicamente quello stato. I tipi primitivi nei linguaggi concorrenti sono generalmente atomici (ma non sempre: double in Java non lo e). I tipi astratti composti da piu oggetti semplici sono tipicamente non atomici: il problema e che un processo puo lavorare su un oggetto mentre un altro lo sta modificando, trovandosi in uno stato interno inconsistente.

Strutture non atomiche

Per i tipi di dato astratti non atomici si distinguono due tipi di stato: lo stato interno (significativo per chi definisce la classe) e lo stato esterno (significativo per chi usa la classe). La corrispondenza tra stati interni ed esterni e parziale: esistono stati interni senza corrispondente stato esterno, detti inconsistenti. In programmazione sequenziale questo non e un problema grazie all'information hiding. In programmazione concorrente, invece, e necessario introdurre meccanismi che garantiscano che i processi lavorino sempre su dati in stati consistenti.

flowchart LR
  subgraph HW["A livello macchina (stack)"]
    P1["P: push n"]-->P2["P: push #1"]
    P2-->P3["P: add"]
    P3-->P4["P: pop n"]
  end
  subgraph HW2[" "]
    Q1["Q: push n"]-->Q2["Q: push #1"]
    Q2-->Q3["Q: add"]
    Q3-->Q4["Q: pop n"]
  end

3. Correttezza: safety, liveness e fairness

La correttezza dei programmi concorrenti (anche non terminanti) e definita in termini di proprieta delle computazioni: condizioni che devono essere verificate in ogni possibile scenario.

Safety properties

Una proprieta di safety deve essere sempre vera: deve valere in ogni stato di ogni computazione. Si esprimono come invarianti della computazione. Tipicamente specificano che le "cose cattive" non devono mai accadere:

Liveness properties

Una proprieta di liveness deve diventare vera prima o poi: in ogni computazione esiste uno stato in cui la proprieta e vera. Tipicamente specificano che le "cose buone" prima o poi accadono:

Fairness

La fairness e una proprieta di liveness che richiede che qualcosa di buono accada infinite volte. Dipende dalla politica di scheduling. Il professore distingue tre livelli:

TipoDefinizione
IncondizionataOgni azione atomica incondizionata che e eligible viene eseguita prima o poi.
DeboleOgni azione atomica condizionale la cui condizione diventa e rimane vera viene eseguita prima o poi.
ForteOgni azione atomica condizionale la cui condizione diventa vera infinite volte viene eseguita prima o poi.
Per l'esame La distinzione tra safety e liveness e classica negli orali. Ricordate: safety = "qualcosa di male non accade mai" ([] !bad); liveness = "qualcosa di buono accade prima o poi" (<> good). La fairness e un tipo specifico di liveness che richiede che []<> eligible.

4. Il problema della sezione critica

Introdotto da Dijkstra nel 1965, il Critical Section Problem e uno dei problemi piu importanti e studiati nella programmazione concorrente, nel contesto della competizione fra processi. Abbiamo N processi, ciascuno esegue in ciclo infinito una sequenza di istruzioni divisibile in due parti: la sezione critica (CS) e la sezione non critica (NCS). Ogni sezione critica e tipicamente una sequenza di istruzioni che accede a un oggetto condiviso.

Il compito e progettare protocolli di ingresso (entry/pre-protocol) e di uscita (exit/post-protocol) che soddisfino le seguenti proprieta:

ProprietaDescrizione
Mutua esclusioneIstruzioni delle CS di due o piu processi non devono essere interlacciate.
Assenza di deadlockSe alcuni processi cercano di entrare nella CS, uno di loro deve prima o poi riuscirci.
Assenza di starvationSe un processo cerca di entrare nella CS, deve prima o poi riuscirci (bounded waiting).
Progresso nella CSUn processo che inizia la CS deve prima o poi finirla.
Nota del redattore La NCS non deve necessariamente progredire: un processo puo rimanere all'infinito fuori dalla CS. Questa asimmetria e una delle cause per cui il problema e piu sottile di quanto sembri.

Primo tentativo: variabile turn

Si introduce una variabile condivisa turn che funge da "permesso": se turn = 1, tocca a P; se turn = 2, tocca a Q. Entrambi i processi attendono che turn assuma il proprio valore.

Verifica tramite diagramma di stato: la mutua esclusione e soddisfatta (non si raggiunge mai p3 ∧ q3), l'assenza di deadlock e soddisfatta, ma la starvation e possibile: se un processo rimane indefinitamente nella NCS, l'altro non potra mai entrare nella CS perche turn non cambiera mai. La soluzione non e corretta.

5. Algoritmi di Dekker e Peterson

Dopo vari tentativi (secondo tentativo con variabili wantp/wantq che viola la mutua esclusione; terzo tentativo che soffre di deadlock; quarto tentativo che soffre di starvation per interleaving perfetto), si arriva alla soluzione finale.

Algoritmo di Dekker (1965)

Scoperto dal matematico olandese T.J. Dekker, combina il primo e il quarto tentativo: il diritto di insistere per entrare (non il diritto di entrare) viene passato esplicitamente tra i processi tramite la variabile turn. L'algoritmo soddisfa mutua esclusione, assenza di deadlock e assenza di starvation.

Algoritmo di Peterson (1981)

Piu conciso della soluzione di Dekker, collassa le due await in una singola condizione composta:

boolean wantp = false, wantq = false;
int turn = 1;

// Processo P
loop forever {
  p1: NCS
  p2: wantp = true
  p3: turn = 2
  p4: await (!wantq || turn == 1)
  p5: CS
  p6: wantp = false
}

// Processo Q
loop forever {
  q1: NCS
  q2: wantq = true
  q3: turn = 1
  q4: await (!wantp || turn == 2)
  q5: CS
  q6: wantq = false
}

Peterson funziona su qualsiasi architettura che fornisca load e store come istruzioni atomiche. La dimostrazione di correttezza puo essere fatta costruendo il diagramma di stato ridotto (solo le etichette rilevanti per la sincronizzazione) e verificando le tre proprieta.

6. Test-and-set e lock hardware

Il problema della sezione critica puo essere enormemente semplificato se la macchina concorrente fornisce istruzioni atomiche composte. Le principali sono:

Con test-and-set, la sezione critica per N processi diventa semplice:

int lock = 0;

// Processo P
loop forever {
  p1: NCS
  repeat {
    p2: test_and_set(lock, was_locked)
    p3: } until (was_locked == 0)
  p4: CS
  p5: lock = 0
}

Questo meccanismo realizza un lock elementare: due operazioni atomiche, acquire e release. In Java, il blocco synchronized fornisce esattamente questa astrazione: due blocchi synchronized(lock) sullo stesso oggetto non possono essere interlacciati.

Idea chiave Le istruzioni hardware atomiche come test-and-set e compare-and-swap sono la base su cui vengono costruiti tutti i meccanismi di sincronizzazione di piu alto livello: semafori, lock, monitor. Il professore sottolinea che la scelta del giusto livello di atomicita e cruciale.

Algoritmo del panificio (Bakery algorithm)

Un'alternativa per N processi basata sui "ticket": ogni processo prende un numero e attende il proprio turno. Problema: i contatori turn e num crescono monotonamente, con possibile overflow aritmetico.

7. Verifica formale e LTL

Il testing rivela la presenza di errori solo per scenari selezionati; la verifica dimostra che una proprieta vale per tutti gli scenari possibili. Servono tecniche formali.

La logica temporale lineare (LTL), introdotta da Amir Pnueli nel 1977, permette di esprimere proprieta di correttezza che devono valere in tutti gli stati di tutte le computazioni. I due operatori fondamentali sono:

Esempi di specifica LTL

ProprietaFormula LTL
Mutua esclusione[] !(p3 ∧ q3)
Assenza di starvation per P[](p2 → <> p3)
1-bounded overtakingtryp → (!CSq) W (CSq W ((!CSq) W CSp))

Operatori derivati

Per l'esame Lezione importante: l'overtaking. La proprieta "1-bounded overtaking" dice che da quando P cerca di entrare nella CS, Q puo entrare al massimo una volta prima di P. Si esprime con W (weak until). E un esempio di come LTL possa specificare proprieta piu fini della semplice assenza di starvation.

8. Model checking: SPIN, JPF, TLA+

Il model checking e la tecnica piu importante per la verifica automatica di proprieta di correttezza dei sistemi concorrenti. La strategia: esplorare esaustivamente l'intero spazio degli stati del sistema e verificare se certe proprieta sono soddisfatte. Se il sistema viola la proprieta, il model checker produce una traccia (controesempio) utile per identificare i bug.

SPIN e PROMELA

SPIN e un model checker ampiamente usato in ambito accademico e industriale. PROMELA e il linguaggio per scrivere i modelli. Permette di specificare processi concorrenti con un insieme limitato di costrutti, sufficienti per costruire modelli astratti del sistema.

Java Path Finder (JPF)

JPF e un model checker specializzato per programmi Java, sviluppato dalla NASA per software critico. E' una JVM speciale che esegue il programma lungo tutti i possibili scenari (percorsi di esecuzione), verificando violazioni di proprieta come deadlock ed eccezioni non catturate. Se trova un errore, JPF riporta l'intera esecuzione che porta all'errore.

TLA+ e PlusCal

TLA+ (Temporal Logic of Actions) e un linguaggio di specifica formale introdotto da Leslie Lamport. Permette di descrivere sia le proprieta di correttezza desiderate (il "cosa") sia il progetto del sistema (il "come"). Usato in contesti reali come Amazon Web Services. PlusCal (ex +CAL) e un linguaggio algoritmico basato su TLA+ che viene tradotto in specifiche TLA+ e verificato con il model checker TLC.

Nota del redattore Il professore accenna che il model checking e stato adottato da Intel dopo il bug del Pentium nel 1994 e dalla NASA dopo l'incidente del Mars Polar Lander nel 1999. Il problema principale del model checking e l'esplosione dello spazio degli stati: tecniche come la costruzione incrementale, la riduzione simbolica e l'uso di variabili con dominio limitato aiutano a gestire grafi con milioni di stati.

9. Semafori: tipo di dato e operazioni

I semafori sono stati introdotti da Dijkstra nel 1968 come costrutto generale-purpose potente ma semplice, in grado di risolvere quasi qualsiasi problema di mutua esclusione e sincronizzazione. L'analogia usata dal professore e quella del semaforo stradale: blocca e sblocca il flusso dei processi (le auto) a seconda della necessita.

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

Si inizializza con S = (k, {}), dove k >= 0 e il valore iniziale di S.V e {} l'insieme vuoto per S.L. Fornisce due operazioni atomiche:

Operazione wait(S) — anche chiamata P(S)

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 (semaforo "verde"), viene semplicemente decrementato. Altrimenti (semaforo "rosso"), il processo viene bloccato e aggiunto a S.L.

Operazione signal(S) — anche chiamata V(S)

signal(S) = <
  if (S.L = {})
    S.V = S.V + 1
  else
    let q = elemento arbitrario di S.L
    S.L = S.L - {q}
    q.state = ready
>

Se nessun processo e in attesa, il valore del semaforo viene incrementato. Altrimenti viene sbloccato un processo q dalla coda S.L.

Idea chiave Invariante del semaforo: sia k il valore iniziale di S.V, #signal(S) il numero di signal completati e #wait(S) il numero di wait completati (un processo bloccato durante wait NON ha completato l'operazione). Allora: S.V >= 0 e S.V = k + #signal(S) - #wait(S). L'invariante e fondamentale per dimostrare la correttezza dei programmi che usano semafori.

10. Classificazione dei semafori

I semafori si classificano secondo diversi criteri. Il professore presenta tre dimensioni principali:

TipoValori di S.VUso tipico
Mutex (binario)0, 1Mutua esclusione (lock)
Counting (general)Qualunque >= 0Gestione risorse multiple
EventInizializzato a 0Sincronizzazione (segnale)

Il semaforo mutex (o binario) puo assumere solo i valori 0 e 1. Il counting puo assumere qualunque valore >= 0. L'event semaphore e inizializzato a 0 e usato esclusivamente per sincronizzazione.

Semaforo forte: S.L e una coda FIFO (non un insieme). Il primo processo bloccato e il primo a essere sbloccato. Proprieta importante: no starvation — per qualsiasi numero N di processi, la starvation e impossibile.

Semaforo debole: S.L e un insieme. La scelta del processo da sbloccare e arbitraria. Non garantisce assenza di starvation.

I busy-wait semaphore non hanno S.L. L'operazione wait implementa un'attesa attiva (spinning):

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

Si perde la garanzia di assenza di starvation. Sono appropriati in sistemi multiprocessore dove il processo in attesa ha il proprio processore e non spreca CPU utilizzabile da altri, o in sistemi a bassa contesa.

Per l'esame Sapere spiegare la differenza tra semaforo forte e debole, e la differenza tra wait/signal sui semafori e waitC/signalC sulle variabili di condizione nei monitor, e una domanda classica.

11. Semafori per mutua esclusione e sincronizzazione

I semafori sono costrutti primitivi utilizzabili come mattoncini di basso livello per risolvere quasi qualsiasi problema di interazione tra processi in architettura a memoria condivisa. Due grandi famiglie di utilizzo:

Mutua esclusione

Il caso piu semplice: un semaforo binario inizializzato a 1 funge da lock. Ogni processo esegue wait(S) prima di entrare nella CS e signal(S) dopo averla lasciata. La soluzione per due processi e banale; funziona anche per N processi, ma senza garanzia di assenza di starvation se il semaforo e debole.

Sincronizzazione (event semaphore)

Un semaforo inizializzato a 0 puo essere usato per inviare/ricevere un segnale temporale. Esempio classico: il merge sort concorrente.

Supponiamo di avere tre processi: sort1 ordina la prima meta dell'array e fa signal(S1); sort2 ordina la seconda meta e fa signal(S2); merge fa wait(S1) e wait(S2) e poi fonde le due meta. I semafori S1 e S2 sono event semaphore (inizializzati a 0). Il processo merge si blocca automaticamente fino a quando entrambi i sort non hanno completato.

flowchart LR
  A["Array A"]-->P1
  P1["sort1: ordina 1a meta"]-->|"signal(S1)"|S1
  A-->P2
  P2["sort2: ordina 2a meta"]-->|"signal(S2)"|S2
  S1-->R1["wait(S1)"]
  S2-->R2["wait(S2)"]
  R1-->M
  R2-->M
  M["merge: fonde le due meta"]-->OUT["Array A ordinato"]

12. Producer-Consumer con semafori

Il problema Producer-Consumer e un esempio classico di problema di ordine di esecuzione. Due tipi di processi: i produttori creano dati e li inviano ai consumatori; i consumatori ricevono dati e li elaborano. Pattern onnipresente in informatica: browser-server, tastiera-SO, word processor-stampante, ecc.

Buffer infinito

Con buffer infinito c'e una sola interazione da sincronizzare: il consumatore non deve prelevare da un buffer vuoto. Invariante: nAvailItems.V = #elementi nel buffer. Si usa un resource semaphore availItems inizializzato a 0.

// Produttore
loop forever {
  p1: Item el = produce()
  p2: append(buffer, el)
  p3: signal(availItems)
}

// Consumatore
loop forever {
  q1: wait(availItems)
  q2: Item el = take(buffer)
  q3: consume(el)
}

Buffer limitato

Con buffer limitato (dimensione N), si aggiunge un'altra sincronizzazione: il produttore non deve scrivere in un buffer pieno. Si introducono due semafori split: availItems (elementi disponibili) e availPlaces (posti disponibili), inizializzato a N. Invariante: availItems + availPlaces = N.

Produttori e consumatori multipli

Se ci sono piu produttori e piu consumatori, le operazioni di append e take sul buffer non sono atomiche. Serve un semaforo mutex aggiuntivo per garantire la mutua esclusione sull'accesso al buffer condiviso. L'ordine delle operazioni e importante: prima si attendono i semafori di sincronizzazione, poi si acquisisce il mutex.

// Produttore
loop forever {
  p1: Item el = produce()
  p2: wait(availPlaces)   // aspetta un posto libero
  p3: wait(mutex)         // entra in sezione critica
  p4: append(buffer, el)
  p5: signal(mutex)
  p6: signal(availItems)  // segnala un nuovo elemento
}

// Consumatore
loop forever {
  q1: wait(availItems)    // aspetta un elemento
  q2: wait(mutex)
  q3: Item el = take(buffer)
  q4: signal(mutex)
  q5: signal(availPlaces) // segnala un posto libero
  q6: consume(el)
}
Attenzione L'ordine delle operazioni nei produttori/consumatori multipli e fondamentale. Se si scambia l'ordine di wait(mutex) e wait(availPlaces), si puo incorrere in deadlock.

13. Deadlock e Filosofi a Cena

Il problema dei Filosofi a Cena (Dining Philosophers) e stato originato da un problema d'esame di Dijkstra nel 1971 su 5 computer in competizione per 5 unita a nastro, ed e stato trasformato nella versione attuale da Tony Hoare. Cinque filosofi siedono a un tavolo con 5 piatti e 5 forchette; nel centro c'e una ciotola di spaghetti. Ogni filosofo alterna pensiero e mangiare, ma per mangiare servono due forchette (la sinistra e la destra), e puo raccoglierne solo una alla volta.

Primo tentativo: un semaforo per forchetta

Ogni forchetta e modellata come un semaforo binario. Purtroppo questa soluzione deadlocka: se tutti i filosofi raccolgono la forchetta sinistra prima che qualcuno provi a raccogliere la destra, nessuno puo mai mangiare.

semaphore[] fork = [1, 1, 1, 1, 1];

// Filosofo i
loop forever {
  think()
  wait(fork[i])             // forchetta sinistra
  wait(fork[(i+1) % N])     // forchetta destra
  eat()
  signal(fork[i])
  signal(fork[(i+1) % N])
}

Condizioni di Coffman per il deadlock

Il deadlock si verifica solo quando tutte e quattro le seguenti condizioni sono soddisfatte simultaneamente:

  1. Mutua esclusione: una risorsa non puo essere usata da piu processi contemporaneamente.
  2. Hold and wait: processi che gia detengono risorse possono richiederne di nuove.
  3. No preemption: nessuna risorsa puo essere rimossa forzatamente da un processo che la detiene.
  4. Circular wait: due o piu processi formano una catena circolare, dove ciascuno attende una risorsa detenuta dal successivo.

Soluzioni

Il professore presenta due soluzioni classiche:

Soluzione 1: ticket (N-1 posti). Si limita il numero di filosofi che possono mangiare simultaneamente a N-1 (4 su 5), introducendo un semaforo ticket inizializzato a N-1. Questo garantisce liveness.

Soluzione 2: rompere la catena (ordinamento delle forchette). L'ultimo filosofo (o tutti) raccolgono le forchette in ordine inverso: prima la destra, poi la sinistra. Equivalentemente, si assegna un ordinamento totale alle risorse (alle forchette) e si acquisiscono sempre nello stesso ordine. Questo rende impossibile il circular wait.

semaphore[] fork = [1, 1, 1, 1, 1];

// Filosofo i — soluzione con ordinamento
int first = min(i, (i+1) % N);
int second = max(i, (i+1) % N);

loop forever {
  think()
  wait(fork[first])    // forchetta con indice minore
  wait(fork[second])   // forchetta con indice maggiore
  eat()
  signal(fork[first])
  signal(fork[second])
}
Idea chiave Regola generale per evitare deadlock con lock multipli: assegnare un ordinamento totale ai lock e acquisirli sempre nello stesso ordine. Questo elimina la possibilita di circular wait, che e la condizione necessaria (insieme alle altre tre) per il deadlock.

Deadlock detection e recovery

Nelle basi di dati, il deadlock viene gestito con detection and recovery: si analizza il grafo delle dipendenze "is-waiting" cercando cicli; se trovato, si seleziona una vittima e si abortisce la transazione. In JVM non esiste alcun meccanismo automatico: se i thread deadlockano, la soluzione e solo lo shutdown dell'applicazione. Esiste solo supporto "post-mortem" per la diagnosi.

14. Readers-and-Writers

Il problema dei lettori e scrittori (Courtois, Heymans, Parnas, 1971) e simile alla mutua esclusione, ma divide i processi in due classi: i lettori (che devono escludere gli scrittori ma non altri lettori) e gli scrittori (che devono escludere sia lettori che altri scrittori). E l'astrazione dell'accesso a un database: non c'e pericolo se piu processi leggono simultaneamente, ma la scrittura deve essere in mutua esclusione.

Invarianti del problema

nR >= 0
nW = 0 oppure nW = 1
(nR > 0 -> nW = 0) AND (nW = 1 -> nR = 0)

dove nR e il numero di lettori e nW il numero di scrittori.

Primo tentativo (over-constrained)

Usare un singolo semaforo rw come lock esclusivo per tutti. Funziona ma serializza anche i lettori, perdendo il vantaggio della lettura concorrente.

Soluzione corretta

I lettori non usano lo stesso lock degli scrittori. Si introduce un mutexR per proteggere il contatore dei lettori nr. Il semaforo rw viene acquisito solo dal primo lettore (quando nr == 0) e rilasciato solo dall'ultimo lettore (quando nr == 0 dopo il decremento).

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

// Lettore
loop forever {
  wait(mutexR)
  if (nr == 0) wait(rw)
  nr = nr + 1
  signal(mutexR)
  Item el = read(dbase)
  wait(mutexR)
  nr = nr - 1
  if (nr == 0) signal(rw)
  signal(mutexR)
}

// Scrittore
loop forever {
  wait(rw)
  write(dbase, el)
  signal(rw)
}
Attenzione Questa soluzione da priorita ai lettori: se arrivano continuamente nuovi lettori, gli scrittori possono soffrire di starvation. Esistono varianti che danno priorita agli scrittori o che garantiscono equita.

15. Monitor: astrazione di coordinamento

I semafori sono potenti ma di basso livello: programmi soggetti a errori, difficili da usare in programmi complessi. Per questo si introducono i monitor, un costrutto di piu alto livello introdotto da Brinch Hansen (1973) e generalizzato da Hoare (1974).

Un monitor e un'astrazione di dati della programmazione concorrente che incapsula:

E' una generalizzazione del concetto di kernel dei sistemi operativi (versioni decentrate del monolitico kernel) e del concetto di oggetto nella OOP (classi che incapsulano dati + operazioni + politica di sincronizzazione/mutex).

Proprieta fondamentali

Sintassi astratta

monitor MonitorName {
  // dichiarazione variabili permanenti
  // inizializzazione
  // procedure (o entries)
}

Esempio minimo: contatore thread-safe.

monitor Counter {
  int count;

  procedure inc() {
    count = count + 1
  }

  procedure getValue(): int {
    return count
  }
}

Il professore sottolinea che la mutua esclusione e implicita: non richiede l'uso di semafori o altri meccanismi da parte del programmatore. Le operazioni sono eseguite atomicamente le une rispetto alle altre. Non esiste una coda esplicita associata a una procedura del monitor: questo puo portare a starvation.

16. Variabili di condizione e discipline di segnalazione

Le variabili di condizione sono tipi di dato primitivi che permettono di sospendere (waitC) e riprendere (signalC) processi all'interno di un monitor, rappresentando condizioni (eventi) sullo stato del monitor in attesa di essere soddisfatte. Ogni variabile di condizione e associata a una coda FIFO di processi bloccati.

Operazioni

OperazioneComportamento
waitC(cond)Sospende il processo e rilascia il lock del monitor. Il processo viene aggiunto alla coda della condizione.
signalC(cond)Se la coda non e vuota, sblocca il primo processo in attesa (lo porta a ready).
signalAllC(cond)Sblocca tutti i processi in attesa sulla condizione.
emptyC(cond)Controlla se la coda e vuota.

Discipline di segnalazione

Il professore spiega che quando un processo esegue una signalC, si pone il problema di chi debba continuare: il processo che segnala o quello che viene risvegliato? Le tre discipline classiche sono:

Signal and Continue (nonpreemptive): il processo che segnala continua l'esecuzione; il processo risvegliato eseguira in un momento successivo, quando potra riacquisire il lock del monitor. Precedenze: E < W < S (processi bloccati sull'entry < processi in attesa sulla condizione < processo che segnala). E' la disciplina piu comune (usata da Java, per esempio).

Signal and Wait (preemptive): il processo risvegliato viene eseguito immediatamente; il processo che ha segnalato si mette in attesa, competendo con gli altri processi per il lock. Precedenze: E = S < W.

Signal and Urgent Wait (Immediate Resumption Requirement): come Signal and Wait, ma il processo che ha segnalato ha priorita rispetto ai processi in attesa sul lock del monitor. Precedenze: E < S < W. E' la disciplina classica per i monitor nella letteratura originale (Hoare).

Conseguenza pratica: if vs while

A seconda della disciplina di segnalazione, il test sulla condizione dopo un waitC puo essere un if o un while: l'uso del while e sempre sicuro ed e necessario con Signal and Continue perche la condizione potrebbe essere cambiata da un altro processo tra il risveglio e la riacquisizione del lock.

// Con Signal and Wait (preemptive): if basta (ma while e sempre sicuro)
if (!B) waitC(cond)

// Con Signal and Continue: while necessario!
while (!B) waitC(cond)

17. Monitor: esempi pratici

Il professore dedica ampio spazio a mostrare come i monitor vengono usati per realizzare componenti di sincronizzazione riutilizzabili.

Synchronized Cell

Una cella che permette a un processo di depositare un valore e a un altro di prelevarlo, con sincronizzazione automatica:

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

Semaforo implementato con monitor

BoundedBuffer (Producer-Consumer) 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
  }
}

Read-Write Lock con monitor

monitor RWLock {
  int nr = 0, 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 || nw > 0)
      waitC(okToWrite)
    nw = nw + 1
  }

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

Implementazione dei monitor tramite semafori

I monitor possono essere realizzati usando semafori (il professore mostra sia la semantica Signal and Continue che Signal and Wait). L'idea base: un semaforo mutex per la mutua esclusione; per ogni variabile di condizione, un semaforo condsem e un contatore condcount.

Nota del redattore I monitor consentono di costruire componenti di coordinamento riutilizzabili: latch, barriere, rendez-vous, message box, blackboard, event services. Sono un tassello fondamentale nella progettazione di sistemi concorrenti perche separano la politica di sincronizzazione dalla logica applicativa.

18. Confronto: Semafori vs Variabili di Condizione

Il professore dedica una slide specifica al confronto, che e importante per evitare confusioni:

SemaforoVariabile di condizione (nei monitor)
wait(S) puo non bloccare (se S.V > 0)waitC(cond) blocca sempre
signal(S) ha sempre effetto (incrementa S.V o sblocca un processo)signalC(cond) non ha effetto se la coda e vuota
signal(S) sblocca un processo arbitrario (nei semafori deboli)signalC(cond) sblocca il processo in testa alla coda FIFO
Il processo sbloccato da signal puo riprendere immediatamenteIl processo sbloccato da signalC deve attendere che il processo segnalante esca dal monitor (a seconda della disciplina)
Per l'esame Domanda classica: "Qual e la differenza tra semafori e variabili di condizione?" Rispondete con questa tabella. Aggiungete che i semafori sono un meccanismo di coordinamento esterno ai dati, mentre le variabili di condizione operano all'interno del monitor e sono strettamente legate allo stato incapsulato.

Verifica le tue conoscenze

Qual e la differenza fondamentale tra safety e liveness? Fate un esempio per ciascuna.

Una proprieta di safety deve essere sempre vera ("qualcosa di male non accade mai"), come la mutua esclusione: [] !(p3 ∧ q3). Una proprieta di liveness deve prima o poi diventare vera ("qualcosa di buono accade"), come l'assenza di starvation: [](p2 → <> p3). La verifica di safety e piu semplice (basta trovare uno stato che viola la proprieta), mentre la liveness richiede di analizzare tutti gli scenari possibili.

Spiegate la regola generale per evitare deadlock con lock multipli.

Assegnare un ordinamento totale ai lock e acquisirli sempre nello stesso ordine in tutti i processi. Questo rende impossibile il circular wait, una delle quattro condizioni necessarie di Coffman per il deadlock. L'esempio classico e la soluzione dei Filosofi a Cena con ordinamento delle forchette: ogni filosofo prende prima la forchetta con indice minore, poi quella con indice maggiore.

Qual e la differenza tra un semaforo mutex (binario) e un event semaphore?

Il mutex e inizializzato a 1 e usato per la mutua esclusione (lock). L'event semaphore e inizializzato a 0 e usato per la sincronizzazione (segnalazione). Con un mutex, la prima wait decrementa da 1 a 0; con un event semaphore, la prima wait blocca il processo perche il valore e gia 0. Un mutex puo essere visto come un semaforo counting ristretto a {0,1}.

Descrivete le tre discipline di segnalazione nei monitor e la differenza principale.

Signal & Continue (E < W < S): chi segnala continua, il risvegliato aspetta. Signal & Wait (E = S < W): il risvegliato parte subito, il segnalante aspetta. Signal & Urgent Wait (E < S < W): come S&W ma il segnalante ha priorita sugli altri in attesa di entrare. La scelta influenza se usare if o while per il test della condizione: con S&C il while e obbligatorio.

Cosa dice l'invariante del semaforo e che cosa implica?

Dato k valore iniziale di S.V, #signal il numero di signal eseguiti e #wait il numero di wait completati (un processo bloccato non conta come completato): S.V = k + #signal - #wait e S.V >= 0. L'invariante garantisce che il valore del semaforo non diventi mai negativo e che il numero di signal meno il numero di wait riproduca esattamente il valore corrente, permettendo ragionamenti formali sulla correttezza.

Perche il testing tradizionale non basta per i programmi concorrenti?

Perche lo stesso input puo produrre output diversi a seconda dello scenario di interleaving. Alcuni scenari possono essere corretti, altri no. Eseguire il programma una volta testa un solo scenario, e scelte diverse dello scheduler possono mascherare i bug (fenomeno degli heisenbug). Servono tecniche formali come il model checking o la dimostrazione induttiva di invarianti. Inoltre, il codice di test stesso puo introdurre artefatti di temporizzazione che mascherano i bug.

Come si risolve il problema dei Filosofi a Cena con il metodo dei ticket?

Si introduce un semaforo ticket inizializzato a N-1 (quindi 4 per 5 filosofi). Ogni filosofo deve eseguire wait(ticket) prima di iniziare a prendere le forchette e signal(ticket) dopo aver finito di mangiare. Questo limita il numero di filosofi che possono mangiare simultaneamente, evitando la condizione di circular wait. La soluzione soddisfa tutte le proprieta (mutua esclusione, assenza di deadlock, assenza di starvation).