Documenti di Didattica
Documenti di Professioni
Documenti di Cultura
Informazioni generali
Si tratta di un corso da 6 CFU, svolto al II anno del CdL Triennale in Informatica.
Aver seguito con profitto un corso sulla Programmazione sequenziale ed un corso
sull’Architettura degli Elaboratori è essenziale per la comprensione degli argomenti
esposti nel corso.
• Materiali didattici Libro di testo: M. Ben-Ari, Principles of Concurrent
and Distributed Programming, [2]. Seguiremo abbastanza fedelmente i primi
8 capitoli di questo libro, con qualche deviazione che sarà di volta in volta
segnalata. Il libro di testo è accompagnato da slides, messe a disposizione
dall’autore, e reperibili per esempio dalla pag. Moodle del corso (v. sotto).
Tali slide sono piuttosto schematiche e non si intendono sostitutive del libro di
testo. Verranno via via anche messe a disposizione sulla piattaforma Moodle
delle note delle lezioni, a cura del docente.
Un altro testo di utile consultazione è quello di G. R. Andrews, [1]. Alcune
copie di [2] e [1] sono disponibili per la consultazione presso la biblioteca di
Matematica e Informatica, all’Ulisse Dini.
1
1 Una panoramica
Questa prima lezione serve ad introdurre alcuni importanti concetti in maniera infor-
male, e allo stesso tempo a fissare un po’ di terminologia. I concetti che introduciamo
ora verranno ripresi, ampliati e formalizzati nelle prossime lezioni.
Abbiamo una comprensione ragionevolmente accurata di cosa sia un programma
sequenziale: esso risulta definito da insieme di dati e da un insieme di istruzioni.
Dal punto di vista del programmatore, le istruzioni che compongono un programma
sequenziale vengono eseguite dalla CPU sequenzialmente, cioè strettamente una dopo
l’altra.
La programmazione concorrente entra in gioco quando dobbiamo gestire il pa-
rallelismo, ovvero l’esecuzione sullo stesso sistema di più attività, che vengono por-
tate avanti allo stesso tempo, ovvero in parallelo. Ciascuna di tali attività è affi-
data ad un programma sequenziale dedicato, che viene detto processo. In prima
approssimazione, dunque
processo = programma sequenziale
programma concorrente = insieme di processi {P1 , ..., Pn }
dove i processi sono eseguiti in parallelo sulla stessa macchina o sistema. Per fare
un esempio concreto, quando accendiamo un PC, ogni finestra corrisponde in gene-
rale ad un processo, su cui è eseguita una particolare applicazione (browser, word
processor, ....). L’impressione che ne abbiamo, come utenti, è che questi processi
vengano eseguiti in parallelo. Questa situazione si ripete in molti altri scenari, dallo
smartphone al software di controllo su un’automobile. La concorrenza, dunque, è
ubiqua.
Dobbiamo a questo punto introdurre una importante distinzione tra due tipi di
scenari (si veda la Figura 1):
(a) parallelismo vero, la situazione in cui ogni processo viene eseguito su una diversa
CPU, e dunque si ha una reale simultaneità nell’esecuzione;
(b) parallelismo simulato, la situazione in cui tutti i processi vengono eseguiti a
turno sulla medesima CPU, e dunque si ha una simultaneità solo percepita.
Tra questi due estremi si possono naturalmente collocare delle situazioni interme-
die, in cui ci sono più CPU, a ciascuna delle quali è assegnato un insieme di processi.
Noi ci porremo in una situazione più generale, che comprende le due date sopra e
altre ancora, e che potremmo riassumere col motto seguente
concorrenza = parallelismo potenziale.
2
Figura 1: Parallelismo vero (a sinistra) e simulato (a destra).
Per essere più precisi, il nostro obiettivo è quello di introdurre un modello astratto
di esecuzione dei programmi concorrenti, che permetta al programmatore di trattare
in maniera unificata il caso (a) e quello (b), come pure altre situazioni intermedie
e diverse. Introdurremo in maniera rigorosa il modello nella prossima lezione. In
prima battuta, possiamo dire che in tale modello il comportamento di un programma
concorrente viene visto come l’esecuzione di una sequenza di istruzioni, sequenza
ottenuta intercalando in modo arbitrario le istruzioni dei singoli processi. Si parla di
modello interleaving, dall’Inglese
interleaving = intercalare, alternare.
Grazie a questo modello, come si vedrà, saremo in grado scrivere programmi concor-
renti, e, cosa importantissima, ragionare sulla loro correttezza, indipendentemente
dal fatto che essi poi vengano eseguiti nella situazione (a) o in quella (b) di cui sopra:
i programmi funzioneranno correttamente in ambedue i casi. Le situazioni (a) e (b)
diventano cosı̀ intercambiabili, dal punto di vista del programmatore. Se siamo in (a)
(parallelismo vero), la comprensione di alcuni aspetti potrà essere facilitata se assu-
miamo che le istruzioni siano eseguite sequenzialmente, in un ordine arbitrario, come
in (b). Viceversa, se siamo in (b) (parallelismo simulato), potremo ”far finta” che i
vari processi siano davvero eseguiti in parallelo, se questo facilita la comprensione
del sistema. Vediamo un esempio di quest’ultima situazione.
Esempio 1. Già con i primi sistemi operativi, negli anni ’60, si pose il problema di
dover impiegare la CPU in modo efficiente nella gestione dell’Input-Output (I/O). Il
problema origina dal fatto che esiste una enorme differenza di velocità di elaborazione
tra la CPU da una parte e gli utenti umani e i dispositivi elettromeccanici di I/O
(es. tastiere, stampanti) dall’altra.
Consideriamo l’immissione di caratteri da una tastiera. Con qualche approssi-
mazione, possiamo assumere che questa avvenga, da parte di un essere umano, alla
velocità di circa 1 carattere al secondo. Ovvero, secondo l’unità di tempo più vicina
3
alla CPU, di 1 carattere ogni 109 ns (1 ns = 10−9 s). Possiamo stimare che una CPU
impieghi circa 100 ns per elaborare un carattere (ad esempio, scriverlo in un’area di
memoria corrispondente allo schermo). Se dedicassimo una CPU solo alla gestione
dell’I/O, essa sarebbe enormemente sottoutilizzata: per fare un paragone, è più o
meno come se un essere umano dovesse dedicare ad una certa attività 100 s del suo
tempo ogni 109 s ≈ 32 anni. Quello che succede normalmente è che non esiste una
CPU dedicata all’I/O. Piuttosto, esiste una singola CPU che dedica la stragrande
maggioranza del suo tempo ad eseguire l’elaborazione principale, mentre solo una
minuscola frazione del suo tempo viene ogni tanto ’rubata’ e dedicata alla gestione
dell’I/O (si veda la Figura 2). In questo modo, l’esecuzione del programma principa-
le, chiamiamolo Elaborazione (E), non ne risente in maniera apprezzabile, rispetto
alla alla situazione in cui l’I/O ha un processore dedicato.
Qual è il legame di questo con la concorrenza? In teoria, essendoci qui solo
computazione sequenziale, sarebbe possibile scrivere i programmi che costituiscono
l’elaborazione principale in modo tale che periodicamente, es. ogni 10−3 s, essi saltino
ad una routine di gestione dell’I/O, e la eseguano. In pratica però, questo costitui-
rebbe una complicazione inaccettabile: ogni applicazione dovrebbe interfacciarsi con
l’I/O, e i singoli programmatori sarebbero tenuti a conoscere i dettagli dell’I/O. Il
sistema risulterebbe molto difficile da comprendere, gestire e modificare.
Per un programmatore, è assai più semplice considerare la gestione dell’I/O ed
Elaborazione come due processi autonomi, che vengono eseguiti concorrentemente
(parallelismo simulato, v. Figura 3). In pratica, il processo Elaborazione rimane in
esecuzione per la maggior parte del tempo. Quando un carattere viene immesso dalla
tastiera, esso genera una interruzione, cioè un segnale diretto alla CPU, in seguito al
quale essa sospende, per il tempo strettamente necessario, l’esecuzione del processo
Elaborazione per mettere in esecuzione il processo I/O.
4
I/O
Elaborazione
6 6
start I/O end I/O
time →
5
è diviso in intervalli (time-slicing): in ciascun intervallo, un processo tra quelli in
attesa di essere eseguiti viene scelto e messo in esecuzione; allo scadere dell’intervallo,
il processo viene messo di nuovo in uno stato di attesa, e se ne mette in esecuzione
un altro, e cosı̀ via. L’entità che prende le decisioni su quale processo mettere in
esecuzione ad ogni intervallo si chiama scheduler, ed è una parte fondamentale di ogni
sistema operativo. La politica di scheduling può dipendere da vari fattori, quali: la
priorità (importanza) di certi processi rispetto ad altri; la fairness, cioè la garanzia
che ciascun processo venga messo in esecuzione sufficientemente spesso; o anche la
necessità di dover reagire in maniera rapida a input provenienti dall’esterno (si pensi
al sistema di controllo dei freni di un’automobile). In ogni caso, in fase di scrittura del
software, è sostanzialmente impossibile per il programmatore prevedere quale sarà
la precisa politica di scheduling adottata dal sistema, e quale particolare sequenza
di esecuzione essa produrrà. Ai fini del problema dell’interazione tra processi (si
veda la prossima sezione), è importante notare che nei sistemi multitasking i processi
possono comunicare attraverso la memoria condivisa del sistema.
Nei sistemi multiprocessori, il parallelismo è reale. Al giorno d’oggi, non è
difficile trovare più CPU anche su un comune PC; quasi sempre, poi, si trovano pro-
cessori dedicati alla grafica e all’elaborazione in virgola mobile. I processi possono
come nel caso precedente comunicare attraverso la memoria condivisa del sistema.
Tuttavia, possono ora sorgere problemi di contention: più processori possono cercare
di accedere alla stessa area di memoria nello stesso intervallo di tempo, ovvero pos-
sono contendersi la risorsa rappresentata dalla memoria. Una contention elevata può
portare ad attese e quindi ad una seria inefficienza.
Nei sistemi multicomputer non è prevista in genere una memoria condivisa. I
processi devono quindi comunicare scambiandosi messaggi, che viaggiano attraverso
le interconnessioni (es. bus) tra le CPU. Anche questo può generare problemi di con-
tention. I multicomputer trovano impiego, per esempio, nelle applicazioni di calcolo
numerico che operano su grandi vettori o matrici. In tali casi, può risultare vantag-
gioso suddividere il problema da risolvere in sottoproblemi più o meno indipendenti,
da assegnare a processi diversi. I vari processi possono risolvere i sottoproblemi in
parallelo, sincronizzandosi di tanto in tanto. Per esempio, volendo calcolare il pro-
dotto due grandi matrici, C = A × B, si potrebbe inizialmente distribuire a ciascun
processore l’intera matrice A e una distinta colonna di B; ogni processore potrà quin-
di calcolare una intera riga del prodotto C in modo autonomo e in parallelo con gli
altri.
I sistemi distribuiti si basano su reti di comunicazione, locali o su larga scala
(es. Internet), in cui ogni nodo rappresenta un calcolatore distinto. Alcuni nodi
poi possono essere dedicati solo allo smistamento dei messaggi che viaggiano sulla
6
Figura 5: Multicomputer (a sinistra) e sistema distribuito (a destra).
7
Esempio 2. Due client, che rappresentano due utenti che accedono ad un sistema
di prenotazioni online di voli aerei, accedono ad uno stesso DB. Il client Ci , per
i = 1, 2, esegue una sequenza di comandi di questo tipo:
1. leggi DB[d], ovvero disponibilità e prezzo per la data d;
3. xi ← xi + 1;
4. c ← xi .
Supponiamo che inizialmente c = 0 e che due persone entrino ai due ingressi, in
momenti molto vicini. L’esecuzione concorrente che ne potrebbe risultare potrebbe
essere:
1,1’,2,2’,3,3’,4,4’.
8
È facile vedere che il valore finale di c è 1, mentre il valore corretto sarebbe 2. Diverso
sarebbe se le due persone entrassero a una certa distanza di tempo l’una dall’altra: in
tal caso, il primo processo verrebbe interamente eseguito prima dell’altro, e il valore
finale di c sarebbe corretto, cioè 2.
Gli esempi precedenti mettono in luce la difficoltà di programmare in maniera
corretta l’interazione tra processi. Esso introduce anche un aspetto essenziale relativo
a tale difficoltà:
nondeterminismo = un programma concorrente, eseguito due volte in
momenti diversi, può dare luogo a due risultati differenti, anche partendo
da condizioni iniziali identiche.
Nel caso dei tornelli, per esempio, non è possibile prevedere in anticipo se verrà ese-
guita la sequenza ’maligna’ vista prima, oppure la sequenza benigna 1,2,1’,3,4,2’,3’,4’,
che porta al risultato corretto c = 2. Questo dipenderà da vari fattori (differenza
di tempo tra gli ingressi, velocità relativa dei processori, politiche di scheduling, se
siamo in un multitasking, ...), che sono totalmente fuori dal controllo del progettista,
nel momento in cui egli scrive il programma. Il nondeterminismo è ciò che rende
il debugging dei programmi concorrenti particolarmente arduo. Nel caso sequenzia-
le, siamo abituati a pensare al debugging come ad un procedimento incrementale.
Scoperto un bug (errore):
1. analizza il codice e apporta le modifiche ritenute necessarie;
3. il risultato è quello atteso? Se sı̀, questo bug è stato eliminato; se no, torna a
1.
Questa metodologia non funziona con i programmi concorrenti, per via del nondeter-
minismo: se nella seconda esecuzione osserviamo un risultato che è quello atteso (es.
c = 2), potrebbe voler dire non che il bug è stato eliminato, ma semplicemente che
la seconda volta è stata eseguita una sequenza ’benigna’. Né va troppo meglio se,
invece di una volta, eseguiamo il programma modificato molte volte: in programmi
concorrenti complessi, certe sequenze di esecuzione ’maligne’ ma improbabili, non
saltano fuori durante i test, per poi magari manifestarsi al momento dell’esecuzione
vera e propria, con risultati a volte disastrosi (e assai costosi, si pensi al fallito lancio
del razzo Ariane 5 nel 1996 [3], v. Figura 6).
In definitiva, l’unico approccio possibile quando si programmano sistemi concor-
renti è quello di ritenere che tutte le sequenze di esecuzione possibili, secondo il codice
9
Figura 6: Il lancio del razzo Ariane 5, 1996.
dato, possono aver luogo. Il programma sarà considerato corretto solo se il risultato
è quello atteso in tutte le sequenze possibili:
10
• Sul modello dato, definiremo dei costrutti linguistici per la gestione della
sincronizzazione e del nondeterminismo. In particolare, si parlerà di semafori,
monitor e canali.
• Introdurremo via via tecniche di analisi e verifica formale con le quali dimo-
streremo la correttezza degli algoritmi sviluppati: invarianti, logica temporale
LTL, un cenno al model checking.
Riferimenti bibliografici
[1] G. R. Andrews. Foundations of Multithreaded, Parallel, and Distributed
Programming, Addison-Wesley, 2000.
[3] J. L. Lions (Chairman of the Board). ARIANE 5 Flight 501 Failure: Re-
port by the Inquiry Board. https://www.ima.umn.edu/~arnold/disasters/
ariane5rep.html, 1996.
11
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 2 - 29/9/2022
Il modello interleaving
Docente: Prof. Michele Boreale (Disia)
Definizione 2 (control pointer). Ogni processo P possiede una variabile locale, det-
ta control pointer e indicata con cpP , che indica il prossimo comando atomico da
eseguire nel programma di P . Se il processo P è terminato, cpP assume il valore nil.
1
p3, . . .
6cp p
Qui ci sono tre processi, P, Q, R, di ciascuno dei quali sono stati già eseguiti alcuni
comandi atomici, dando origine alla sequenza di esecuzione scritta a sinistra, cioè
p1, r1, p2, q1. Secondo il modello interleaving, il prossimo comando da eseguire può
essere uno qualsiasi tra quelli puntati dai tre control pointer. Dunque, la sequenza
di esecuzione potrà essere continuata o con p3, o con q2, o con r2.
L’esempio precedente mette in luce che ogni sequenza di comandi atomici è pos-
sibile, con un unico vincolo: che l’esecuzione sequenziale di ciascun processo deve
essere rispettata.
Introduciamo ora la sintassi del linguaggio che utilizzeremo per descrivere i nostri
programmi concorrenti. Si tratta di un linguaggio in cui gli aspetti relativi ai costrutti
sequenziali sono stati semplificati al massimo, concentrandosi su quelli concorrenti.
Introduciamo la sintassi con l’esempio presentato di seguito.
2
Algorithm: Trivial concurrent program
integer n ← 0
p q
integer k1 ← 1 integer k2 ← 2
p1: n ← k1 q1: n ← k2
Uno stato di questo programma è una n-upla con campi corrispondenti alle
variabili: (cpP , n, k1, k2). Per esempio,
s = (cpP = p1, n = 0, k1 = 1, k2 = 2)
è lo stato iniziale del programma. La dinamica del programma è data dalle transi-
zioni: ogni transizione corrisponde al passaggio da uno stato ad un altro.
3
Definizione 4 (transizione). Una transizione è una coppia di stati (s, s0 ), scritta
come s→s0 , tale che s0 si ottiene a partire s eseguendo un qualsiasi comando atomico
tra quello specificati nei control pointer di s.
s → s0
Per esempio,
s→s0 →s00
dove s00 = (cpP = nil, n = 2, k1 = 1, k2 = 2) è una computazione del programma
precedente, ed è completa.
2 Diagramma stati-transizioni
Il diagramma stati-transizioni descrive in maniera compatta l’insieme di tutte le
computazioni possibili del programma. Si tratta di un grafo con archi orientati, in
cui ciascun nodo rappresenta uno stato, e un arco fra due nodi rappresenta una
transizione. Tale grafo può essere costruito seguendo questa procedura:
2. per ogni stato s rappresentato, e per ciascuna transizione s→s0 che parte da s,
si aggiunge al grafo un nodo s0 , se esso non esiste già, e un arco da s a s0 , se
esso non esiste già;
4
' $' $' $
s-
p1: n ← k1 -
p2: n ← k2 -
(nil)
k1 = 1, k2 = 2 k1 = 1, k2 = 2 k1 = 1, k2 = 2
& n=0 %& n=1 %& n=2 %
Più interessante è il diagramma stati-transizioni del Trival concurrent program:
r
' ? $
p1: n ← k1
q1: n ← k2
k1 = 1, k2 = 2
&n = 0 %
@
@
' $ '@ $
@
R
(nil) p1: n ← k1
q1: n ← k2 (nil)
k1 = 1, k2 = 2 k1 = 1, k2 = 2
&n = 1 % &n = 2 %
' ? $ ' ? $
(nil) (nil)
(nil) (nil)
k1 = 1, k2 = 2 k1 = 1, k2 = 2
&n = 2 % &n = 1 %
Vediamo che dallo stato iniziale escono due archi, corrispondenti alle due possibili
transizioni da questo stato: o viene eseguito prima p1 (arco di sinistra), oppure prima
q1 (arco di destra). Ne derivano due computazioni differenti, rappresentate dai rami
di sinistra e di destra nel grafo. Questa biforcazione è una rappresentazione grafica
del nondeterminismo. Si noti che, conseguentemente, nel grafo ci sono due possibili
stati finali del programma, uno in cui n = 1 e uno in cui n = 2. Più in generale,
osserviamo che, dato un diagramma stati-transizioni:
• ogni percorso che parte dalla radice (stato iniziale) e, seguendo gli archi, finisce
in un nodo qualsiasi, rappresenta una computazione; le computazioni complete
sono o percorsi ciclici oppure percorsi che terminano in un nodo-foglia (senza
archi uscenti);
• l’insieme di tutti i nodi raggiungibili a partire dallo stato iniziale, viene detto
insieme degli stati raggiungibili del programma.
Dalle considerazioni precedenti, dovrebbe essere chiaro che nel diagramma è possibile
rappresentare anche computazioni infinite. Queste corrispondono, nel grafo, a cicli.
Il prossimo è un esempio molto semplice di questa situazione.
5
Algorithm: Semplice programma con loop
boolean flag ← true
p q
p1: flag ← false q1: if flag goto q1
dove gli stati sono: A = (p1, q1, f lag = true), B = (nil, q1, f lag = f alse) e C =
(nil, nil, f lag = f alse). Alcune computazioni complete di questo programma sono:
• A→B→C
• A→A→B→C
• A→A→A→ · · · (infinite volte A).
Terminiamo questa sezione introducendo un modo tabellare di rappresentare le com-
putazioni, quello degli scenari. Uno scenario è una tabella in cui ogni riga corrisponde
ad uno stato. Le colonne corrispondono alle variabili del programma, inclusi i con-
trol pointer. La riga sotto a quella di uno stato rappresenta lo stato successivo della
computazione. Il comando che viene eseguito per passare da uno stato al successivo
è evidenziato in grassetto (o sottolineato) in ogni riga.
Ad esempio, consideriamo il diagramma degli stati del Trivial concurrent pro-
gram, e la computazione corrispondente al percorso radice-foglia di sinistra. Lo
scenario corrispondente a questa computazione è il seguente.
Process p Process q n k1 k2
p1: n←k1 q1: n←k2 0 1 2
(nil) q1: n←k2 1 1 2
(nil) (nil) 2 1 2
6
3 Discussione: adeguatezza del modello interlea-
ving
Alla base di quasi tutti i modelli che vengono considerati in molti campi del sapere,
dalla Fisica, alla Biologia, all’Economia, c’è un procedimento di astrazione e sempli-
ficazione. In ogni procedimento di questo tipo, si dà per scontato che alcuni dettagli
della situazione reale che si vuole studiare debbano essere tralasciati. Questa sem-
plificazione è accettabile se rende il modello risultante facile da comprendere, e se
i dettagli tralasciati non influiscono in maniera determinante sulla correttezza delle
conclusioni che possiamo trarre ragionando sul modello astratto.
Il modello astratto basato sull’esecuzione interleaving è una finzione conveniente.
Vediamo in ciascuno degli scenari concreti in cui troviamo all’opera la concorrenza,
quali dettagli vengono trascurati, e se essi influiscono in maniera determinante sulle
conclusioni che possiamo trarre, circa il comportamento del programma e la sua
correttezza.
7
Tuttavia, se non c’è contention, ovvero se i processori non si contendono la me-
moria, è facile convincersi che i risultati ottenuti con una esecuzione veramente
parallela su un sistema multiprocessore sono indistinguibili da quelli ottenuti
sequenzializzando le istruzioni dei processi in maniera arbitraria, come nel mo-
dello interleaving. In presenza di contention, può succedere che due CPU, P1 e
P2 , cerchino di accedere alla stessa locazione di memoria contemporaneamente.
In questo caso, è l’hardware a garantire che il conflitto sia risolto mediante
sequenzializzazione: accederà prima P1 e poi P2 , oppure prima P2 e poi P1 , in
un ordine che non è possibile prevedere a priori. Tutto questo è consistente con
una esecuzione interleaving. Le conclusioni che possiamo trarre nel modello
interleaving sono dunque valide anche per i multiprocessori.
8
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 3 - 4/10/2022
1 Comandi atomici
Nel modello interleaving, l’esecuzione di un comando atomico non è interrompibile.
In altre parole, una volta iniziata l’esecuzione di un comando atomico, la CPU la
porterà a termine senza interruzioni di sorta. Nel seguito, non fisseremo alcuna
architettura specifica, e assumeremo che i comandi atomici del nostro modello siano
quelli descritti di seguito.
1
• await b: attende che la condizione booleana b diventi vera; quando (se) ciò
accade, salta al comando atomico successivo. È equivalente, nel codice,
ad una linea di programma del tipo
pj: if (not b) then goto pj
per una opportuna etichetta pj.
Si noti che il comando await b realizza quella che viene chiamata una attesa
attiva (busy wait): il comando continua ad essere eseguito finché la condizione
booleana diventa vera. È evidente che un comando di questo tipo ha senso solo
in ambito concorrente: infatti, deve essere un processo diverso da quello che ese-
gue l’await a “lavorare” per rendere vera la condizione b. Benché molto utile per il
controllo dell’interazione tra processi, l’attesa attiva può comportare un serio spreco
di risorse computazionali, in ambito multitasking: infatti il tempo della CPU viene
parzialmente impiegato ad eseguire un ciclo di attesa, invece di istruzioni utili di
altri processi. Questo è in pratica un serio problema, per risolvere il quale dovremo
introdurre, ad un certo punto del corso, il concetto di attesa passiva.
Vedremo nel seguito diversi esempi di uso di tali costrutti, grazie ai quali po-
tremo descrivere la loro semantica in maniera più precisa. Prima di andare avanti,
però, dobbiamo introdurre e discutere una importante restrizione della sintassi dei
comandi.
Definizione 1 (comandi atomici con LCR). Un comando atomico gode della pro-
prietà LCR (Limited Critical References) se la sua esecuzione richiede al più un
accesso alla memoria, o in lettura o in scrittura, riferito ad una qualsiasi variabile
globale.
Per esempio il comando x←a+b, se a e b sono variabili globali, non è LCR, e
neppure x← x+1, se x globale. Viceversa, se temp è locale, temp←x+1 è LCR. Il
senso della restrizione LCR è che comandi che richiedono più di un accesso alla me-
moria globale potrebbero risultare, una volta compilati, in sequenze di più istruzioni
assembly (es. una di lettura e una di scrittura in memoria). Con la qual cosa l’assun-
zione di atomicità diventerebbe difficile da rispettare, a meno di non fare assunzioni
sull’architettura hardware sottostante.
Esempio 1. Consideriamo il seguente programma concorrente.
Algorithm: Atomic assignment statements
integer n ← 0
p q
p1: n ← n + 1 q1: n ← n + 1
2
Secondo la semantica data, i possibili scenari completi di esecuzione di questo pro-
gramma sono due:
Process p Process q n
p1: n←n+1 q1: n←n+1 0
(end) q1: n←n+1 1
(end) (end) 2
oppure
Process p Process q n
p1: n←n+1 q1: n←n+1 0
p1: n←n+1 (end) 1
(end) (end) 2
In ogni caso, il valore finale della variabile globale n è 2. Tuttavia, il comando p1 non
soddisfa la restrizione LCR, infatti si trovano nel comando due accessi relativi alla
variabile globale n, uno in lettura e uno in scrittura. Lo stesso si può dire del comando
q1. Assumendo che nell’architettura sottostante sia possibile eseguire atomicamente
o un accesso in lettura (load) oppure uno in scrittura (store) verso la memoria, ma
non due accessi contemporaneamente (cf. architetture RISC), il programma sopra
non potrebbe essere compilato correttamente.
Si sarebbe tentati allora di riscrivere il programma, spezzando ciascuno dei co-
mandi incriminati in due comandi distinti, uno di lettura verso una variabile locale
e uno di scrittura verso n:
Si noti che questo programma rispetta la restrizione data, perché non poniamo
limiti sull’accesso alle variabili locali ai processi. Tuttavia, questo programma non è
semanticamente equivalente a quello di prima. In particolare, sono ora possibili sia
computazioni che portano al valore finale (intuitivamente corretto) n = 2:
3
Process p Process q n p.temp q.temp
p1: temp←n q1: temp←n 0 ? ?
p2: n←temp+1 q1: temp←n 0 0 ?
(end) q1: temp←n 1 0 ?
(end) q2: n←temp+1 1 0 1
(end) (end) 2 0 1
che computazioni che portano al valore finale (non corretto) n = 1:
Dunque il nuovo programma non può essere considerato corretto, rispetto alla spe-
cifica che il valore finale di n deve essere 2, a differenza di quello precedente, che lo
è.
Nel seguito assumeremo normalmente la restrizione sintattica LCR, salvo diversa
indicazione. In qualche caso, che indicheremo, dovremo andare oltre LCR: in tali
casi, vorrà dire che l’atomicità necessaria dovrà essere o garantita dall’hardware
(l’architettura sottostante supporta questo tipo di accessi), o simulata via software
(mediante l’impiego della tecniche di mutua esclusione che vedremo in una successiva
lezione).
4
Un altro fattore che complica il procedimento di debugging in ambito concorrente
è la possibilità di non-terminazione. Un programma concorrente può essere corretto
e utile anche se non termina: si pensi ai processi di un sistema operativo, che devono
fornire servizi ai processi applicazione, fin tanto che il sistema rimane in esecuzione.
La non-terminazione e il nondeterminismo, a livello teorico, fanno sı̀ che non esista
un concetto di funzione di input-output calcolata da un programma concorrente. La
funzione di I/O è invece è alla base del concetto di correttezza nel caso sequenziale.
La correttezza per i programmi concorrenti è fondata sul concetto di computazio-
ne già introdotto, ovvero una sequenza di stati collegati da transizioni: s0 →s1 →s2 → · · · .
Vengono generalmente considerati due tipi di correttezza. Con P indicheremo nel
seguito un generico predicato sugli stati; cioè un’asserzione circa il valore delle va-
riabili del programma, che risulta vera o falsa, in dipendenza dallo stato in cui viene
valutata. Per esempio, nel caso del programma Assignment statements with one global
reference visto nella sezione precedente, il predicato P : n = 2 risulta falso in tutti
gli stati, tranne che in uno dei due stati finali.
Si noti che, in tutti e due i casi, insistiamo sul fatto che la condizione richiesta in
P valga per ogni computazione possibile. Nel caso della Safety, la proprietà P viene
a volte chiamata invariante, perché deve appunto risultare vera in ogni stato della
computazione (e quindi, in ogni stato raggiungibile del programma). Facciamo un
po’ di esempi, ad alto livello:
• Liveness: se premo il tasto destro del mouse, prima o poi un menù viene
mostrato sullo schermo.
5
In altre parole, si pone P = ¬C, dove C è l’evento cattivo da tenere d’occhio. Ad
esempio
• Non accade mai che un processo utente accede ad un’area di memoria protetta.
• Non accade mai che due processi scrivono nel DB allo stesso momento.
• Non accade mai che T > 5000 (T = variabile temperatura del nocciolo del
reattore).
• Non accade mai che rosso e verde siano accesi contemporaneamente (in un
semaforo ad un incrocio).
E cosı̀ via. Allo stesso modo, spesso le proprietà di Liveness interessanti prendono la
seguente forma, dove B è un certo evento ’buono’
Ad esempio
• Prima o poi, tra quelli che lo richiedono, un processo scriverà nel DB.
Come si sarà intuito, Safety e Liveness catturano due aspetti diversi ma ugualmente
importanti della correttezza di un programma concorrente. È facile scrivere un pro-
gramma che rispetta una data proprietà di Safety, ad es. quella relativa al cursore
del mouse
6
Naturalmente questi due programmi sono di utilità nulla. La parte ’utilità’ è codifi-
cata generalmente da una proprietà di Liveness. Dunque la sfida della programma-
zione concorrente consiste nello scrivere programmi corretti, ovvero programmi che
rispettino sia date proprietà di Safety che date proprietà di Liveness.
3 Fairness
Dobbiamo ora ritornare sul principio che tutte le computazioni possibili, secondo il
codice dato, debbano essere tenute in considerazione quando ragioniamo di corret-
tezza. In effetti, risulterà a volte ragionevole escludere certe computazioni “patolo-
giche”. Per introdurre il concetto, vediamo un esempio. Ne approfittiamo anche per
specificare meglio la sintassi dei comandi atomici. In generale, per risparmiare sulle
parentesi, nei comandi che prevedono un corpo (if, while, do), questo viene identi-
ficato dall’indentazione (allineamento) a destra rispetto alla keyword del comando.
Cosı̀, per esempio
p1. while b
p2. c1
p3. c2
p4. c3
p1. while b {
p2. c1;
p3. c2 }
p4. c3
Si noti anche che il corpo del while può essere costituito da più comandi atomici (due,
nell’esempio sopra). Ai fini dell’esecuzione interleaving, tali comandi sono considerati
individualmente: ciascun comando nel corpo dà origina ad una transizione distinta
nella computazione.
7
Algorithm: Stop the loop A
8
4 Concorrenza in Java
Quasi tutti i linguaggi di uso comune offrono supporto alla programmazione concor-
rente: Ada, C, C++, Java,... Noi ci soffermeremo in questo corso su alcuni aspetti
della concorrenza nel linguaggio Java. Di volta in volta, vedremo dunque come le
soluzioni introdotte a livello della teoria prendono corpo in Java, e attraverso quali
costrutti linguistici.
Introduciamo ora gli elementi di base. Consideriamo il programma Concurrent
counting, espresso nella nostra notazione.
2. Una variabile globale è resa come una variabile statica, ovvero di classe (linea
2).
3. Ogni classe che estende Thread deve implementare il metodo run(), che contiene
il codice da eseguire, per ogni istanza (thread) della classe (linea 4).
5. Per rendere un thread eseguibile, invochiamo il metodo start() del thread (linee
15-16). Questo di per sé non vuol dire che il thread verrà immediatamente
eseguito: solo che da quel momento la CPU, quando sarà possibile secondo
le politiche di scheduling del sistema, lo potrà eseguire (per esempio, quando
sospende l’esecuzione di main()). All’atto dell’esecuzione di un thread, verrà
invocato il suo metodo run(). Dopo la linea 15 p.start() in main(), quindi, l’unica
9
cosa di cui possiamo essere certi è che il thread p è eseguibile, non che è stato
eseguito né tantomeno completato. Lo stesso dicasi per q e q.start().
Un altro punto da notare nel codice è l’uso della keyword volatile: non si tratta
di un comando vero e proprio, ma di una direttiva per il compilatore, che in questo
modo viene istruito di non impiegare ottimizzazioni che prevedono il mantenimento
di una copia temporanea della variabile n in un registro della CPU. Dunque, ogni
volta che la variabile viene letta, essa va letta dalla memoria (e non da un registro
della CPU), e ogni volta che essa viene scritta, va scritta in memoria: in questo modo,
è garantito che quando un thread accede alla variabile n, essa contiene il valore più
recentemente scritto.
Esercizio 3. Costruire esplicitamente uno scenario in cui il valore finale della varia-
bile n nell’algoritmo Concurrent Counting sia 2.
10
1 class Count extends Thread {
2 static volatile int n = 0;
3
4 public void run() {
5 int temp;
6 for (int i = 0; i < 10; i ++) {
7 temp = n;
8 n = temp + 1;
9 }
10 }
11
12 public static void main(String[] args ) {
13 Count p = new Count();
14 Count q = new Count();
15 p. start ();
16 q. start ();
17 try {
18 p. join ();
19 q. join ();
20 }
21 catch (InterruptedException e) { }
22 System.out.println (”The value of n is ” + n);
23 }
24 }
11
/* Copyright (C) 2006 M. Ben-Ari. See copyright.txt */
class Count extends Thread {
static volatile int n = 0;
1 Introduzione al problema
Si tratta del più semplice e comune problema di sincronizzazione tra processi. In
poche parole, si vuole regolare l’accesso ad una risorsa (area di memoria, periferica,
DB, ...) da parte di due o più processi, in particolare evitando che due processi vi
accedano allo stesso tempo. Introduciamo il problema riprendendo un esempio già
visto, che può servire da modello per tante situazioni concrete.
Esempio 1. Due agenzie di viaggio si servono di un sistema di prenotazione online
di voli aerei, in particolare accedono ad uno stesso DB. L’agenzia Agi , per i = 1, 2,
esegue una sequenza di comandi, che descriviamo ad alto livello in questo modo
1. Attendi l’arrivo di un cliente Ci ;
2. Accedi a DB per controllare disponibilità, se non c’è segnala ’no disponibilità’
a Ci ;
3. Se prezzo ok per cliente, accedi a DB per assegnare il posto a Ci .
Chiediamoci cosa può succedere se le due agenzie, Ag1 e Ag2 , eseguono questo codice
in maniera concorrente, e con un unico posto disponibile. Se Ag1 viene eseguito
completamente prima che inizi Ag2 , il posto verrà assegnato a C1 ; analogamente, se
Ag2 viene eseguito prima, il posto andrà a C2 . Entrambi questi risultati sono corretti,
perché chi non riceve il posto ne sarà informato.
Tuttavia, la sequenza di comandi risultante dall’esecuzione concorrente potrebbe
essere ‘maligna’, per esempio (istruzioni di Ag1 senza apici, di Ag2 con apici):
1,1’,2,2’,3,3’.
Alla fine dell’esecuzione, ambedue i clienti credono di aver avuto il posto assegnato;
ma in effetti il posto risulta assegnato al solo C2 : questo non è certamente un risultato
corretto.
1
Per evitare la situazione di inconsistenza dell’esempio precedente, l’idea è di con-
siderare i punti 2 e 3 del programma come una sezione critica: vogliamo garantire
che in ogni momento al più un processo sia in esecuzione in questa sezione di codi-
ce. In questo modo, forzeremo un accesso sequenziale al DB, cosa che garantirà la
consistenza dei risultati ottenuti, mentre non metteremo alcun vincolo all’esecuzione
in parallelo di attività che non accedono al DB. Per usare una metafora, è come
se la sezione critica fosse una stanza, in cui diverse persone (processi) possono voler
entrare, ma in cui può stare al più una persona alla volta:
fff
HH
H
2
1. le variabili usate nei pre- e post-protocol sono disgiunte da quelle usate in NCS
e CS;
2. CS deve progredire, cioè la sua esecuzione deve prima o poi terminare, una volta
iniziata.
• Mutua Esclusione (ME). Non capita mai che due processi siano contempo-
raneamente all’interno della CS.
La ME è la proprietà da cui siamo partiti. DF vuol dire che il sistema non entra
mai in uno stato di blocco o stallo. Da notare che DF richiede che per i processi sia
possibile progredire, entrando in CS: quindi non basta, per esempio, che i processi
possano essere eseguito all’infinito rimanendo in un protocollo. Infine, SF è una
proprietà ancora più forte di DF, e più difficile da garantire. Si noti che, in questa
formulazione, ME e DF sono proprietà di Safety, mentre SF è di Liveness.
Quello che faremo ora sarà di seguire la classica esposizione di Dijkstra [1]: consi-
dereremo vari tentativi di soluzione, quattro in tutto, ciascuno dei quali però difetterà
di qualche proprietà di correttezza. Questo procedimento ci aiuterà a capire meglio
3
la quinta proposta, l’algoritmo di Dekker, che è quella che risolve correttamente il
problema. In linea generale, notiamo che buona parte della difficoltà del problema
risiede nel vincolo di compiere più di un accesso atomico alla memoria, che ci siamo
imposti (restrizione LCR).
2 Primo tentativo
L’idea è semplice: stabilire dei turni per l’accesso a CS. La variabile globale turn
verà usata per stabilire di quale processo è attualmente il turno. Ecco l’algoritmo
risultante.
4
s
? ? ? ?
p1,q1,1 - p1,q2,1 p1,q1,2 - p1,q2,2
? ? ? ? ?
p2,q1,1 - p2,q2,1 p2,q1,2
- p1,q3,2
? ? ? ? ?
p3,q1,1 - p3,q2,1
- p2,q2,2 p1,q4,2
? ? ? ? ?
p4,q1,1 - p4,q2,1
- p2,q3,2
- p2,q4,2
5
t
' ? $ ' ? ? $
p1: await turn=1, p1: await turn=1,
' $
-
q1: await turn=2, q1: await turn=2,
& turn = 2 % & turn = 1 %
& % & %
' ? $ '? $
p1: await turn=1, p2: turn←2,
'-
q2: turn←1, q1: await turn=2, $
& % & %
Per quanto riguarda DF, sempre dal diagramma abbreviato, si verifica che da cia-
scuno degli stati è sempre possibile per ciascuno dei due processi arrivare in CS, cioè
in p2 (per P ) o in q2 (per Q). Non ci sono dunque situazioni di blocco (es. stati da
cui non è più possibile uscire).
Tuttavia, SF non vale. Torniamo alla versione originale dell’algoritmo. Per esem-
pio, inizialmente, Q può richiedere l’accesso passando a q2, ma P può rimanere in
maniera indefinita in p1 (per un fallimento, o altra ragione), cosı̀ turn = 1 per sem-
pre, e Q non avrà mai la possibilità di entrare in CS. Nel diagramma grande (versione
completa), questa computazione è generata dai due stati più a sinistra della prima
riga del diagramma: il self-loop in (p1, q2, 1) corrisponde o ad una esecuzione di q2
(await turn=1) o ad una esecuzione di p1 (NCS) che non fa progredire il processo.
Dunque, sono possibili computazioni fair che violano SF.
3 Secondo tentativo
Il problema del primo tentativo è che se P “muore” in NCS mentre turn = 1, Q
aspetterà per sempre il suo turno (e viceversa per Q, P ). Tuttavia, se P non vuole
entrare in CS, a prescindere dal turno, non c’è ragione per ritardare l’ingresso di Q
in CS. Introduciamo quindi due variabili booleane col seguente significato
wantp = true: P vuole entrare in CS;
wantq = true: Q vuole entrare in CS.
6
Un processo può entrare in CS se l’altro non vuole. Il programma risultante è il
seguente.
Algorithm: Second attempt
boolean wantp ← false, wantq ← false
p q
loop forever loop forever
p1: non-critical section q1: non-critical section
p2: await wantq = false q2: await wantp = false
p3: wantp ← true q3: wantq ← true
p4: critical section q4: critical section
p5: wantp ← false q5: wantq ← false
Chiediamoci prima di tutto se vale la ME. Anche qui, ragionando come nel caso
precedente, possiamo considerare una versione abbreviata.
Algorithm: Second attempt (abbreviated)
boolean wantp ← false, wantq ← false
p q
loop forever loop forever
p1: await wantq = false q1: await wantp = false
p2: wantp ← true q2: wantq ← true
p3: wantp ← false q3: wantq ← false
La violazione della ME corrisponde agli stati in cui P è in p3 e Q in q3. Se cominciamo
a costruire il diagramma degli stati della versione abbreviata, dopo un po’ arriviamo
ad uno stato che viola la ME, e da quel punto è inutile continuare a costruire il
diagramma. In particolare, un frammento del diagramma, che corrisponde ad una
computazione che viola la ME, è il seguente:
' $ ' $ ' $
p1: await !wantq, - p2: wantp←true, p2: wantp←true,
q1: await !wantp, q1: await !wantp, - q2: wantq←true,
&false,false %&false,false %&false,false %
r
6
' $'? $
p3: wantp←false, p3: wantp←false,
q3: wantq←false, q2: wantq←true,
& true,true % & true,false %
Il problema qui è che, dopo che P trova wantq=false, è per cosı̀ dire destinato
ad entrare in CS; prima che P ponga wantp=true, tuttavia, anche Q può andare in
esecuzione e trovare che wantp=false, dunque anch’egli viene avviato verso la CS.
7
4 Terzo tentativo
Dalla discussione precedente, un modo per risolvere il problema che abbiamo riscon-
trato sembra quello di invertire await e wantp← true nel codice di P , e analogamente
per Q. In questo modo, una volta che P trova wantq = false, può direttamente
entrare in CS, visto che wantp è già a true. L’algoritmo risultante è il seguente.
Algorithm: Third attempt
boolean wantp ← false, wantq ← false
p q
loop forever loop forever
p1: non-critical section q1: non-critical section
p2: wantp ← true q2: wantq ← true
p3: await wantq = false q3: await wantp = false
p4: critical section q4: critical section
p5: wantp ← false q5: wantq ← false
Costruendo il diagramma di questo programma, si vede in effetti che la ME è sod-
disfatta. Tuttavia, ora è la DF ad essere violata. Per vederlo, basta costruire una
computazione in cui P è Q vengono eseguiti in maniera strattamente alternata. Il
frammento del diagramma degli stati corrispondente è
' $ ' $ ' $
p2: wantp←true, - p3: await !wantq, p3: await !wantq,
q2: wantq←true, q2: wantq←true,
- q3: await !wantp,
-
& false,false % & true,false % & true,true %
r
6
8
5 Quarto tentativo
Il deadlock nel terzo tentativo nasce da questa circostanza: dopo che un processo
decide di voler entrare in CS, non desiste mai dal volerlo fare, neanche se c’è una
situazione di contention con l’altro processo. Si può risolvere questo problema facendo
in modo che i processi siano meno “testardi” e più educati: un processo dovrebbe
desistere (ponendo want a false) se rileva che l’altro vuole entrare, e riprovare più
tardi. Questa idea porta al quarto tentativo.
Algorithm: Fourth attempt
boolean wantp ← false, wantq ← false
p q
loop forever loop forever
p1: non-critical section q1: non-critical section
p2: wantp ← true q2: wantq ← true
p3: while wantq q3: while wantp
p4: wantp ← false q4: wantq ← false
p5: wantp ← true q5: wantq ← true
p6: critical section q6: critical section
p7: wantp ← false q7: wantq ← false
Si noti la sequenza di comandi p4, p5 nel corpo del while, che assegna a wantp prima
false e poi true. In un programma sequenziale, tale sequenza non avrebbe senso. Quı̀,
il senso è che P desiste, e riprova dopo un po’. Infatti, non è detto che p5 venga
eseguito immediatamente dopo p4: la speranza è che lo scheduler, dopo l’esecuzione
di p4, metta in esecuzione Q, dandogli la possibilità di entrare in CS e quindi di
uscirne, ponendo wantq a false. A quel punto, P potrebbe riprendere l’esecuzione
e, dopo aver valutato la guardia while ancora una volta, ma ora con wantq = false,
potrebbe entrare in CS a sua volta.
In effetti, costruendo il diagramma degli stati, si può verificare che sia ME che DF
sono soddisfatte. Tuttavia, non lo è SF. In particolare, si ottiene una computazione
dove sia P che Q sono starved (vorrebbero entrare, ma non lo faranno mai) eseguendo
i due processi in maniera strettamente alternata, per esempio:
q1,p1,q2,p2,q3,p3,q4,p4,q5,p5,q3,p3,...
Il problema in questa computazione è che ogni processo è troppo educato nei con-
fronti dell’altro, e continua a cedergli il passo: in tal modo, nessuno dei due finisce
per entrare. In termini di diagramma degli stati, il frammento rilevante, che ge-
nera la computazione incriminata, è il seguente ciclo (non sono mostrati gli stati
corrispondenti alla parte iniziale della computazione):
9
r
'? $ ' $ ' $
p3: while wantq, p3: while wantq, p4: wantp←false,
q3: while wantp, - q4: wantq←false, - q4: wantq←false,
& true,true % & true,true % & true,true %
6
'? $
p6: critical section,
q5: wantq←true,
& true, false %
6 Algoritmo di Dekker
L’idea è di combinare il primo (turn) e il quarto (wantp, wantq) tentativo. Intuitiva-
mente, è come se adesso la CS si trovasse dentro una anticamera, AC. Per accedere
alla CS, un processo deve prima accedere alla AC. Ora turn rappresenta non il diritto
di entrare in CS, ma di rimanere in AC (il diritto di “insistere”, per cosı̀ dire; si veda
la Figura 1). Le variabili hanno dunque il seguente significato
turn=1: diritto per P di rimanere in AC;
wantp=true: P è in AC.
10
Figura 1: AC e CS.
È temporaneamente ammesso che due processi stiano in AC; tuttavia, solo chi ha il
diritto vi può rimanere, mentre l’altro deve uscire. In ogni caso, quando un processo
si ritroverà finalmente solo in AC, esso potrà entrare in CS. Uscendo poi da CS, e
dunque da AC, passerà il diritto di stare in AC all’altro processo. Questa idea porta
al seguente schema di programma, per ciascun processo. Si noti che se un processo è
in CS, si intende che esso si trova automaticamente anche in AC (ma non viceversa).
Una descrizione informale del comportamento di ogni processo à la seguente.
1. Se il P si trova da solo in AC, può entrare in SC, sia che abbia diritto o meno
di restare in AC.
11
perché Q, che non ha diritto, dovrà prima o poi uscire e aspettare il proprio
turno fuori da AC.
Riferimenti bibliografici
[1] E. W. Dijkstra. Cooperating Sequential Processes. Technische Hogeschool Ein-
dhoven, 1965. Ristampato in F. Genuys (ed.), Programming Languages, pp.
12
43-112. Academic Press, Orlando, Florida, 1968. Una trascrizione è dispo-
nibile a https://www.cs.utexas.edu/users/EWD/transcriptions/EWD01xx/
EWD123.html.
13
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 5 - 11/10/2022
common = 0: CS libera
common = 1: CS occupata.
Ogni processo testa la variabile common in un ciclo di attesa attiva (repeat ... until),
in cui il valore di common è copiato in local, mentre atomicamente common è posto
1
a 1. Se il processo trova che la CS è libera, cioè local=0, entra senza indugio in CS.
In particolare, common a questo punto sarà già 1, segnalando correttamente che la
CS è occupata.
Si può dimostrare che questo algoritmo soddisfa ME e DF. Tuttavia esso non sod-
disfa la SF. Può infatti succedere che un processo, mettiamo P , sia molto più veloce
dell’altro1 : esso può uscire e rientrare subito in CS, senza mai dare a Q la possibilità
di accedere alla CS. La fairness non viene violata, perché Q continua ad eseguire il
ciclo di attesa attiva: in particolare, Q andrà a eseguire q2, q3 sempre quando P è in
CS. Questo è un esempio di corsa critica (race condition), una situazione in cui la
violazione o non-violazione di una certa proprietà dipende dalla velocità relativa dei
processi coinvolti.
common = 0: CS occupata
common = 1: CS libera.
1
Ad esempio, perché ha una priorità più alta, o è in esecuzione su una CPU più veloce.
2
Algorithm: Critical section problem with exchange
integer common ← 1
p q
integer local1 ← 0 integer local2 ← 0
loop forever loop forever
p1: non-critical section q1: non-critical section
repeat repeat
p2: exchange(common, local1) q2: exchange(common, local2)
p3: until local1 = 1 q3: until local2 = 1
p4: critical section q4: critical section
p5: exchange(common, local1) q5: exchange(common, local2)
synchronized (lock ) {
B // Accedi a variabili condivise
}
Qualsiasi oggetto Java può fungere da lock. Un lock si trova sempre in uno di due
stati possibili, acquisito (acquired) o rilasciato (released). Il lock è automaticamen-
te acquisito da un thread in esecuzione prima di entrare nel blocco sincronizzato,
3
ed è automaticamente rilasciato non appena il thread in esecuzione lascia il bloc-
co sincronizzato. L’unico modo di acquisire il lock è quello di entrare in un blocco
sincronizzato guardato da quel lock. Ovviamente tutto questo avviene in manie-
ra trasparente al programmatore, che non deve quindi programmare alcun ciclo di
attesa, ma limitarsi a usare in modo appropriato i blocchi synchronized.
Il punto fondamentale è che un lock in Java agisce da mutex, ovvero da lock di
mutua esclusione. Questo vuol dire che al più un thread alla volta può possedere
un dato lock. Se il thread A cerca di acquisire un lock in possesso del thread B, A
deve aspettare, o bloccarsi, finché B non rilascia il lock. Se per qualche motivo B
non rilascia mai il lock, allora A aspetterà per sempre. In ogni caso è importante
notare che, anche se ad un livello di implementazione non visibile al programmatore
(macchina virtuale), l’attesa di un lock da parte di un thread in esecuzione può
comportare cicli di attesa attiva.
Dal momento che al più un thread alla volta può star eseguendo un blocco di
codice guardato da un certo lock, due o più blocchi synchronized guardati dallo
stesso lock vengono eseguiti in maniera atomica gli uni rispetto agli altri. Nel presente
contesto, atomicità ha il seguente significato:
un blocco di comandi B è atomico se l’esecuzione di B produce gli
stessi effetti (sulla memoria) che produrrebbe se fosse eseguito come una
singola unità indivisibile, e questo per qualunque interleaving o scheduling
possibile.
Per riassumere, la regola generale per garantire la mutua esclusione tramite blocchi
synchronized è la seguente.
Per ogni variabile di stato che può essere acceduta da due o più thread,
tutti gli accessi (in lettura o in scrittura) a quella variabile devono essere
eseguiti all’interno di blocchi synchronized guardati da uno stesso lock.
Solo in questo caso possiamo dire che la variabile è guardata dal quel
lock.
Con queste premesse, possiamo proporre una soluzione in Java al problema della
Sezione Critica, semplicemente inserendo il codice della Sezione Critica all’interno
di un blocco synchronized. Questo porta al programma in Figura 1. In questo
esempio, la sezione critica è rappresentata dai due comandi che accedono alla variabile
condivisa n. Inoltre, abbiamo per praticità rimpiazzato il loop forever dello schema
originale con un ciclo di 10 iterazioni.
Per quanto riguarda la correttezza, è facile convincersi che questo schema di solu-
zione rispetta le proprietà di ME e DF. Tuttavia, la SF non è in generale garantita.
4
Consideriamo il caso di processi con un loop infinito (while(true) invece di for(...) nel
codice). Uno dei due thread, diciamo p, potrebbe essere molto più veloce di q (come
al solito, questa può dipendere da priorità nello scheduling, velocità relativa delle
CPU etc.). In tal caso, mentre p continua ripetutamente ad entrare ed uscire da CS,
può succedere che q va a testare il lock sempre quando esso è in possesso di p, senza
mai riuscire ad entrare in CS.
Terminiamo questa introduzione ai blocchi synchronized in Java con una precisa-
zione relativa al modo in cui viene gestito il possesso del lock da parte dei threads.
Se il thread A, attualmente in possesso del lock, lo richiede nuovamente durante
l’esecuzione, A si vedrà automaticamente riassegnato il lock. Questo rende possibili
le chiamate ricorsive di un metodo che fa uso di blocchi synchronized. A livello di
macchina virtuale, un contatore associato al lock tiene conto di quante volte il thread
A è entrato (contatore incrementato) e uscito (contatore decrementato) da un blocco
synchronized guardato dal lock. Il lock verrà automaticamente rilasciato quando il
contatore varrà 0 (per esempio, quando l’ultima istanza pendente del metodo ricor-
sivo esce dal blocco synchronized). Questo viene descritto nei manuali (si veda ad
esempio [1], capitolo 2) dicendo che i lock Java sono rientranti. Ribadiamo che que-
sta gestione avviene automaticamente a livello di macchina virtuale e pertanto non
è direttamente visibile al programmatore.
Riferimenti bibliografici
[1] B. Goetz, T. Peierls, J. Bloch, J. Bowbeer, D. Holmes, D. Lea. Java Concurrency
in Practice. Addison-Wesley, 2006
5
1 class CountCS extends Thread {
2 static volatile int n = 0;
3 static volatile Object lock = new Object();
4
5 int temp;
6 int id ; // campo identità del thred
7
8 CountCS(int myid){ // costruttore , inizializza il campo id
9 this . id =myid;
10 }
11
12 public void run() {
13 for (int i = 0; i < 10; i ++) {
14 System.out.println (”Process ” + id + ” in NCS”); // NCS
15 synchronized(lock){
// CS = blocco synchronized
16 temp = n;
17 n = temp + 1;
18 }
19 }
20 }
21
22 public static void main(String[] args ) {
23 CountCS p = new CountCS(1);
24 CountCS q = new CountCS(2);
25 p. start ();
26 q. start ();
27 try {
28 p. join ();
29 q. join ();
30 }
31 catch (InterruptedException e) { }
32 System.out.println (”The value of n is ” + n);
33 }
34 }
6
class CountCS extends Thread {
static volatile int n = 0;
static volatile Object lock = new Object();
int temp;
int id; // campo identita' del thread
P Q
p1: c1 q1: c’1
p2: c2 q2: c’2
p3: c3 q3: c’3
p4: c4 q4: c’4
(a) Dire quante sono le computazioni complete di questo programma nel modello
interleaving.
(b) Dare una formula f (n, k) per il caso più generale di n processi ciascuno di k
comandi atomici. Si stimi numericamente f (10, 10).
Soluzione.
1
(b) Siano P1 , ..., Pn gli n processi. Il ragionamento è simile al caso precedente: da
un vettore di n · k posizioni si scelgono prima le k posizioni di P1 , poi le k
posizioni di P2 e cosı̀ via fino a Pn . Si ottiene
nk nk − k nk − 2k k
f (n, k) = · · ··· (n fattori)
k k k k
(nk)! (nk − k)! (nk − 2k)! k!
= · · ···
k!(n − k)! k!(nk − 2k)! k!(nk − 3k)! k!
(nk)!
= .
(k!)n
2
Esercizio 3. Si consideri una funzione f definita sugli interi, per cui è noto che esiste
almeno uno zero, cioe esiste almeno un intero y tale che f (y) = 0. Tuttavia, non è
noto il valore di y. Considerare i seguenti algoritmi concorrenti per la ricerca di y.
Un algoritmo è corretto se, per tutte le computazioni, lo zero viene trovato (quindi
valore finale di found è true) e tutti e due i processi terminano. Per ogni algoritmo,
discutere se esso è corretto, oppure costruire uno scenario che dimostra che non lo è.
Algorithm: Zero A
boolean found← false
p q
integer i ← 0 integer j ← 1
p1: found ← false q1: found ← false
p2: while not found q2: while not found
p3: i←i+1 q3: j←j−1
p4: found ← f(i) = 0 q4: found ← f(j) = 0
Algorithm: Zero B
boolean found ← false
p q
integer i ← 0 integer j ← 1
p1: while not found q1: while not found
p2: i←i+1 q2: j←j−1
p3: found ← f(i) = 0 q3: found ← f(j) = 0
3
Algorithm: Zero C
boolean found ← false
p q
integer i ← 0 integer j ← 1
p1: while not found q1: while not found
p2: i←i+1 q2: j←j−1
p3: if f(i) = 0 q3: if f(j) = 0
p4: found ← true q4: found ← true
Soluzione. Si supponga, per concretezza, che la f data sia tale che f (1) = 0
mentre f (y) 6= 0 per y 6= 1. f può essere vista come un vettore (infinito in entrambi
le direzioni): si esamini la figura sottostante, dove vengono anche evidenziate le
porzioni del vettore scandagliate da P e da Q.
L’algoritmo Zero A non è corretto: per esempio, nella computazione che origina da
una esecuzione completa di P , seguita dall’esecuzione di Q, Q rimane per sempre
bloccato nel ciclo q2,q3,q4.
Neanche l’algoritmo Zero B è corretto. Si consideri, per esempio, una esecuzione
strettamente alternata di P e di Q, a partire da P : dopo l’esecuzione di p3, che
pone found a true, q3 rimette found a false, e dunque nessuno dei due processi ha la
possibilità di terminare (visto che in f non ci sono altri zeri).
L’algoritmo Zero C è corretto, sotto assunzione di fairness. Infatti, una volta che
found sia stato posto a true, non c’è modo di riportarlo al valore false, perché non ci
sono assegnazioni di found a false nel programma; dallo stato in cui found=true, per
4
fairness, i processi dovranno essere tutti e due eseguiti, fino alla loro terminazione.
Si noti che found viene di sicuro posto a true, nel momento in cui si trova f (i) = 0 o
f (j) = 0, per qualche valore di i e j.
Algorithm: Zero D
boolean found ← false
integer turn ← 1
p q
integer i ← 0 integer j ← 1
p1: while not found q1: while not found
p2: await turn = 1 q2: await turn = 2
turn ← 2 turn ← 1
p3: i←i+1 q3: j←j−1
p4: if f(i) = 0 q4: if f(j) = 0
p5: found ← true q5: found ← true
5
Algorithm: Zero E
boolean found ← false
integer turn ← 1
p q
integer i ← 0 integer j ← 1
p1: while not found q1: while not found
p2: await turn = 1 q2: await turn = 2
turn ← 2 turn ← 1
p3: i←i+1 q3: j←j−1
p4: if f(i) = 0 q4: if f(j) = 0
p5: found ← true q5: found ← true
p6: turn ← 2 q6: turn ← 1
Soluzione. Per Zero D, esiste uno scenario in cui P trova lo zero, ma avviene un
context switch prima che possa eseguire l’assegnamento found ← true: questo provoca
un deadlock per Q. Una sequenza di esecuzione che porta a questa situazione è per
esempio
p1,p2,p3,p4,q1,q2,q3,q4,q1,p5,p1,q2,q2,...
Il problema qui è che P , una volta trovato lo zero, non si preoccupa di ristabilire
il turno per Q. Si può ovviare a questo facendo in modo che l’ultima istruzione di
P , prima di terminare, sia proprio turn←2 (analogamente per Q): questo porta al
programma Zero E, corretto sotto assunzione di fairness.
(a) Costruire tre scenari che diano origine alle seguenti sequenze di output: 012,
002, 02.
6
(d) Quante volte il valore 1 può apparire nell’output?
Soluzione. In (a), prestare attenzione al fatto che, affinché in output sia generato il
valore 2, nell’ultima iterazione del ciclo while, l’esecuzione di p1 deve avvenire prima
che a n sia assegnato il valore 2, mentre l’esecuzione di p2 (write) deve avvenire dopo
di ciò. Per esempio, una sequenza di comandi che genera la sequenza 0,0,2, è
p1,p2,p1,p2,p1,q1,q2,p2,p1.
Per quanto riguarda (b), basta considerare una qualsiasi computazione in cui,
dopo l’esecuzione di q2, P si trova in p1, per esempio
p1,p2,q1,q2,p1
7
Soluzione. Per quanto riguarda (a), è sufficiente considerare una sequenza di
esecuzione in cui Q venga completamente eseguito prima di P .
Per quanto riguarda (b), i possibili valori finali sono 0 e 1. Il valore finale 0
è quello risultante dalla computazione descritta al punto precedente. Per quanto
riguarda il valore finale 1, basta considerare la seguente sequenza di esecuzione
q1,q2,p1,p2,q3,q1,p1.
Per quanto riguarda (c), la risposta è no: si consideri una computazione in cui Q
esegue sempre il controllo q2 quando n = 1. Uno scenario esplicito, per esempio, è il
seguente, dove si nota che l’ultima riga (stato) della tabella prima dei puntini è uguale
alla terza. Questo corrisponde ad un ciclo, che dà origine ad una computazione in
cui sia P che Q vengono eseguiti infinitamente spesso. Dunque ad una computazione
fair che non porta a terminazione.
CP CQ n flag
p1 q1 0 f
p2 q1 0 f
p1 q1 1 f
p1 q2 1 f
p1 q1 1 f
p2 q1 1 f
p1 q1 0 f
p2 q1 0 f
p1 q1 1 f
.. .. .. ..
. . . .
e similmente nel caso di Q. Dal momento che questa soluzione rispetta la ME,
nell’output non dovremmo mai osservare sequenze del tipo:
... Begin CS: P Begin CS: Q ...;
8
Algorithm: Fourth attempt
boolean wantp ← false, wantq ← false
p q
loop forever loop forever
p1: non-critical section q1: non-critical section
p2: wantp ← true q2: wantq ← true
p3: while wantq q3: while wantp
p4: wantp ← false q4: wantq ← false
p5: wantp ← true q5: wantq ← true
p6: critical section q6: critical section
p7: wantp ← false q7: wantq ← false
le due variabili globali wantp, wantq sono tradotte come un array want di due
posizioni, che è dichiarato static, quindi come variabile di classe (una copia
comune a tutti gli oggetti della classe);
Riferimenti bibliografici
[1] AA.VV. Voce Eddington number in Wikipedia. https://en.wikipedia.org/
wiki/Eddington_number, consultato il 13/10/2022.
9
1 class FourthAttempt extends Thread {
2
3 private static volatile boolean[] want = {false, false };
4 private String myname;
5 private int id ;
6
7 public FourthAttempt(String x, int j ){
8 myname=x;
9 id =j;
10 }
11
12 public void run() {
13 for (int i = 0; i < 10; i ++) {
14 System.out.println (”NCS: ” + myname); /* NCS */
15 want[id]=true;
16 while (want[1=id]) {
17 want[id]=false;
18 want[id]=true;
19 }
20 System.out.println (”Begin CS: ” + myname); /* CS */
21 System.out.println (”End CS: ” + myname); /* CS */
22 want[id]=false;
23 }
24 }
25
26 public static void main(String[] args ) {
27 FourthAttempt p = new FourthAttempt(”p”,0);
28 FourthAttempt q = new FourthAttempt(”q”,1);
29 p. start ();
30 q. start ();
31 try { p. join (); q. join (); }
32 catch (InterruptedException e) { }
33 System.out.println (”End of program”);
34 }
35 }
10
class FourthAttempt extends Thread {
Soluzione. Ogni stato raggiungibile è una quadrupla (cP , cQ , wantp, wantq). Ab-
breviamo i possibili valori delle variabili booleane wantp e wantq, cioè true e false, con
t e f , rispettivamente. Ogni arco è etichettato col nome del processo la cui esecuzione
genera la transizione. Dal diagramma, si evince che la ME è violata, per la presenza
di uno stato in cui sia P che Q sono in CS: (p3, q3, t, t, ). La DF invece è soddisfatta,
perché da tutti gli stati, per tutti e due processi è possibile prima o poi arrivare in
P P Q P
CS. Infine, la SF non è soddisfatta, per via del ciclo A −→ B −→ C −→ C −→ A,
che può dare origine ad una computazione fair nella quale Q è starved.
1
• p1, q1, f, f
P Q
P Q
p2, q1, f, f p1, q2, f, f
P Q P Q
P
p3, q1, t, f p2, q2, f, f p1, q3, f, t
Q P Q
P Q
Q P
p3, q3, t, t
2
Esercizio 3. Si generalizzi l’algoritmo di Dekker al caso di N ≥ 2 qualsiasi processi.
Soluzione. Il problema è come gestire il passaggio del diritto di stare in AC, cioè
la variabile turn. Semplicemente passare il diritto al processo successivo, ciclicamente
(turn = turn +1 mod N), non funziona. Si pensi al caso di tre processi, in cui il diritto
è detenuto dal terzo, che però non vuole o non può più entrare (cicla all’interno
della propria NCS), mentre gli altri due competono per la CS: una volta usciti la
prima volta da AC, nessuno dei due acquisirebbe mai il diritto di starvi, per cui
aspetterebbero per sempre di entrarvi (deadlock).
Diamo di seguito la soluzione proposta da E.W. Dijkstra in un articolo del 1965
[1]. In questa soluzione, want[i]=true vuol dire che il processo Pi è intenzionato ad
entrare in CS; turn=i vuol dire che il processo ha il diritto di rimanere in AC; e
passed[i]=true vuol dire che il processo è in AC. Come nell’algoritmo visto per il caso
di due processi, un processo può entrare in CS solo quando si ritrova solo in AC (ciclo
for da p9 a p12). Tuttavia viene usata una strategia più aggressiva per l’acquisizione
del diritto di stare in AC: se il processo che attualmente detiene il diritto non è
interessato, il processo Pi tenta di acquisirlo; in caso di competizione, vince chi ha
preso il diritto per ultimo (ciclo while da p4 a p6). Si può dimostrare, ma non lo
faremo, che l’algoritmo soddisfa la ME e la DF. E’ abbastanza chiaro che esso non
soddisfa la SF: per esercizio, descrivere un esempio di computazione fair che viola la
SF.
3
Algorithm: Dekker N processes
boolean array[1..N] want ← [f,...,f], passed ← [f,...,f]
turn← 1
p(i)
done← false
loop forever
p1: NCS
p2: want[i]←true
p3: while not done
p4: while turn6=i
p5: if not want[turn]
p6: turn←i
p7: passed[i]← true
p8: done← true
p9: for j=1,...,N, j!=i
p10: if passed[j]
p11: passed[i]←false
p12: done←false
p13: CS
p14: passed[i]←false
p15: want[i]←false
Esercizio 4. Programmare in Java la soluzione di Dekker vista nell’esercizio prece-
dente, fissando N = 3.
4
Algorithm: Sezione critica con fetch-and-add
integer common ← 0
p q
integer local integer local
loop forever loop forever
p1: non-critical section q1: non-critical section
repeat repeat
p2: fetch-&-add(common,local,1) q2: fetch-&-add(common,local,1)
p3: until local = 0 q3: until local = 0
p4: critical section q4: critical section
p5: common ← 0 q5: common ← 0
Si noti che nella soluzione data il valore della variabile common può crescere in
maniera indefinita (per esempio, se Q chiede di entrare e P rimane tanto tempo in
CS).
Esercizio 6. Programmare una versione in Java della soluzione dell’esercizio prece-
dente. Servirsi del costrutto synchronized per programmare l’esecuzione atomica del
blocco di istruzioni che compongono fetch-and-add e l’accesso alle variabili condivise.
Esercizio 8. Dare una soluzione al problema della sezione critica che si basi sul
comando atomico compare-and-swap, definito sotto. Programmare poi la soluzione in
Java.
compare-and-swap(common,old,new) =
integer temp
temp ← common
if common = old
common ← new
return temp
5
Riferimenti bibliografici
[1] E.W. Dijkstra. Solution of a problem in concurrent programming control. CACM
8(9):569, Settembre 1965.
6
1 class CSFetchAndAdd extends Thread {
2 static volatile int common = 0; // common sara’ *guardata* da lock
3
4 static volatile Object lock = new Object();
5 // ogni accesso a common va dentro blocco
6 // synchronized guardato da lock
7
8
9 int local ;
10 int id ; // campo identità del thred
11
12 CSFetchAndAdd(int myid){ //costruttore, inizializza i campi del thread
13 this . local =0;
14 this . id =myid;
15 }
16
17 public void run() {
18 for (int i = 0; i < 10; i ++) {
19 System.out.println (”Process ” + id + ” in NCS”); // NCS
20 do { // pre =protocol
21 synchronized(lock){ // Fetch&Add = blocco synchronized:
22 local = common; // esecuzione atomica degli assegnamenti
23 common = common + 1;
24 }
25 } while (local !=0);
26 System.out.println (”Process ” + id + ” starts CS”); // CS
27 System.out.println (”Process ” + id + ” ends CS”);
28 synchronized(lock){ // post=protocol
29 common = 0; // accesso a common guardato da lock
30 }
31 }
32 }
33
34 public static void main(String[] args ) {
35 CSFetchAndAdd p = new CSFetchAndAdd(1);
36 CSFetchAndAdd q = new CSFetchAndAdd(2);
37 p. start ();
38 q. start ();
39 try {
40 p. join ();
41 q. join ();
42 }
43 catch (InterruptedException e) { }
44 System.out.println (”Program terminated.”);
45 }
46 }
7
Figura 1: Listing della classe CSFetchAndAdd.java.
class CSFetchAndAdd extends Thread {
static volatile int common = 0; // common sara' *guardata* da lock
int local;
int id; // campo identit� del thred
1
Spesso abbrevieremo le formule atomiche relative ai control pointer omettendo il
nome del control pointer, se chiaro dal contesto, e il segno ’=’: cosı̀ “cP = p1”
diventa semplicemente “p1”, etc., e la formula sopra si scrive
def
A = p1 ∧ q1 ∧ ¬wantp ∧ ¬wantq .
Ogni formula logica può essere interpretata in uno stato qualsiasi del programma:
nello stato dato, essa risulterà o vera o falsa, secondo i valori delle variabili nello
stato. Dunque, la medesima formula può risultare vera in alcuni stati, e falsa in
altri. Se una formula A è vera quando interpretata in uno stato s, diremo anche che
s soddisfa A e scriveremo
s ⊨ A.
Esempio 1. La formula A scritta in precedenza risulta vera nello stato
s = (p1, q1, f alse, f alse)
mentre risulta falsa negli stati
s′ = (p1, q2, f alse, f alse)
s′′ = (p1, q1, true, f alse).
Ancora, la formula
C = p4 ∧ q4
è vera esattamente in tutti gli stati in cui P e Q sono entrambi in CS. Quindi questa
formula è vera in uno stato che viola la ME, se un tale stato esiste nel diagramma
degli stati. Dunque la formula
B = ¬C = ¬(p4 ∧ q4)
è vera in uno stato se e solo se esso non viola la ME. Dunque, posso esprimere la
ME per l’algoritmo Terzo tentativo in questo modo
ME (Terzo Tentativo) = in tutte le computazioni, in tutti gli stati, è vera
la formula
B = ¬(p4 ∧ q4).
Riferendosi alla spiegazione intuitiva delle proprietà di Safety, vista in una lezione
precedente, vediamo che la formula C dell’esempio gioca il ruolo di evento cattivo, e
la B quello di evento buono. Questa situazione è abbastanza generale per la Safety,
e possiamo darle un nome.
2
Definizione 1 (invariante). Dato un programma concorrente, diremo che la formula
logica A è un suo invariante se A è vera in tutti gli stati raggiungibili del programma.
(a) Caso base. Dimostrare che A è vera nello stato iniziale del programma.
(b) Passo induttivo. Assumere che A sia in nello stato corrente s, qualunque esso
sia (ipotesi induttiva), e dimostrare che A vale anche in qualsiasi stato s′ in
cui si può arrivare da s con una transizione (cioè in qualsiasi stato s′ tale che
s→s′ ).
Il principio è, in effetti, è una semplice conseguenza del familiare principio di indu-
zione sui numeri naturali. Per quanto riguarda il passo induttivo, si noti che, per
un dato stato s, ci possono in generale essere più stati s′ tali che s→s′ (nondeter-
minismo), o anche nessuno (stato s terminato). Vediamo un semplice esempio, per
cominciare.
3
Algorithm: Dekker’s algorithm
boolean wantp ← false, wantq ← false
integer turn ← 1
p q
loop forever loop forever
p1: non-critical section q1: non-critical section
p2: wantp ← true q2: wantq ← true
p3: while wantq q3: while wantp
p4: if turn = 2 q4: if turn = 1
p5: wantp ← false q5: wantq ← false
p6: await turn = 1 q6: await turn = 2
p7: wantp ← true q7: wantq ← true
p8: critical section q8: critical section
p9: turn ← 2 q9: turn ← 1
p10: wantp ← false q10: wantq ← false
Usiamo il principio di induzione computazionale.
(a) Caso base. Nello stato iniziale, turn = 1, dunque A è vera.
(b) Passo induttivo. Sia s uno stato qualsiasi del programma. Supponiamo che
A sia vera in s e che s→s′ . Dobbiamo dimostrare che anche in s′ A è vera.
Procediamo prendendo in considerazioni tutti i possibili comandi atomici che
possono dar luogo alla transizione s→s′ . Distinguiamo due casi.
Dal momento che abbiamo dimostrato sia il caso base che il passo induttivo, per il
principio di induzione computazionale possiamo concludere che A è vera in tutti gli
stati raggiungibili del programma, cioè essa è un invariante.
Nel seguito useremo la notazione A ⇒ B per denotare l’implicazione materiale,
cioè
A ⇒ B è un’abbreviazione per la formula logica ¬A ∨ B.
Si ricordi che la formula A ⇒ B, per definizione, è falsa solo quando A (la premessa)
è vera e B (la conseguenza) è falsa; è vera in tutti gli altri casi.
4
Esempio 3. Dimostrare che la formula F = p3 ⇒ wantp è un invariante per
l’algoritmo Terzo Tentativo, sotto riportato.
Algorithm: Third attempt
boolean wantp ← false, wantq ← false
p q
loop forever loop forever
p1: non-critical section q1: non-critical section
p2: wantp ← true q2: wantq ← true
p3: await wantq = false q3: await wantp = false
p4: critical section q4: critical section
p5: wantp ← false q5: wantq ← false
Procediamo per induzione computazionale. Il caso base è banalmente verificato,
perché la premessa è banalmente falsa nello stato iniziale (cP = p1 ̸= p3). Vediamo
il passo induttivo. Assumiamo che, per uno stato qualsiasi s, s soddisfi F (cioé:
F è vera in s) e consideriamo una qualsiasi transizione s→s′ . Il linea di principio,
dobbiamo considerare le transizioni originate da tutti i comandi atomici possibili. In
realtà, poiché F è una implicazione, dobbiamo preoccuparci solo di due tipi di
comandi atomici:
Per tutti gli altri comandi, il valore di verità di F nello stato s′ o diventa vero,
oppure rimane inalterato dallo stato s, dove è vero per ipotesi induttiva: non è
dunque necessario analizzare tali casi esplicitamente.
Per quanto riguarda il caso 1, l’unico comando atomico da considerare è p2: la
sua esecuzione fa passare il control pointer di P a p3, ma pone anche wantp a true,
rendendo dunque la formula F vera in s′ (premessa vera, conseguenza vera).
Per quanto riguarda il caso 2, l’unico comando atomico da considerare è p5: la
sua esecuzione fa passare il control pointer di P a p1, rendendo dunque la formula F
vera in s′ (premessa falsa).
5
i comandi che possono modificare in vera la premessa, A;
i comandi che possono modificare in falsa la conseguenza, B.
Infatti, in tutti gli altri casi, l’esecuzione del comando non può rendere falsa la formula
A ⇒ B in uno stato successivo.
Prova Procediamo per induzione computazionale. Il caso base è banale, visto che
la premessa dell’implicazione è falsa. Vediamo il passo induttivo.
6
def
Lemma 2. H = (wantp ⇒ p3..5) è un invariante per Terzo Tentativo.
Il comando che può modificare in vera la premessa è p2: la sua esecuzione rende
tuttavia vera anche la conseguenza.
Prova Supponiamo, per assurdo, che ci sia una computazione, non necessariamente
completa, che porta ad uno stato di violazione della ME. Sia s′ tale stato e sia s→s′
l’ultima transizione di tale computazione. Ci sono solo due possibilità per quanto
riguarda tale transizione (i puntini indicano che non specifichiamo il valore dei campi
rimanenti):
s = (p4, q3, ...)→(p4, q4, ...) = s′ e dunque l’ultimo comando eseguito è q3, con
wantp = f alse in s;
s = (p3, q4, ...)→(p4, q4, ...) = s′ e dunque l’ultimo comando eseguito è p3, con
wantq = f alse in s.
Supponiamo che valga il primo caso. Per il Lemma 1, deve valere in s la formula G,
s ⊨ G. Dunque, poiché in s vale cP = p4, deve valere anche wantp = true: ma ciò
contraddice wantp = f alse in s, e dà origine ad un assurdo. Analogamente si tratta
il secondo caso (usando l’analogo del Lemma 1 per Q).
7
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 9 - 27/10/2022
1 Introduzione a LTL
La verità o falsità di una formula logica proposizionale è, come sappiamo, univo-
camente determinata una volta fissati i valori di verità delle proposizioni atomiche.
Quando consideriamo un programma concorrente in un certo stato, il valore di verità
delle proposizioni atomiche, e dunque della formula data, è determinato univocamen-
te dal valore delle variabili in quello stato. Tali valori sono naturalmente soggetti
a cambiamento, man mano che il programma viene eseguito e passa da uno stato
all’altro. Per questo, anche la verità o falsità di una formula può cambiare passando
da uno stato all’altro di una computazione. Per esempio, in Dekker, la formula
turn = 1
• always (sempre) 2
• until (finché) U
1
Dunque, sintatticamente, le formule LTL sono tutte le formule che si possono formare
usando i normali operatori booleani, più quelli elencati sopra. È importante sotto-
lineare una differenza rispetto alla normale logica proposizionale: mentre le formule
proposizionali vengono interpretate rispetto ad un dato stato – per cui si può dire
che la formula è vera o falsa in quello stato – le formule LTL vengono interpretate
rispetto ad una data computazione (completa) del programma in esame. Il con-
cetto fondamentale, che andremo a chiarire nel seguito, è dunque il seguente: data
una formula A ed una computazione σ del nostro programma concorrente, dobbiamo
definire cosa significa che σ soddisfa A, ovvero, cosa significa che la formula A è vera
nella computazione σ. Quando σ soddisfa A, scriveremo
σ A.
Passiamo in rassegna gli operatori temporali di LTL uno ad uno, chiarendone il
significato, dapprima in maniera informale. Rimandiamo la definizione formale della
semantica a più tardi. Per chi vuole approfondire l’argomento delle logiche temporali,
[1] rappresenta un ottimo punto di partenza.
1.1 Always
Nel seguito, supponiamo di aver fissato una generica computazione completa di un
programma che vogliamo analizzare.
La formula 2A, da leggere sempre A, è vera se A è vera in tutti gli stati
della computazione.
Volendo rappresentare l’andamento del valore di verità di A in un grafico, in cui
sulle ascisse mettiamo i vari istanti (o stati) della computazione i = 0, 1, 2, ..., e sulle
ordinate il valore di verità (true o f alse), otterremmo dunque una linea piatta.
A
true
false
6
i time →
L’operatore 2 è dunque adatto per descrivere concisamente proprietà invarianti, dove
A rappresenta l’evento buono che vogliamo sia sempre vero. Per esempio, nel caso
del Terzo Tentativo, riportato sotto
2
Algorithm: Third attempt
boolean wantp ← false, wantq ← false
p q
loop forever loop forever
p1: non-critical section q1: non-critical section
p2: wantp ← true q2: wantq ← true
p3: await wantq = false q3: await wantp = false
p4: critical section q4: critical section
p5: wantp ← false q5: wantq ← false
la proprietà di ME si scrive cosı̀ in LTL
def
M E(3) = 2¬(p4 ∧ q4).
1.2 Eventually
Il box è tutto ciò che serve per descrivere delle semplici proprietà di Safety, come
appunto gli invarianti. Nel caso della Liveness, le cose sono un po’ più articolate. Un
ruolo fondamentale è giocato dall’operatore eventually.
Si noti che non è richiesto che la formula A rimanga sempre vera da un certo punto
in avanti, ma semplicemente che sia vera in almeno uno stato (istante) della compu-
tazione. Dunque, nel caso ♦A valga per la computazione, l’andamento di A potrebbe
essere rappresentato da un grafico di questo tipo.
A
true
false
6
i time →
Per esempio, nel caso del Terzo Tentativo, potrei considerare la seguente proprietà:
se P si trova in p2 (pre-protocol) allora prima o poi P arriverà in p4 (CS). In LTL,
3
questa proprietà è espressa dalla seguente formula
p2 ⇒ ♦p4 .
Nella SF, richiedo che questo debba avvenire per P ogniqualvolta P si trova in p2 –
e non solo nel primo stato della computazione. Posso esprimere ogniqualvolta come
sempre, ovvero come un always. Dunque
def
SF (P ) = 2 (p2 ⇒ ♦p4)
esprime la SF per il Terzo Tentativo. Come sappiamo, poi, SF non è valida per il
Terzo Tentativo (nemmeno DF lo è). Al momento, però, ci stiamo solo occupando
di come descrivere proprietà significative in LTL, al di là del fatto che poi esse siano
vere o false nel programma considerato, o di come fare a dimostrarle.
1.3 Until
Si tratta di un operatore binario, scritto cosı̀: A U B. La semantica è per certi versi
simile a quella dell’eventually, ma con delle garanzie in più.
La formula A U A, da leggere A finché B, è vera se prima o poi B è vera
in uno stato della computazione, e in tutti gli stati precedenti a questo,
A è vera.
Dunque, come nel caso dell’eventually, richiediamo che prima o poi B sia vera; la
formula A deve, per cosı̀ dire, fare da back-up, rimanendo vera nel frattempo. Non
richiediamo che B rimanga vera per sempre da un certo punto in avanti; né ci importa
della sorte di A dopo che B è diventata vera. Graficamente
A, B
true
false
6
i time →
4
Per esempio, potremmo richiedere che, se P si trova in CS, prima o poi ne esca,
e che nel frattempo Q ne stia fuori
Si noti che questa particolare formula è vera in tutte le computazioni del Terzo
Tentativo.
1.4 Next
Questo operatore può essere usato per dire qualcosa sul prossimo stato della compu-
tazione.
La formula A, da leggere nel prossimo stato A, è vera se A è vera nello
stato successivo al primo.
Dal momento che, per via del nondeterminismo, non c’è di solito alcun controllo su
quello che sarà lo stato successivo di un programma, questo operatore non è molto
usato in pratica (è inserito in LTL per ragioni teoriche riguardanti la completezza
della logica, sulle quali non ci soffermiamo). Esso può comunque risultare utile per
esprimere situazioni senza via d’uscita. Per esempio, se P e Q sono contemporanea-
mente alla terza locazione, ciascuno di loro si troverà ancora alla stessa locazione,
nello stato successivo, per via del deadlock che si è venuto a creare. Posso esprimere
questo in LTL cosı̀
2 ((p3 ∧ q3) ⇒ (p3 ∧ q3)) .
Si noti che questa particolare formula è vera in tutte le computazioni del Terzo
Tentativo.
Esercizio 1. Considerare il seguente programma concorrente.
Algorithm: Assignments
integer x ← 0, y ← 0
p q
p1: x←1 q1: y←1
p2: x←2 q2:
Dire quali di queste formule sono vere in tutte le computazioni complete del pro-
gramma.
5
2 Combinazioni di operatori temporali e leggi de-
duttive
Combinando gli operatori always e eventually si possono esprimere interessanti pro-
prietà. Vediamone due fondamentali.
Ovvero, espandendo 2A
Ovvero, prima o poi (magari dopo molto tempo), A diventa vero in modo definitivo.
Graficamente, l’andamento di A è qualcosa del tipo
A
true
false
6
i time →
6
sempre (cioè da ogni stato), vale ♦A.
Ovvero, espandendo ♦A
sempre (cioè da ogni stato), prima o poi si arriva in uno stato in cui vale
A.
true
false
6
i time →
Ad esempio, potrei esprimere la proprietà che P entra ed esce infinite volte dalla CS
in questo modo
2♦p4 ∧ 2♦p5 .
Notare che questa proprietà non è soddisfatta da Terzo Tentativo, per via del noto
problema del deadlock.
¬2A ⇔ ♦¬A
¬♦A ⇔ 2¬A .
7
¬2A ⇔ prima o poi ¬A ⇔ ♦¬A.
Esistono altre leggi interessanti, per esempio, le seguenti sono abbastanza ovvie ( ⇒
indica qui l’implicazione logica tra formule)
• 22A ⇔ 2A (idempotenza di 2)
• 2(A ∧ B) ⇔ 2A ∧ 2B;
• ♦(A ∨ B) ⇔ ♦A ∨ ♦B.
Esercizio 2. Dire quali implicazioni sono valide tra le formule 2(A ∨ B) e 2A ∨ 2B.
Fare lo stesso con le formule ♦(A ∧ B) e ♦A ∧ ♦B.
Nel loro insieme, queste leggi (e altre ancora, sulle quali qui non ci soffermiamo)
ci aiutano a ragionare sulle proprietà cui siamo interessati, una volta che le abbiamo
formalizzate in LTL. Terminiamo questa breve introduzione informale a LTL con un
esempio.
• V G R
8
Algorithm: Semaforo stradale
p q
integer color ← 0
loop forever
p1: color ← (color+1) mod 3 q1: await false
Formalizziamo alcune proprietà in LTL, e per ciascuna ci chiediamo se il programma
la rispetta: cioè, se la relativa formula è soddisfatta in ogni computazione completa
dallo stato iniziale del programma.
• Sempre, se sono su V prima o poi arriverà R:
2 (V ⇒ ♦R) .
Si vede che tutte le proprietà considerate sono rispettate dal programma, a patto di
assumere fairness, per cui a P viene ogni tanto data la possibilità di essere eseguito.
Esercizio 3. Considerare il programma Concurrent Counting, sotto descritto.
Algorithm: Concurrent counting algorithm
integer n ← 0
p q
integer temp integer temp
p1: do 10 times q1: do 10 times
p2: temp ← n q2: temp ← n
p3: n ← temp +1 q3: n ← temp + 1
Formalizzare in LTL le seguenti proprietà.
1. Prima o poi il programma termina.
2. Se termina, il valore finale di n è 20.
Per ciascuna proprietà, dire se essa è o meno soddisfatta da tutte le computazioni
del programma.
9
3 Semantica formale di LTL
Diamo ora la semantica rigorosa di LTL, senza la quale sarebbe impossibile parlare
di dimostrazioni e garanzie formali di correttezza. Tale semantica è inoltre indispen-
sabile per lo sviluppo di tool di verifica automatica, come i model checker di cui par-
leremo in una lezione successiva. Ricordiamo che, dato un programma concorrente,
una computazione
σ = s0 , s1 , s2 , ...
è una sequenza di stati collegati da transizioni: s0 →s1 → · · · . Le computazioni pos-
sono essere di lunghezza finita o infinita. Tuttavia, nel trattamento di LTL convie-
ne restringersi a computazioni complete infinite. Non c’è perdita di generalità in
questo: una computazione completa finita, il cui l’ultimo stato sk è terminato, vie-
ne rappresentata semplicemente ripetendo tale stato infinite volte; cioè si considera
σ = s0 , s1 , ..., sk , sk+1 , sk+2 , ... con sk = sk+1 = sk+2 = · · · .
Nel seguito, denoteremo con si l’i-mo stato di una data computazione σ, per
i = 0, 1, .... Inoltre, denoteremo con σi la computazione ottenuta eliminando i primi
i stati da σ, cioè la computazione σ presa dall’istante i ≥ 0 in poi:
def
σi = si , si+1 , si+2 , ...
Come abbiamo detto, la relazione di base in LTL è quella che dice quando una
computazione soddisfa (o rispetta) una formula, cioè quando la formula è vera se
interpretata nella computazione. Questo è scritto come
σ A.
Di seguito definiremo questa relazione, per arbitrari σ e A, per induzione strutturale
su A. Prima definiamo esplicitamente la sintassi di LTL.
Definizione 1 (sintassi di LTL). Sia P r un qualsiasi programma concorrente. La
sintassi di LTL è data dalla seguente grammatica, dove ’a’ denota una generica
formula atomica riguardante il programma concorrente P r.
A, B ::= a | A ∨ B | A ∧ B | ¬A | 2A | ♦A | A U B | A.
L’intuizione dietro la definizione di σ A è che, se A è una pura formula pro-
posizionale (non contiene operatori temporali), essa va interpretata nel primo stato
della computazione (s0 ), nel modo usuale. Se A contiene operatori temporali, allo-
ra dobbiamo spostarci lungo la computazione come prescritto da tali operatori, per
controllare cosa succede nei vari stati.
10
Definizione 2 (soddisfacibilità tra computazioni e formule). σ A è definita in
maniera induttiva come segue.
• σ a se e solo se a è vera in s0 .
• σ A ∨ B se e solo se o σ A oppure σ B.
• σ A ∧ B se e solo se σ A e σ B.
• σ ¬A se e solo se non σ A.
• σ 2A se e solo se ∀i ≥ 0: σi A.
• σ ♦A se e solo se ∃i ≥ 0: σi A.
• σ A se e solo se σ1 A.
A questo punto possiamo definire formalmente cosa vuol dire che un programma
soddisfa (o rispetta) una formula.
Si noti che insistiamo sul fatto che tutte le computazioni (fair) del programma
devono soddisfare la formula data. Questo è naturalmente necessario per rendere la
nozione di correttezza significativa. È anche ciò che rende le prove di correttezza
nella programmazione concorrente difficili.
Riferimenti bibliografici
[1] M. Ben-Ari. Mathematical Logic for Computer Science, 3/e. Springer, ISBN
978-1-4471-4128-0, 2012.
11
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 10 - 3/11/2022
1 Motivazioni
In questa lezione cominceremo a mettere a frutto quello che abbiamo appreso nelle
scorse lezioni sulla logica LTL. Ricordiamo che il problema generale è: dati un pro-
gramma concorrente P r ed una proprietà desiderabile del nostro programma (ME,
DF, SF, ...), formalizzata come una formula LTL A, essere in grado di dimostrare, o
confutare, che vale
Pr A.
Come sappiamo, questo vuol dire dimostrare che
∀σ : σ A
dove σ qui varia fra tutte le computazioni complete che partono dallo stato iniziale
di P r. È proprio in questa quantificazione universale, necessaria per via del non-
determinismo, che risiede la difficoltà del dimostrare la correttezza dei programmi
concorrenti. Questo compito può essere affrontato essenzialmente in uno di due modi.
(a) Mediante dimostrazioni basate sulle regole inferenziali di LTL, condotte ma-
nualmente: prove deduttive.
(b) Mediante l’utilizzo di software di verifica automatica: model checking.
Nel seguito daremo un esempio di tutti e due i tipi di dimostrazione, poi ne valuteremo
pro e contro.
1
correttezza, possono lasciare un margine dubbio. Procederemo ora ad una prova
deduttiva della SF per Dekker. La proprietà di ME è più facile da dimostrare e viene
tralasciata. La DF sarà naturalmente conseguenza della SF.
Proveremo la SF per Dekker sotto una ipotesi semplificatrice, e cioè che prima o
poi NCS termina, consentendo di passare a p2 o q2: progress per NCS. Si noti che
il risultato è vero anche senza questa ipotesi, ma richiede una dimostrazione più
complessa.
Teorema 2 (SF per Dekker). Sotto l’assunzione di fairness e di progress per la NCS,
Dekker soddisfa la SF.
2
Prova Dobbiamo dimostrare che per ogni computazione completa σ di Dekker, che
parta dal suo stato iniziale, vale che σ SF . Procediamo per assurdo, e supponiamo
che esista una computazione completa σ in cui ciò non vale: σ 6 SF . Supponiamo,
senza perdita di generalità, che il processo starved in tale computazione sia P . Dun-
que, una volta rimossa una parte in iniziale, otteniamo da σ una computazione che
soddisfa
p2 ∧ ¬♦p8 . (1)
Adesso, il passo fondamentale è mostrare che, in questa computazione, vale
♦(turn = 1) . (2)
Infatti se, al contrario, fosse 2(turn = 2), per progress P prima o poi arriverebbe
in p6 (non potendo arrivare in p8 quando esegue p3), e ci rimarrebbe dunque per
sempre. Per via dell’invariante 2 del Lemma 1, questo implicherebbe che ♦2¬wantp.
Per progress, allora, prima o poi Q eseguirebbe q3 trovando wantp falso, dunque
entrerebbe in CS, e poi ne uscirebbe mettendo turn = 1, contro quanto assunto. Ora
(2) può in realtà essere rafforzata in
♦2(turn = 1) (3)
cioè, prima o poi turn diventa 1 in maniera definitiva, nella computazione data.
Infatti, per ipotesi di assurdo (1), P non arriverà mai in p8 e dunque in p9.
Esaminando il codice P , allora, l’unico comportamento compatibile col fatto che
esso per fairness deve essere eseguito, ma che non arrivi mai in p8, mentre turn = 1
definitivamente, è che da un certo punto in poi P rimanga sempre tra le locazioni
p3 e p4 (si consideri la prima volta che viene eseguito p3, dal momento in cui turn è
definitivamente 1). Dunque
♦2(p3 ∨ p4) . (4)
Da quest’ultimo fatto, e dall’invariante 2 del Lemma 1, ricaviamo che da un certo
punto in poi, nella computazione, wantp è definitivamente vero
♦2wantp . (5)
Dunque, mettendo insieme (3) e (5), otteniamo, che da un certo punto in poi,
definitivamente wantp è vero e turn = 1
3
Esaminando ora ciò che può fare Q, da quel punto in poi, per fairness otteniamo che
prima o poi Q arriverà in q6, e vi rimarrà per sempre. Dunque, ♦2q6. Ma questo,
per l’invariante 3 del Lemma 1, implica che
♦2¬wantq, . (7)
Ora, la prima volta che P eseguirà p3 (v. (4)), dal punto in cui wantq è definitivamen-
te falso, salterà in p8: ma questo è assurdo, perché avevamo assunto il contrario.
la risposta ’Sı̀’, se P r A;
4
intero il diagramma degli stati: è sufficiente visitare il grafo secondo una strategia
di esplorazione predefinita, di solito depth-first (in profondità). Gli stati vengono
generati man mano che si procede nella visita, calcolando le transizioni possibili a
partire dallo stato corrente. In tal modo, solo una porzione del grafo viene mantenuta
in memoria, in una opportuna struttura dati. Se si adotta una strategia depth-
first, questa porzione è semplicemente il percorso dalla radice al nodo corrente, e la
struttura dati è una pila (LIFO). Si osservi la figura sottostante, dove la numerazione
dei nodi riflette l’ordine di visita.
Liveness Nel caso delle proprietà di liveness, il procedimento di verifica può essere
notevolmente più complicato. In questo caso, infatti, bisogna andare a caccia non di
5
un singolo stato, ma di una intera computazione che viola la proprietà data, sempre
che tale computazione esista. Accenniamo qui ad un caso particolare, quello in cui
la proprietà sia del tipo
2♦A .
Ossia, A (formula proposizionale) è vera infinitamente spesso. L’algoritmo di model
checking parte dalla negazione di questa proprietà
¬2♦A ⇔ ♦2¬A .
Se si trova una computazione σ che soddisfa la proprietà ♦2¬A, allora si sarà trovato
un controesempio alla proprietà data 2♦A. Altrimenti la proprietà 2♦A è vera.
Dunque il compito del model checker si riduce a quello di cercare una computazione
in cui, da un certo punto in poi, ¬A vale sempre. Questa computazione, se esiste,
corrisponde nel diagramma degli stati ad un percorso che parte dalla radice, ed è
composto da un prefisso detto trail, che porta ad uno stato da cui parte un ciclo
di accettazione: un ciclo, cioè, nei cui stati vale sempre ¬A. Tale ciclo può essere
espanso all’infinito, per generare una computazione che soddisfa ¬2♦A, e che quindi
viola 2♦A (si veda la figura sottostante).
Il compito del model checker è dunque, più precisamente, quello di trovare tali cicli
di accettazione, che confutano la proprietà di interesse, se tali cicli esistono. Non
appena un ciclo di accettazione è stato trovato, la ricerca può fermarsi. Se nessun
ciclo di accettazione viene trovato, la proprietà originaria è soddisfatta.
Stato dell’arte nel model checking Ci sono naturalmente delle difficoltà pra-
tiche in questo procedimento, legate soprattutto alla dimensione dello spazio degli
stati del programma. Abbiamo visto, con qualche esempio, che tale dimensione tende
a crescere esponenzialmente, man mano che crescono il numero di processi e la loro
6
complessità: si parla di esplosione degli stati. Allo stato attuale, si stima che una
procedura esaustiva di model checking possa essere applicata a sistemi software con
un numero di stati dell’ordine di 109 . Se lo spazio degli stati è troppo grande, o addi-
rittura infinito, la procedura di ricerca esaustiva non potrà essere portata a termine1 .
In tal caso, il model checker punta piuttosto a trovare un controesempio, usando al
meglio le risorse di tempo e memoria a sua disposizione. Se il controesempio viene
trovato, il compito del model checker si può dire assolto; altrimenti il model checker
dovrà ad un certo punto terminare la ricerca prima di aver esplorato completamente
lo spazio degli stati e, per cosı̀ dire, arrendersi. Soprattutto nelle fasi iniziali della
progettazione, la scommessa dello sviluppatore, quando decide di servirsi del model
checking, è che un errore verrà probabilmente trovato: sfortunatamente, è molto più
facile scrivere programmi scorretti che programmi corretti.
Per quanto riguarda la disponibilità di tool di model checking, ci limitiamo qui a
menzionare, tra i tanti esistenti, il model checker SPIN, sviluppato da G. Holzmann
[4]. Esso si basa sul linguaggio Promela per la specifica di programmi concorrenti,
e sulla logica LTL. A partire da SPIN è stato sviluppato, presso l’Ames Research
Center della NASA, un model checker che accetta direttamente codice Java, il Java
Pathfinder (JPF), [1]. Questi, e altri strumenti che qui non citiamo, sono stati
applicati con successo alla verifica automatica di sistemi reali anche di una certa
complessità. Per esempio, JPF è stato impiegato dalla NASA nella validazione del
sistema operativo della sonda Deep-space 1. Una introduzione sistematica alla teoria
avanzata del model checking è il testo [2].
7
semplificato che abbiamo utilizzato nel corso, dal quale si discosta per l’aggiunta di
un po’ di zucchero sintattico.
Le due keyword active proctype vengono usate per dichiarare un processo, istanziarlo
dandogli un nome e allo stesso tempo renderlo eseguibile. Le formule logiche LTL da
verificare vengono dichiarate mediante la keyword ltl nell’intestazione. La proprietà
di safety norace qui definita è un invariante: esso richiede che, sempre, se p è termi-
nato (term=1=true) allora il valore della variabile y è 5; la qual cosa è evidentemente
falsa.
Lanciamo ora Erigone, dalla sua GUI, chiamata EUI. Una volta aperta l’appli-
cazione, apriamo il file testo race.pml, dove avremo in precedenza copiato il codice
di cui sopra. Ci troveremo di fronte a questa finestra, con due sotto-finestre: quella
di sinistra che mostra il nostro codice, quella di destra che mostrerà i messaggi di
Erigone.
8
Per verificare la formula, scriviamo il suo nome (norace) sulla finestrella “LTL name”
dell’applicazione e poi clicchiamo sul bottone “Safety”. Ecco il risultato che apparirà
sulla finestra destra dell’applicazione (vengono omessi messaggi relativi all’uso delle
risorse e altre informazioni secondarie).
9
x, y (i control pointer non sono esplicitamente visualizzati, ma sono facilmente de-
sumibili). La parte con intestazione Process Statement dice invece quale comando
è eseguito per passare allo stato (riga) successivo. Esaminando la tabella, è facile
capire cosa è successo: l’assegnamento a y è stato eseguito per primo, quando ancora
x=1, e dunque il valore finale di y è 10 in questa computazione. Questo è un tipico
caso di corsa critica: vuol dire che il fatto che la proprietà venga violata o no, in una
data computazione, dipende dalla velocità relativa dei due processi. Informalmente,
se Q è più veloce di P , o viene eseguito per primo, la proprietà verrà violata nella
computazione data.
do
...
od
while (true){
...
}
Il corpo del do deve essere un comando con guardie (guarded command), con questa
sintassi
10
:: b1 -> S1
...
:: bn -> Sn
Ciascuna riga viene detta alternativa. Le bi sono espressioni booleane, e le S1, S2,...
sono sequenze di comandi. La semantica è che, ad ogni iterazione del ciclo, se è
vera la condizione (guardia) bi, allora l’alternativa Si può essere scelta ed eseguita,
mentre le altre alternative vengono scartate. Se più alternative sono possibili, ne
viene scelta una in maniera nondeterministica. La guardia else è, per definizione,
vera esattamente quando nessuna delle altre guardie è vera. Dunque il comando con
due alternative
:: b -> S1
:: else -> S2
verification terminated=successfully,
Questo ci conferma che, sotto assunzione di fairness, la proprietà è vera, cioè, in ogni
computazione fair prima o poi flag diventa true.
verification terminated=successfully,
In assenza di proprietà specificate, questo risultato vuol dire che non sono stati rilevati
stati di deadlock. Infine, proviamo a verificare nostarve, col bottone “Fairness”. Il
risultato è, come atteso, che un ciclo di accettazione viene trovato.
11
verification terminated=acceptance cycle,
line=15,statement={wantP = true},
Run a guided simulation to see the counterexample
if
...
fi
Esso ha una sintassi simile al do, in particolare richiede come corpo un comando
con guardie. Semanticamente, una volta solamente (anziché per sempre, come nel
do), una e una sola delle alternative, tra quelle che hanno la guardia verificata, verrà
scelta ed eseguita, e le altre scartate. In secondo luogo, si noti il comando alla linea
18,
(turn == 1);
await(turn = 1);
12
In generale, il comando await(b) si esprime semplicemente scrivendo l’espressione
booleana b (v. anche linea 39).
Sottoponendo ad Erigone le formula LTL mutex (bottone “Safety”) e nostar-
ve (bottone “Fairness”), otteniamo la risposta attesa, cioè che le proprietà sono
soddisfatte.
Esercizio 1 (semaforo stradale). Si consideri l’esempio del semaforo per il controllo
del traffico. La sua versione Promela è riportata sotto. Si noti, in q, l’uso del comando
true, che viene immediatamente eseguito (simile a skip in certi linguaggi), all’interno
di un do: questo ha l’effetto di un ciclo di attesa attiva. Verificare tramite Erigone
che tutte e quattro le proprietà LTL specificate valgono (sotto fairness).
1 byte x = 1;
2
3 ltl f1 { []( (x==0) => <>(x==2) ) }
4
5 ltl f2 { []( (x==0) => ((!(x==2))U(x==1)) ) }
6
7 ltl f3 { []<>(x==0) }
8
9 ltl f4 { []( (x==2) => (!(x==1)U(x==0) )) ) }
10
11 active proctype p() {
12 do
13 :: x=0;
14 x=1;
15 x=2;
16 od;
17 }
18
19 active proctype q() {
20 do
21 :: true;
22 od;
23 }
13
casi, la dimostrazione può essere condotta con l’assistenza di un dimostratore au-
tomatico di teoremi, ma rimane la necessità di formulare gli appropriati enunciati
intermedi (lemmi).
Le verifiche condotte mediante model checking hanno l’indubbio vantaggio di
essere automatiche, richiedendo l’intervento umano solo nella fase di specifica della
proprietà. D’altra parte, esse si scontrano con il limite dell’esplosione degli stati, che
rende la verifica automatica di sistemi complessi praticamente impossibile, almeno
allo stato attuale.
In generale, la verifica formale dei programmi concorrenti rimane un compito
difficile, per il quale non esistono ricette sempre valide. Ogni caso fa storia a sé
e richiederà una opportuna combinazione di approcci. È comunque sempre buona
norma, se possibile:
individuare le componenti critiche del sistema (es. protocolli di comunicazione
e sincronizzazione), che dovrebbero essere isolate e analizzate separatamente le
une dalle altre;
costruire una opportuna astrazione del sistema, cioè un modello semplificato,
dove alcuni dettagli ininfluenti sono stati tralasciati: è quello che abbiamo fatto
quando abbiamo modellato la NCS e la CS come semplici comandi atomici; e
ancora di più, quando abbiamo considerato le versioni abbreviate, allo scopo
di ridurre la dimensione dello spazio degli stati.
Esiste un’area di ricerca molto attiva intorno a queste tematiche, nelle quali non
possiamo qui addentrarci.
Riferimenti bibliografici
[1] AA. VV. Pagina di Java PathFinder. https://github.com/javapathfinder,
2020.
[2] C. Baier, J-P. Katoen. Principles of Model Checking. MIT Press, 2008. Dispo-
nibile online a http://is.ifmo.ru/books/_principles_of_model_checking.
pdf.
[3] M. Ben-Ari. The Erigone Model Checker. https://github.com/motib/
erigone, 2020.
[4] G. Holzmann. Spin Model Checker, the: Primer and Reference Manual.
Addison-Wesley Professional, ISBN 0-321-22862-6, 2004.
14
1 bool wantP = false, wantQ = false;
2 bool csp = false;
3 bool csq = false;
4
5
6 ltl mutex { []!( csp && csq) }
7 ltl nostarve { []<>csp }
8
9 active proctype P() {
10 do
11 :: wantP = true;
12 do
13 :: wantQ =>
14 wantP = false;
15 wantP = true
16 :: else => break
17 od;
18 csp = true;
19 csp = false;
20 wantP = false
21 od
22 }
23
24 active proctype Q() {
25 do
26 :: wantQ = true;
27 do
28 :: wantP =>
29 wantQ = false;
30 wantQ = true
31 :: else => break
32 od;
33 csq = true;
34 csq = false;
35 wantQ = false
36 od
37 }
15
1 bool wantp = false, wantq = false;
2 byte turn = 1;
3 bool csp = false, csq = false;
4
5 ltl mutex { []!( csp && csq) }
6 ltl nostarve { []<>csp && []<>csq }
7
8 active proctype p() {
9 do
10 :: wantp = true;
11 do
12 :: ! wantq => break;
13 :: else =>
14 if
15 :: (turn == 1)
16 :: (turn == 2) =>
17 wantp = false;
18 (turn == 1);
19 wantp = true
20 fi
21 od;
22 csp = true;
23 csp = false;
24 wantp = false;
25 turn = 2
26 od
27 }
28
29 active proctype q() {
30 do
31 :: wantq = true;
32 do
33 :: ! wantp => break;
34 :: else =>
35 if
36 :: (turn == 2)
37 :: (turn == 1) =>
38 wantq = false;
39 (turn == 2);
40 wantq = true
41 fi
42 od;
43 csq = true;
44 csq = false;
45 wantq = false;
46 turn = 1
47 od
48 }
16
Figura 2: Listing del programma Promela dekker-two.pml.
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 11 - 9/11/2022
1 Motivazioni
Le soluzioni al problema della CS viste fin qui si basano sull’attesa attiva. Queste
soluzioni hanno due inconvenienti.
Sono complesse, anche dal punto di vista delle prove di correttezza. Questa
complessità trae origine dallo scarso controllo sul nondeterminismo disponibile
nel linguaggio fin qui considerato: con le primitive considerate, risulta difficile
limitare le computazioni a sole quelle desiderabili.
Una soluzione a questi problemi può arrivare introducendo dei meccanismi di atte-
sa passiva. L’idea è semplice: quando una certa condizione necessaria perché un
processo P possa andare avanti è non verificata (es. CS non libera), l’esecuzione di
P viene temporaneamente sospesa. P ritornerà eseguibile solo quando la condizione
tornerà ad essere verificata (es. CS libera). In questo modo, P non sottrae tempo di
CPU prezioso ai processi che sono invece in grado di progredire; e dunque anche in
grado di rendere prima o poi vera la condizione per cui P potrà ritornare eseguibile.
Il più elementare dei meccanismi di attesa passiva è quello basato su strutture
chiamate semafori. Queste strutture furono introdotte da Dijkstra nel 1962 [1]. Per
realizzare questo, come altri meccanismi di attesa passiva, l’idea è di affidarsi in
parte alla gestione dei processi che fa il Sistema Operativo: grazie allo scheduler, il
Sistema Operativo ha pieno controllo su quali processi possono o no essere eseguiti in
ogni momento, in un un dato sistema. Vediamo dunque qualche dettaglio di questo
aspetto.
1
2 Il ciclo di vita di un processo
I processi hanno un ciclo di vita, caratterizzato da diverse fasi, come illustrato nel
diagramma sottostante. Essenzialmente, quando un processo viene dichiarato e istan-
ziato è ancora in fase inactive e non eseguibile. Esso sarà ad un certo punto reso
eseguibile o ready (si pensi al significato del metodo start() in Java). Secondo la poli-
tica di scheduling del sistema, esso potrà essere messo in esecuzione sulla CPU, cioè
divenire running, a turno con gli altri processi ready. Potrà però anche accadere che,
per qualche ragione, la sua esecuzione debba essere sospesa per qualche tempo: in
tal caso, egli esce dal novero dei processi eseguibili, passando ad una fase dormiente
o blocked. Da questa fase, potrà essere risvegliato prima o poi, ritornando alla fase
ready. Infine, il processo potrà prima o poi terminare, entrando nella fase completed.
Tutte le sequenze di passaggi compatibili con le frecce del diagramma sono, in linea
di principio, ammissibili.
inactive - ready - running -completed
Y
H
HH
H ?
HH
H blocked
Consistentemente con quanto sopra illustrato, assumeremo d’ora in poi che ogni
processo P disponga anche di una variabile locale P.phase (non accessibile al pro-
grammatore), che dice in quale delle cinque fasi esso si trova, in ogni momento (NB:
il libro di testo di Ben-Ari chiama questa variabile P.state).
2
di un altro automobilista, oppure il ritorno al verde del semaforo, se non c’è nessuno
in attesa.
Le azioni di guardare il semaforo e di segnalare l’uscita dall’incrocio corrispondono
a due primitive (comandi atomici) che agiscono sul semaforo, e che chiameremo
rispettivamente wait e signal.
Formalmente, un semaforo è una struttura dati S=(S.V,S.L), dove S.V è una
variabile intera (n. permessi) e S.L è un insieme di identificatori di processo (chiamato
per motivi storici coda). Un semaforo viene inizializzato mediante la dichiarazione
S ← (k,∅)
dove k è un intero ≥ 0. I comandi atomici wait(S) e signal(S) sono definiti come
segue, quando eseguiti da un processo P:
def
wait(S) = h
if S.V>0
S.V ← S.V=1
else
P.phase ← blocked
S.L ← S.L ∪ {P}
i
def
signal (S) = h
if S.L=∅
S.V ← S.V+1
else
pick Q from S.L
Q.phase ← ready
S.L ← S.L \ {Q}
i
La situazione S.V=0 (0 permessi) vuol dire semaforo rosso, mentre S.V>0 vuol
dire semaforo verde. Dunque, la wait provoca il blocco del processo se il semaforo è
rosso e il suo inserimento in coda; altrimenti provoca il decremento del n. di permessi.
La signal guarda l’insieme S.L: se esso è vuoto, il n. di permessi viene incrementato,
altrimenti, un processo tra quelli in attesa nella coda del semaforo viene prelevato
(pick) e reso nuovamente eseguibile. Si noti che qui non specifichiamo la politica
di prelievo da S.L, che dunque non è necessariamente FIFO (il termine coda viene
3
adottato per motivi storici, ma rappresenta un po’ un abuso di terminologia).
Per quanto riguarda i control pointer, si assume quanto segue:
2. nell’esecuzione della signal, ramo else, il control pointer del processo sbloccato,
Q, viene fatto avanzare, portandolo alla locazione successiva a quella della wait
che l’aveva bloccato (wait completata).
Si noti che le operazioni wait e signal su S sono per definizione atomiche: non
c’è alcuna difficoltà implementativa in questo, se le pensiamo delegate al Sistema
Operativo, che ha pieno controllo su chi può andare in esecuzione in ogni momento.
La keyword binary qui segnala semplicemente che la variabile S.V può assumere
uno di due valori, 1 o 0. Allo scopo di costruire il diagramma degli stati, consideriamo
la versione abbreviata.
4
Algorithm: Critical section with semaphores (two proc., abbrev.)
binary semaphore S ← (1, ∅)
p q
loop forever loop forever
p1: wait(S) q1: wait(S)
p2: signal(S) q2: signal(S)
Si noti che da ciascuno dei due stati più a destra nel diagramma, esce una sola
transizione, quella del processo non bloccato. Dal diagramma si evince immediata-
mente la correttezza dell’algoritmo. La ME vale perché non ci sono stati (p2, q2, ...).
La SF vale sotto assunzione di fairness, perché non ci sono stati di deadlock, e
qualunque ciclo (fair) nel grafo prevede il passaggio di P da p2 e di Q da q2.
5 Il caso di CS di N processi
L’algoritmo con semaforo visto per la CS per il caso di due processi si estende facil-
mente al caso generale di N ≥ 2 processi. In effetti, non c’è alcuna modifica a livello
dei singoli processi: essi eseguono tutti lo stesso codice (quello eseguito da P e Q
nel caso N = 2). Si vede facilmente che ME e DF continuano a valere. Tuttavia,
la SF cessa di valere per questo semplice algoritmo, se N ≥ 3. La ragione risiede in
questo: per certe computazioni, quando una signal viene eseguita, ci sono sempre due
processi in coda, di cui uno è Q. Tuttavia, la pick non sceglie mai Q. Un esempio di
tale computazione è riportato qui sotto, sotto forma di scenario.
5
n Process p Process q Process r S
1 p1: wait(S) q1: wait(S) r1: wait(S) (1, ∅)
2 p2: signal(S) q1: wait(S) r1: wait(S) (0, ∅)
3 p2: signal(S) q1: blocked r1: wait(S) (0, {q})
4 p2: signal(S) q1: blocked r1: blocked (0, {q, r})
5 p1: wait(S) q1: blocked r2: signal(S) (0, {q})
6 p1: blocked q1: blocked r2: signal(S) (0, {p, q})
7 p2: signal(S) q1: blocked r1: wait(S) (0, {q})
Si noti che la computazione infinita risultante è a tutti gli effetti fair: è vero che Q
non viene mai eseguito da un certo punto in poi, ma questo non viola la fairness, che
richiede l’esecuzione di Q solo se il processo è costantemente abilitato, dunque non
bloccato. Un modo di ovviare al problema è imporre una opportuna disciplina alla
coda. Per esempio, possiamo rendere S.L una vera e propria coda FIFO: in tal caso
ogni processo che entri è garantito essere prima o poi prelevato, se vengono eseguite
un n. di signal sufficienti. Un semaforo con disciplina FIFO della coda viene detto
strong semaphore.
Si deve notare che, per motivi di efficienza e sicurezza, non sempre una gestione
FIFO dei processi è desiderabile. Si pensi ad un sistema in cui bisogna dare deter-
minate risposte in tempi brevissimi se arrivano certi segnali esterni (es., il sistema
anti-slittamento dei freni di un’automobile): in tali casi, l’ordine FIFO dei processi
non può essere in generale rispettato.
6 Semafori in Java
In Java, un semaforo è un oggetto della classe Semaphore, definita nel package
java.util.concurrent. La corrispondenza con la sintassi del nostro linguaggio è la
seguente:
Per illustrare l’uso dei semafori, vediamo la versione Java del seguente algoritmo
di conteggio concorrente, dove le operazioni di incremento costituiscono una CS,
protetta da un semaforo S.
6
Algorithm: Concurrent counting with semaphore
integer n ← 0, semaphore S ← (1,∅)
p q
integer temp integer temp
p1: do 10 times q1: do 10 times
p2: wait(S) q2: wait(S)
p3: temp ← n q3: temp ← n
p4: n ← temp + 1 q4: n ← temp + 1
p5: signal(S) q5: signal(S)
La traduzione più o meno letterale di questo programma in Java è presentata in
Figura 1.
Il metodo acquire() può sollevare un’eccezione: ciò accade se il thread invocante
viene interrotto mentre attende in coda. Dunque tale metodo va invocato dall’interno
di un blocco try-catch. Per quanto riguarda la semantica, abbiamo quanto segue.
7
1 import java.util . concurrent. Semaphore;
2 class CountSem extends Thread {
3 static volatile int n = 0;
4 static Semaphore s = new Semaphore(1);
5
6 public void run() {
7 int temp;
8 for (int i = 0; i < 10; i ++) {
9 try {
10 s. acquire ();
11 }
12 catch (InterruptedException e) {}
13 temp = n;
14 n = temp + 1;
15 s. release ();
16 }
17 }
18
19 public static void main(String[] args ) {
20 CountSem p = new CountSem();
21 CountSem q = new CountSem();
22 p. start ();
23 q. start ();
24 try { p. join (); q. join (); }
25 catch (InterruptedException e) { }
26 System.out.println (”The value of n is ” + n);
27 }
28 }
Riferimenti bibliografici
[1] E. W. Dijkstra. Over seinpalen (EWD-74) (in Olandese), 1962 (o 1963). E.W.
Dijkstra Archive. Center for American History, University of Texas at Austin.
8
import java.util.concurrent.Semaphore;
class CountSem extends Thread {
static volatile int n = 0;
static Semaphore s = new Semaphore(1);
Invarianti di semaforo.
Il problema produttore-consumatore
Docente: Prof. Michele Boreale (Disia)
1 Invarianti di semaforo
I semafori rendono più semplici le prove logiche, grazie ad alcuni invarianti che hanno
validità generale. Sia S un semaforo dichiarato in un programma concorrente, e σ una
computazione di tale programma. Introduciamo le due seguenti variabili ausiliarie,
associate al semaforo S:
def
#wait(S) = n. di wait(S) completate;
def
#signal(S) = n. di signal(S) completate.
1
sia vera in uno stato s della computazione, e sia s0 il successivo, con s→s0 . Dimo-
striamo che essa vale anche in s0 . Le uniche transizioni da considerare sono quelle
dovute ai comandi che influiscono su S.
wait(S). Se viene preso il ramo then, allora S.V diminuisce di 1 sul lato
sinistro: questo decremento è bilanciato, sul lato destro, da un incremento di
1 di #wait(S), sotto il segno meno. Se invece viene preso il ramo else (wait
non completata), il processo che ha eseguito la wait viene bloccato, e nessuna
quantità di quelle coinvolte varia.
signal(S). Se viene preso il ramo then, allora S.V sul lato sinistro viene incre-
mentata di 1: questo incremento è bilanciato, sul lato destro, da un incremento
di 1 di #signal(S). Se invece viene preso il ramo else, allora S.V , sul lato
sinistro, rimane invariata. Sul lato destro, l’incremento di #signal(S) è com-
pensato da un incremento di #wait(S) sotto il segno meno (la wait del processo
sbloccato Q viene infatti completata).
Vediamo una prova logica di correttezza della soluzione con semafori di CS, che
fa uso degli invarianti appena introdotti. Riportiamo di seguito il programma già
visto nella lezione precedente.
Algorithm: Critical section with semaphores (two processes)
binary semaphore S ← (1, ∅)
p q
loop forever loop forever
p1: non-critical section q1: non-critical section
p2: wait(S) q2: wait(S)
p3: critical section q3: critical section
p4: signal(S) q4: signal(S)
Teorema 2 (correttezza di CS tramite invarianti). La soluzione con semafori a CS
con due processi è corretta.
Prova Si definisca la variabile di stato
def
#CS = n. di processi in CS nello stato dato.
Il passo fondamentale è mostrare che
#CS + S.V = 1 (1)
2
è un invariante. Per far questo, prima notiamo che, per induzione computazionale, è
facile mostrare che
(Questo vuol dire semplicemente che ogni processo che entra in CS completa una
wait, e ogni processo che ne esce completa una signal). A questo punto, ragioniamo
come segue
dove nel primo passaggio abbiamo espanso #CS usando (2) e S.V usando il Teorema
1(b). Dunque (1) risulta dimostrata. A questo punto, notando che S.V ≥ 0 per il
Teorema 1(a), da (1) segue immediatamente che
#CS ≤ 1
3
il completamento di compiti precedenti: in questo caso, i relativi processi devono
essere ritardati, fino al completamento dei compiti da cui essi dipendono. Vediamo
un esempio tipico.
Algorithm: Mergesort
integer array A
binary semaphore S1 ← (0, ∅)
binary semaphore S2 ← (0, ∅)
sort1 sort2 merge
p1: sort 1st half of A q1: sort 2nd half of A r1: wait(S1)
p2: signal(S1) q2: signal(S2) r2: wait(S2)
p3: q3: r3: merge halves of A
È importante notare che, in questo tipo di problemi, a differenza che nella CS, i
semafori vengono inizializzati con un numero di permessi uguale a 0 : sono i processi
stessi (nell’esempio i due sort) a creare i permessi attraverso delle signal, quando si
realizzano le condizioni per farlo. Si noti anche che non ha alcuna rilevanza quale
dei due processi sort termina per primo, perché merge deve in ogni caso aspettare
tutti e due i sort; dunque, sarebbe anche possibile invertire l’ordine delle due wait nel
codice.
In maniera del tutto generale, la relazione di precedenza tra diversi compiti può
essere rappresentata come un grafo orientato, in cui a ciascun arco è associato un
semaforo. Per l’esempio precedente, avremo il grafo rappresentato nella figura sot-
tostante (NB: vedremo in un successivo esercizio che è possibile fare un uso più
parsimonioso dei semafori rispetto a questa soluzione).
4
3 Il problema del Produttore-Consumatore
Questo problema può essere visto come un’istanza della classe di problemi ordine
di esecuzione. Lo trattiamo in qualche dettaglio perché è una situazione piuttosto
diffusa. In maniera del tutto generale, consideriamo uno scenario con due tipi di
processi: i Produttori, che producono dei dati (es: il processo che sovrintende l’I/O,
il processo che gestisce una connessione TCP, etc.); e i Consumatori, che li consu-
mano, cioè li usano in qualche modo (es: il processo editor di testi, il browser, etc.).
Un produttore P e un consumatore C, per cooperare, devono poter comunicare: il
produttore deve poter trasmettere i dati (o messaggi) prodotti al consumatore. Tale
comunicazione può essere di due tipi.
Sincrona. La trasmissione del messaggio ha luogo solo nel momento in cui
sia P che C sono pronti a comunicare. In tal modo, non è necessaria alcuna
struttura dati per memorizzare i dati in transito.
5
alla velocità dell’altro. Infatti, quando P ha prodotto un dato, lo inserisce nel buffer;
quando C vuole consumare un dato, lo preleva dal buffer. Se, a regime, il buffer è non
vuoto e non pieno, nessuno dei due processi subisce rallentamenti a causa dell’altro.
Si noti che questo è possibile solo se le velocità medie di P e C sono uguali: in tal
caso, il buffer serve ad assorbire eventuali fluttuazioni temporanee nella velocità di
uno dei due. Se le velocità medie invece sono diverse, il buffer a regime diventa
inutile: se P è più veloce di C, il buffer a regime tenderà ad essere sempre pieno
(costringendo P ad andare alla velocità di C); analogamente, se C è più veloce di
P , il buffer a regime tenderà ad essere sempre vuoto (costringendo C ad andare alla
velocità di P ).
L’interazione mediata dal buffer ha luogo attraverso l’esecuzione di due primitive:
1. P esegue append(d,buffer);
2. C esegue take(buffer).
I problemi di sincronizzazione nascono dalla necessità di evitare che si verifichino le
due seguenti situazioni anomale:
1. P cerca di eseguire una append su un buffer pieno;
2. DF: non accade mai che si arrivi in una situazione in cui per P non sarà più
possibile inserire, oppure per C non sarà più possibile prelevare.
3. SF: in ogni computazione, se P vuole inserire un dato nel buffer, prima o poi
lo farà; se C vuole prelevare un dato dal buffer, prima o poi lo farà.
Nella soluzione proposta, al solito non importa specificare cosa facciano concreta-
mente i comandi produce e consume né la esatta natura del dato d.
6
Algorithm: Producer-consumer (infinite buffer)
infinite queue of dataType buffer ← empty queue
semaphore notEmpty ← (0, ∅)
producer P consumer C
dataType d dataType d
loop forever loop forever
p1: d ← produce q1: wait(notEmpty)
p2: append(d, buffer) q2: d ← take(buffer)
p3: signal(notEmpty) q3: consume(d)
Si noti che questo programma riflette quanto discusso in precedenza circa i pro-
blemi ordine di esecuzione: i permessi sono creati da P con signal, man mano che i
dati sono inseriti nel buffer, e consumati da C con wait, man mano che i dati vengono
prelevati dal buffer. In particolare, se il buffer è vuoto ci saranno 0 permessi, e una
eventuale wait bloccherà C. È anche immediato constatare che il n. di elementi nel
buffer può crescere in maniera indefinita, se P è più veloce a produrre che C a con-
sumare, possibilità che non può essere esclusa nel nostro modello. Di conseguenza,
anche il diagramma stati-transizioni risulterà infinito. È comunque istruttivo provare
disegnarne i primi stati. Al solito, è opportuno allo scopo considerare una versione
abbreviata del programma.
7
' $' $' $
p1: append, p2: signal(S), p1: append,
q1: wait(S), - q1: wait(S), - q1: wait(S), -
(0, ∅), [ ] (0, ∅), [d] (1, ∅), [d]
& %& %& %
6
'? $ '? $ '? $
p1: append, p2: signal(S), p1: append,
q1: blocked, - q1: blocked, - q2: take, -
(0, {C }), [ ] (0, {C }), [d] (0, ∅), [d]
& %& %& %
8
Prova Si tratta di una semplice induzione computazionale. Oltre al caso base (ba-
nalmente vero), nel passo induttivo è sufficiente verificare che ciascuno dei comandi
atomici preserva l’uguaglianza. Vediamo solo i comandi di P , lasciando la verifica di
C per esercizio:
p1. L’esecuzione incrementa #buf f er sul lato destro e #p2 sul lato sinistro
(passa da 0 a 1).
p2. Se viene eseguito il ramo else della signal, vuol dire che C è su q1: l’ese-
cuzione allora non cambia notEmpty.V , ma lo sblocco di C incrementa #q2
sul lato destro, sotto il segno meno; questo è bilanciato sul lato sinistro da
un decremento di #p2. Se viene invece eseguito il ramo then della signal,
sul lato sinistro allora si ha un incremento di notEmpty.V , compensato da un
decremento di #p2. Il lato destro rimane invariato.
Veniamo alla prova di correttezza, nella quale faremo anche uso degli invarianti
generali dei semafori (Teorema 1)
S.V ≥ 0
S.V = k − #wait(S) + #signal(S)
che sono validi per qualsiasi semaforo S. Si noti che, nell’enunciare la SF, ci dobbiamo
qui preoccupare solo di C, in quanto P non può rimanere bloccato in questa versione
con buffer infinito.
9
Se C si trova in q2, avremo #q2 = 1; inoltre notEmpty.V ≥ 0 e #p2 ≥ 0 sempre.
Dunque il lato destro dell’uguaglianza sopra è ≥ 1 se C è in q2. Di conseguenza,
abbiamo dimostrato che, in qualsiasi stato
q2 ⇒ (#buf f er > 0) .
Per quanto riguarda la SF, supponiamo per assurdo che in qualche computazione
valga ♦2q1. Dal primo stato in cui C rimane bloccato sulla coda del semaforo per
sempre, solo P verrebbe eseguito. Per fairness, prima o poi P eseguirebbe la signal,
che avrà come effetto quello di sbloccare l’unico processo in coda, cioè C, e porlo in
q2, contro l’assunzione di blocco.
10
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 13 - 15/11/2022
Filosofi a cena
Docente: Prof. Michele Boreale (Disia)
1
..............................
................. ...... ...........................
.... ............ ....... ............ .........
...... .. ..... ... ........
... ....... .... ... ......
.....
... . .
.
. .... ... phil4 ..
. . ....
....
.. .. . .
... ... ....
fork4........
.
H . .
... fork0HH ........................
..
j
...
..
... ......................... ........................... ....
.... ........ ... .. .
......... .
..... .. .
. .... .......
...... ............ .. . ...
.
... .. ....
..... .... phil3 .... .....
.... .... phil0 ..... ..
.
. ...
... ... . .
... ....
......... ........... .. .. ..... ... .....
..... ......... ..... Spaghetti .... .......................... ...
...
...
... ..
. ...
.
... . .
... ..... .. ..
... ....... .... ...
* ...............................
YH
H
... H ....
... ............ .............. .
...fork1 .......... ........... ....... .......... fork3....
... ..
. ... .. ..
. ... . ..
... .... phil1 .... .. .. ...
....
....
.... ...... ... 6 ..... phil2 ..... .......
..... ......... ........... ..... . .
...... ......... .......................... ........
........ ...... ....
......... ...
.........
................. fork2 ..........................
...........
..............................
Dalle specifiche, il codice di ogni filosofo Pi (i = 0, ..., 4) deve attenersi allo schema
seguente.
Algorithm: Philosopher i (outline)
loop forever
p1: think
p2: preprotocol
p3: eat
p4: postprotocol
Prima di vedere una proposta di soluzione, specifichiamo le proprietà di correttezza
che richiediamo.
DF. Non si arriva mai ad una situazione in cui uno o più filosofi non potranno
più mangiare.
SF. Se un certo filosofo vuol mangiare, prima o poi quel filosofo mangerà.
2
Algorithm: Dining philosophers (first attempt)
semaphore array [0..4] fork ← [1,1,1,1,1]
loop forever
p1: think
p2: wait(fork[i])
p3: wait(fork[i+1])
p4: eat
p5: signal(fork[i])
p6: signal(fork[i+1])
In altre parole, #Fi è il numero di processi che hanno eseguito wait(f ork[i]), senza
ancora aver eseguito la corrispondente signal(f ork[i]). È facile allora provare che
dove nel primo passaggio abbiamo applicato appunto l’invariante generale, e nel
secondo l’invariante sopra, (1), per #Fi . Dunque
#Fi ≤ 1
3
Purtroppo, la DF non vale per questo programma, ed esiste una computazione
che porta al deadlock: si supponga che ogni processo, a turno, acquisisca la forchetta
alla propria sinistra. A quel punto, tutti sono in attesa della forchetta alla destra,
ma non ci sono più forchette libere, e questa situazione è senza via d’uscita.
Per ovviare a questo problema, abbiamo davanti due strade: una è quella di
limitare l’accesso al tavolo ad un massimo di 4 filosofi alla volta. L’altra è quella di
spezzare la simmetria del programma, rimpiazzando uno dei filosofi con un filosofo
irregolare, che acquisisce le forchette in ordine inverso. Vediamo di seguito la prima
soluzione.
4
Prova La prova di ME è identica a quella del primo tentativo, e viene qui omessa.
Vediamo la SF, che implica anche la DF. Supponiamo per assurdo che in una
certa computazione, il filosofo Pi non arrivi mai a p5, pur passando da p2, cioè,
supponiamo di avere una computazione che soddisfa p2 ∧ ¬♦p5. Ci sono solo tre
possibilità, e cioè che il processo rimanga bloccato per sempre su una wait, in p2, in
p3, o in p4. Consideriamo queste tre possibilità.
Supponiamo ♦2p3. Cioè, il filosofo rimane bloccato per sempre sulla forchetta
alla propria sinistra, f ork[i]. Poiché, dall’invariante (2), f ork[i].V = 0 implica
che #Fi = 1 Deve allora essere un altro processo, quello alla sua sinistra, Pi−1 ,
a detenere la forchetta f ork[i]. Dunque, nel momento in cui Pi si blocca per
sempre su p3, Pi−1 ha eseguito la propria p4, ma non ancora la corrispondente
p7. Ma per fairness, Pi−1 prima o poi mangerà ed eseguira p7, e questo avrà
l’effetto di sbloccare Pi e porlo in p4, contro quanto assunto.
Supponiamo ♦2p4. Cioè, il filosofo rimane bloccato per sempre sulla forchetta
alla propria destra, f ork[i + 1]. Consideriamo in particolare il caso di un Pi
alla cui destra siede un filosofo Pi+1 che non si blocca nella computazione
considerata: dimostreremo più sotto che un tale filosofo deve esistere. Ma
allora nemmeno Pi può bloccarsi per sempre su f ork[i + 1], perché prima o
poi Pi+1 rilascerà tale forchetta. Dimostriamo ora che nella computazione c’è
almeno un filosofo che non si blocca. Questo è una conseguenza del fatto che
nella stanza possono essere presenti al più quattro filosofi. Infatti, se chiamiamo
#R la variabile di stato che ci dice quanti filosofi si trovano nella stanza (le
locazioni tra p3 e p8 comprese), abbiamo questa disuguaglianza
#R = #wait(room) − #signal(room)
= 4 − room.V
≤ 4
dove nella seconda riga abbiamo usato l’invariante generale di semaforo appli-
cato a room: room.V = 4 − #wait(room) + #signal(room).
Supponiamo ♦2p2. Nel momento in cui Pi esegue la wait(room) che lo blocca,
deve essere room.V = 0. Dunque, per il solito invariante di semaforo, deve
essere che nella stanza ci sono 4 filosofi, cioè #R = 4. Abbiamo già dimostrato
che nessun filosofo si blocca su p3 o p4. Dunque, tutti questi filosofi usciranno
prima o poi. Il primo di loro ad uscire eseguirà una signal(room), che avrà
come effetto quello di sbloccare l’unico processo nella coda di room, cioè il
nostro Pi , e porlo in p3, contro quanto assunto.
5
Come preannunciato, quella appena vista non è l’unica soluzione al problema.
Un’altra prevede di sostituire uno dei filosofi, diciamo il n. 4, con uno “irregolare”,
che prende prima la forchetta a destra e poi quella a sinistra, e che dunque esegue
questo programma.
In questo caso, il filosofo regolare P3 , che siede alla sinistra dell’irregolare, non
potrà rimanere bloccato per sempre in attesa della forchetta alla propria destra, e
dunque non potrà rimanere bloccato per sempre. Di conseguenza, neanche il suo vici-
no regolare di sinistra P2 potrà rimanere bloccato, e cosı̀ via. Si vede cosı̀ che nessun
filosofo regolare può rimanere bloccato. Questo implica che nemmeno l’irregolare P4
potrà rimanere bloccato. Omettiamo qui i dettagli della prova di correttezza.
Infine, un’altra soluzione simmetrica prevede che ogni filosofo lanci una moneta
per decidere se prendere per prima la forchetta a destra o a sinistra. Ne risulta un
algoritmo randomizzato, in cui la probabilità di arrivare ad un deadlock decresce
in maniera esponenziale con il numero N di filosofi. L’algoritmo non può essere
descritto in maniera formale nel nostro semplice modello interleaving.
6
Introduciamo infine un tipo di semaforo detto busy wait e basato, come suggerisce
il nome, sull’attesa attiva. In questo tipo di semafori, non esiste il campo coda, S.L.
Dunque, S è identificato con un intero non negativo. Le operazioni di wait e signal
sono cosı̀ definite.
wait(S) = h await(S>0) S← S=1 i
signal (S) = h S ← S+1 i
L’esecuzione del comando atomico complesso <await(b) c> nel nostro modello è la
seguente. Controlla la condizione booleana b: se essa è vera, atomicamente esegui
anche il comando c; altrimenti l’esecuzione lascia lo stato invariato (e dunque il
comando verrà rieseguito).
Notiamo che, benché più semplice da implementare (non è richiesta la gestione
esplicita della coda di processi), il semaforo busy wait soffre dei problemi già discussi,
legati all’attesa attiva in ambienti ad alta contention. Inoltre, esso non assicura la
SF nemmeno nel caso di due processi. Per esempio, nella CS con semafori, caso di
due processi, il seguente scenario è possibile.
n Process p Process q S
1 p1: wait(S) q1: wait(S) 1
2 p2: signal(S) q1: wait(S) 0
3 p2: signal(S) q1: wait(S) 0
4 p1: wait(S) q1: wait(S) 1
Riferimenti bibliografici
[1] C.A.R. Hoare. Communicating Sequential Processes. Prentice Hall International,
1985. Una versione rivista e aggiornata è disponibile a http://www.usingcsp.
com/cspbook.pdf.
3
Ma non coincide: si ricordi che in Java, invocare S.acquire() con S==0 sospende effettivamente
il thread invocante; mentre S.release() effettivamente sblocca un thread sospeso su S, se c’è.
7
Università di Firenze - CdS Triennale in Informatica
1
Algorithm: Algoritmo con semafori e loop
semaphore S ← (1, ∅)
boolean B ← false
P Q
p1: wait(S) q1: wait(S)
p2: B ← true q2: while not B
p3: signal(S) q3: write(”*”)
p4: q4: signal(S)
Soluzione. Il semaforo S offre un solo permesso, che può essere consumato inizialmente
o da P o da Q, a seconda di chi per primo esegue la wait(S). Nel primo caso, il flag B verrà
messo a true; Q potrà arrivare al while dopo che P ha completato la signal; a quel punto,
l’esecuzione del while terminerà immediatamente (B=true). Dunque l’output osservato sarà
la stringa vuota.
In maniera simile, si vede che se muove per primo Q, allora non ci sarà modo che B
venga messo a true, dunque verrà osservata la sequenza infinita “***...”.
Esercizio 3. Riconsiderare lo schema di algoritmo concorrente per il Mergesort visto in
precedenza e richiamato di seguito.
Algorithm: Mergesort concorrente
integer array A
binary semaphore S1 ← (0, ∅)
binary semaphore S2 ← (0, ∅)
sort1 sort2 merge
p1: ordina I metà di A q1: ordina II metà di A r1: wait(S1)
p2: signal(S1) q2: signal(S2) r2: wait(S2)
p3: q3: r3: merge
2
il semaforo S1 viene utilizzato per forzare le relazioni di precedenza dove Client deve
attendere qualcun altro: Bank (prima di iniziare una nuova transazione) oppure Flight e
Hotel.
Algorithm: Travel
semaphore S1 ← (1,∅), semaphore S2,S3,S4 ←(0,∅)
Client Flight Hotel Bank
loop forever loop forever loop forever loop forever
p1: wait(S1) q1: wait(S2) r1: wait(S3) s1: wait(S4)
p2: provideData q2: bookF r2: bookH s2: paymentOk
p3: signal(S2) q3: signal(S1) r3: signal(S1) s3: signal(S1)
p4: signal(S3) q4: r4: s4:
p5: wait(S1) q5: r5: s5:
p6: wait(S1) q6: r6: s6:
p7: pay q7: r7: s7:
p8: signal(S4) q8: r8: s8:
3. rilascia le risorse.
Si supponga di porre a guardia di ciascuna risorsa uno strong semaphore (semaforo con
coda FIFO) binario, inizializzato a 1. Modellare le risorse e le operazioni di acquisizione e
rilascio come wait e signal sugli stessi.
(a) Si fissi in maniera arbitraria un ordinamento totale tra le risorse, diciamo r0 < r1 <
r2 . Dimostrare che una soluzione in cui ogni processo acquisisce le tre risorse sem-
pre rispettando questo ordine, e dopo averle usate le rilascia in un ordine qualsiasi,
garantisce la SF per ogni processo.
3
(b) Considerare il caso più generale di un insieme di k ≥ 1 risorse R = {r0 , ..., rk−1 },
in cui ciascun processo Pi ha bisogno di un sottoinsieme Ri ⊆ R (dunque, non
necessariamente di tutte le risorse; inoltre gli Ri possono essere diversi tra loro).
Fissare un ordinamento arbitrario tra le risorse r0 < · · · < rk−1 . Dimostrare che la
soluzione precedente va ancora bene: se ogni Pi acquisisce le risorse in Ri rispettando
l’ordinamento (se i < j, ri deve essere acquisita prima di rj ), è garantita la SF.
(c) Applicare la soluzione del punto (b) al problema dei filosofi a cena. Dire quali sono
le risorse per ciascun filosofo. Dimostrare che la soluzione che si ottiene è proprio
quella asimmetrica, in cui il filosofo 4 è rimpiazzato da un filosofo che prende prima
a destra e poi a sinistra.
Soluzione. Vediamo il punto (a). Supponiamo per assurdo che esista una computazione
in cui alcuni processi sono starved. Ciascuno di essi rimarrà dunque bloccato per sempre su
una qualche wait(ri ). Consideriamo tra tutti questi quello, diciamo P , che si blocca sulla
wait(ri ) con indice i più grande. Da un certo punto in poi, P sarà bloccato in una posizione
della coda FIFO del semaforo ri , diciamo in posizione l, posizione dalla quale non avanzerà
più (dove l = 0 corrisponde alla testa della coda). Questo vuol dire che, da un certo punto
in poi della computazione, nessun processo eseguirà più signal(ri ). L’unico motivo
per cui questo può accadere è che un altro processo, diciamo Q, dopo aver acquisito ri , non
la rilascia mai più perché si blocca. Avendo acquisito tutte le risorse precedenti a ri e ri
stesso, Q può solo bloccarsi su un semaforo rj con j > i. Dunque Q è starved e bloccato
per sempre su rj con j > i. Ma questo è assurdo, perché avevamo supposto che fosse P
quello bloccato sulla risorsa di indice più alto.
Per quanto riguarda (b), la prova è praticamente identica.
Per quanto riguarda (c), vediamo che in questo caso
R = {f0 , ..., f4 }
R0 = {f0 , f1 }
R1 = {f1 , f2 }
R2 = {f2 , f3 }
R3 = {f3 , f4 }
R4 = {f0 , f4 }.
Applicando la soluzione del punto precedente, fissiamo l’ordinamento f0 < · · · < f4 . Nel
rispetto di questo ordinamento, ciascuno filosofo Pi , per i = 0, ..., 3 acquisisce prima fi
(forchetta sinistra) poi fi+1 (forchetta destra); sempre nel rispetto dell’ordinamento, P4
acquisisce prima f0 (forchetta destra), poi f4 (forchetta sinistra). Questa è proprio la
soluzione asimmetrica.
4
2 Esercizi sui semafori in Java
Per i seguenti esercizi, si veda anche il codice messo a disposizione sulla pagina Moodle del
corso.
Esercizio 8. Basandosi sullo schema generale visto la scorsa lezione, programmare in Java
la versione simmetrica (che soffre di deadlock) dei Filosofi a Cena. Lanciare il programma
ed osservare il risultato.
Soluzione. Un programma che riprende lo schema dato è quello riportato in Figura 3.
Proviamo a lanciare il programma con java Dining dal command prompt. Otter-
remo un output simile a questo, che dimostra concretamente il deadlock: si noti infat-
ti che la stringa finale non viene stampata (occorre interrompere l’esecuzione del pro-
gramma). L’output ottenuto potrà differire leggermente ad ogni esecuzione, per via del
nondeterminismo.
Esercizio 9. Programmare in Java una versione asimmetrica dei Filosofi a Cena. Lanciare
il programma e osservare il risultato.
Soluzione. Una soluzione è quella riportata nelle Figure 4 e 5. In questa versione
asimmetrica, il quarto filosofo prende le forchette prima a destra poi a sinistra. Lanciando
anche più volte il programma, si osserva che la stringa finale viene sempre stampata.
5
1 /* Possibile schema base di un programma concorrente in Java */
2
3 import java. util . concurrent....;
4
5 /* Classe principale */
6
7 class MyClass {
8 /* Dichiara variabili condivise tra i processi come static volatile */
9
10 static volatile Type sharedvar = ...
11
12 /* Le variabili di sincronizzazione (es. semafori ) vanno anch’esse dichiarate qui */
13
14 /* Inner class che estendono Thread: una per ciascun tipo di processo (es. Produttore, Consumatore) */
15
16 class ProcType1 extends Thread {
17 public void run() {
18 ....
19 }
20 }
21
22 class ProcType2 extends Thread {
23 public void run() {
24 ....
25 }
26 }
27
28 /* ... */
29
30 /* Definisco ora il costruttore per classe principale , MyClass */
31
32 MyClass() {
33 /* Istanzio e rendo eseguibili processi dei vari tipi */
34 ProcType1 p = new ProcType1();
35 ProcType2 q = new ProcType2();
36 /* ... */
37 p. start ();
38 q. start ();
39 /* ... */
40 /* attendo terminazione di tutti */
41 try { p. join (); q. join ();
42 /* ... */ }
43 catch (InterruptedException e) { }
44 System.out.println (”Program terminated!”);
45 }
46
47
48 /* Metodo ”main” della classe principale : viene eseguito quando si lancia il programma */
49
50 public static void main(String[] args ) {
51 /* Puo’ limitarsi ad invocare il costruttore della classe principale */
52 new MyClass();
53 } 6
54 }
10
1 /* Travel Agency */
2 import java. util . concurrent. Semaphore;
3
4 class Travel {
5 /* Semaphores ensure correct order of execution */
6 static Semaphore[] s = new Semaphore[4];
7
8 class Client extends Thread {
9 public void run() {
10 for (int j = 0; j < 4; j ++) {
11 try {
12 s[0]. acquire ();
13 System.out.println (”Client : provideData.”);
14 s[1]. release ();
15 s[2]. release ();
16 s[0]. acquire ();
17 s[0]. acquire ();
18 System.out.println (”Client : pay.”);
19 s[3]. release ();
20 }
21 catch (InterruptedException e) {}
22 }
23 }
24 }
25
26 class Flight extends Thread {
27 public void run() {
28 for (int j = 0; j < 4; j ++) {
29 try {
30 s[1]. acquire ();
31 System.out.println (”Flight : bookF.”);
32 s[0]. release ();
33 }
34 catch (InterruptedException e) {}
35 }
36 }
37 }
38
39 class Hotel extends Thread {
40 public void run() {
41 for (int j = 0; j < 4; j ++) {
42 try {
43 s[2]. acquire ();
44 System.out.println (”Hotel: bookH.”);
45 s[0]. release ();
46 }
47 catch (InterruptedException e) {}
48 }
49 }
50 }
12
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 15 - 22/11/2022
Monitor
Docente: Prof. Michele Boreale (Disia)
1 Motivazioni
I semafori sono un meccanismo di sincronizzazione abbastanza flessibile, ma han-
no delle limitazioni. Il meccanismo è ancora piuttosto a basso livello e difficile da
usare correttamente. In particolare, la responsabilità del corretto uso dei monitor
è distribuita tra tutti i progettisti del sistema. Per esempio, se un programmatore
dimentica di inserire una signal nel codice di un certo processo, ciò potrebbe portare
ad un deadlock dell’intero sistema, e questo errore può essere difficile da individuare.
In un certo senso, il meccanismo dei semafori pecca in modularità. Queste conside-
razioni hanno portato all’introduzione di una versione centralizzata dei semafori, i
monitor. Ci sono tre idee alla base dei monitor.
1
fff
HH
H
monitor
f
2 Monitor
Introduciamo la sintassi e semantica dei monitor nel nostro linguaggio tramite un
semplice esempio. Consideriamo ancora il problema dell’incremento una variabile
condivisa n, da parte di due processi P e Q. L’operazione di incremento può essere
vista come una sezione critica. È possibile dunque incapsularla in un monitor, di-
ciamo CS, che offre appunto una operazione increment. Il programma risultante è
quello di seguito riportato. Si noti che il monitor va dichiarato come variabile globale
del programma. Al suo interno, il monitor può contenere dichiarazioni di variabili,
come n qui, che rappresentano la risorsa incapsulata nel monitor stesso. Seguono le
dichiarazioni delle operazioni che possono essere invocate, in questo caso ce n’è solo
una, increment. I processi possono invocare le operazioni del monitor, con la sintassi
CS.increment: queste invocazioni sono comandi atomici dei processi.
2
Algorithm: Atomicity of monitor operations
monitor CS
integer n ← 0
operation increment
integer temp
temp ← n
n ← temp + 1
p q
p1: CS.increment q1: CS.increment
3
Notiamo che il valore finale della variabile n è quello atteso, cioè 2. Questa è
una conseguenza del fatto che gli incrementi vengono eseguiti in ME. Vediamo anche
che l’invocazione di un’operazione del monitor corrisponde ad un comando atomico:
la sua esecuzione dà origine nel diagramma ad una singola transizione. Per questo
motivo, i diagrammi stati-transizioni dei programmi basati su monitor tendono ad
essere piuttosto compatti.
3 Condition variables
Algoritmi di sincronizzazione complessi richiedono strutture che permettano ai pro-
cessi di sospendersi, in attesa che una certa condizione diventi vera. Inoltre, per
motivi già ampiamente discussi, è preferibile evitare quanto più possibile forme di
attesa attiva. A tale scopo, i monitor mettono a disposizione delle strutture dati
chiamate condition variables. In sostanza
4
condition cond
5
control pointer di Q si troverà dunque spostato al comando atomico successivo
all’operazione invocata e perciò fuori dal monitor (a meno che che Q non venga
bloccato da una ulteriore waitC all’interno dell’operazione).
Assumeremo per il momento, per semplicità, che, se viene eseguita, signalC sia l’ulti-
ma istruzione in un’operazione del monitor1 . Riferendosi alla semantica della signalC
di cui sopra, vediamo allora che atomicamente (= in una sola transizione), avvengono
tre cose:
6
Algorithm: Semaphore simulated with a monitor
monitor Sem
integer s ← 1
condition notZero
operation wait
if s = 0
waitC(notZero)
s←s−1
operation signal
s←s+1
signalC(notZero)
p q
loop forever loop forever
\\non-critical section \\non-critical section
p1: Sem.wait q1: Sem.wait
\\critical section \\critical section
p2: Sem.signal q2: Sem.signal
Vediamo il diagramma stati transizioni.
Si noti la transizione uscente dallo stato più a destra nella prima riga: essa corri-
sponde all’invocazione di Sem.signal da parte di P. Vediamo che, atomicamente: (1)
P completa l’operazione ed esce dal monitor; (2) Q viene sbloccato; (3) Q completa
il resto dell’operazione (decrementa s) ed esce dal monitor. Considerazioni analoghe
valgono per la transizione uscente dallo stato più a destra della seconda riga, con i
ruoli di P e Q invertiti.
7
all’attesa che una certa condizione booleana b diventi vera, avremo uno schema di
questo tipo:
if (not b)
waitC(cond)
c
...
Si noti la mancanza del ramo else: se vale b, si prosegue ad eseguire c, altrimenti ci si
blocca su cond. In tal caso, l’esecuzione riprenderà a tempo debito direttamente dal-
l’istruzione successiva alla waitC, ovvero c. Dunque, in ogni caso, quando il comando
c verrà eseguito, la condizione booleana b sarà vera.
Esercizio 1. Programmare in Java gli algoritmi per il problema Buy Milk proposti
alle pp. 17–25 delle slide sulla pagina Moodle.
Riferimenti bibliografici
[1] C. A. R. Hoare. Monitors: an operating system structuring concept. Comm.
ACM 17 (10): 549–557. doi:10.1145/355620.361161. Ottobre 1974.
8
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 16 - 24/11/2022
1 Un esempio
Prima di proseguire, vediamo ancora un semplice esempio, che serve a chiarire
ulteriormente il meccanismo implementato delle primitive waitC e signalC.
Supponiamo
p di dover calcolare la norma euclidea di un vettore (x, y), cioè il
valore n = x2 + y 2 . Tale compito viene suddiviso in due sotto-compiti: avremo
un processo P che calcola x2 + y 2 e un processo Q che calcola la radice quadrata,
ma solo dopo che P ha concluso il suo compito. Questo è un tipico problema ordine
di esecuzione. Vediamo come risolverlo tramite monitor. Le variabili x, y, n sono
incapsulate in un monitor, Euclid, che offre due operazioni, squaresum e root. La
precedenza tra i due compiti viene implementata tramite una condition variable
notNeg, che sospende l’invocante se n è ancora negativo (si noti che n è inizializzato
a -1). Il codice che ne risulta è il seguente.
1
Algorithm: Euclidean norm
monitor Euclid
real x ← a, y ← b, n ← -1
condition notNeg
operation squaresum
n ← x*x + y*y
signalC(notNeg)
operation root
if n<0
waitC(notNeg)
n ← sqrt(n)
p q
p1: Euclid.squaresum q1: Euclid.root
Vediamo il diagramma stati-transizioni, che chiarisce l’atomicità delle operazioni.
2 Politiche di resumption
Nel descrivere la semantica della signalC, abbiamo finora assunto che essa fosse l’ulti-
ma istruzione eseguita di una operazione: per cui abbiamo semplicemente detto che
il processo segnalante, completata la signalC, esce dal monitor. Se rilasciamo l’assun-
zione che la signalC sia usata solo come istruzione finale di un’operazione, allora c’è
un potenziale conflitto tra il segnalante e il segnalato: entrambi vorrebbero rimanere
ed essere eseguiti nel monitor, ma questo non è possibile, per il vincolo di mutua
esclusione dei monitor. Come risolvere il conflitto, è una questione di quale politica
di resumption adottare.
2
Facciamo un passo indietro. Al momento in cui viene completata una signalC, ci
sono in generale diversi processi che competono per essere eseguiti dentro il monitor:
processi fuori dal monitor e in attesa di entrare per eseguire una operazione,
indicati collettivamente con E (external );
processi segnalanti, ovvero che hanno eseguito una segnalazione dentro il mo-
nitor, indicati collettivamente con S (signalling).
Oltre a questi, ci sono naturalmente anche i processi bloccati nelle varie condi-
tion variables, ma essi non sono ancora eseguibili. Per cui la situazione è quella
rappresentata nella figura seguente.
external fff
HH
condition 1 H waiting
fff monitor ff
A
AA
f
condition 2 A signaling
ff AA
f
Una politica di resumption si ottiene fissando una priorità tra questi tre insiemi di
processi, per esempio E < W < S. Ci sono diverse possibilità, qui ci limiteremo a
prenderne in esame tre, che sono in pratica quelle più usate.
1. Signal & Continue (1): E < W < S. I segnalanti hanno priorità più alta,
seguiti da quelli sbloccati e in attesa, mentre gli esterni hanno la priorità più
bassa. Perciò, nel momento in cui una signalC viene completata, il segnalante
rimane nel monitor (continua), mentre lo sbloccato viene inserito in un insieme
W di processi sbloccati, in attesa di essere eseguiti dentro il monitor. Quando
un processo esce dal monitor, un processo prelevato da W viene messo in ese-
cuzione, se W è non vuoto. Altrimenti, viene immesso nel monitor un processo
prelevato dall’insieme E. Si noti che per E non è prevista, in generale, alcuna
3
disciplina specifica. Si noti anche che in questo caso non è necessario prevedere
un insieme di attesa per gli S.
2. Signal & Continue (2): E = W < S. Una variante della precedente in cui
E e W hanno pari priorità. In pratica, E = W viene implementato ponendo il
processo sbloccato da una signalC fuori dal monitor, insieme agli esterni. Da qui
dovrà riconquistarsi l’accesso al monitor, per terminare la propria operazione,
competendo con gli altri esterni. Non c’è però alcuna garanzia che prima o poi
rientri nel monitor. Questa è la politica di resumption adottata in Java.
3. Immediate Resumption Requirement (IRR): E < S < W . È detta an-
che Signal & Urgent Wait. Il processo sbloccato da una signalC viene posto
immediatamente in esecuzione dentro il monitor. Il processo segnalante do-
vrà attendere dentro un insieme di processi segnalanti in attesa di riprendere
l’esecuzione dentro il monitor, a meno che non abbia terminato l’operazione
(signalC ultima istruzione), nel qual caso viene posto a ready fuori dal monitor.
Quando un processo esce dal monitor, un processo prelevato da S viene messo
in esecuzione, se S è non vuoto. Altrimenti, viene immesso nel monitor un
processo dall’insieme E. Si noti che in questo caso non è necessario prevedere
un insieme di attesa per i W .
Nel seguito, a meno che non specifichiamo diversamente (come quando tratteremo i
monitor in Java)
assumeremo sempre una politica di resumption IRR.
La politica IRR ha un vantaggio cruciale rispetto alle altre: quando signalC(cond)
viene chiamata, evidentemente la condizione che attendono i processi in cond è di-
ventata vera; dunque è sensato mandare subito in esecuzione uno di tali processi,
prima di qualche altro processo che potrebbe rendere la condizione di nuovo falsa.
Per esempio, nell’operazione signal del monitor Sem
operation signal
s ← s+1
signalC (notZero)
la signalC viene eseguita proprio perché, a seguito dell’incremento di s, ora s è non
zero. Dunque, il processo sbloccato che riprende immediatamente (prima di chiunque
altro) da dopo la waitC, troverà la condizione vera, e potrà decrementare s senza
temere di incorrere in un errore. Questo è quello che avviene nell’operazione wait di
Sem
4
operation wait
if (s=0)
waitC(notZero)
s ← s=1
In generale, questo però è corretto solo se assumiamo politica IRR. D’altra parte,
con una politica S&C, vi è poco o nessun controllo su quando un processo, sbloccato
da una signalC, rientrerà in esecuzione nel monitor. Di conseguenza, con S&C, un
processo che riprende l’esecuzione dopo una waitC, non può essere certo che qualcun
altro nel frattempo non sia entrato prima di lui, rendendo di nuovo falsa la condizione.
Per esempio, assumiamo la politica S&C(2) di Java e riconsideriamo l’esempio
del monitor Sem, nel caso di (almeno) tre processi che competono per la CS. Ora,
quando un un processo Q riprende l’esecuzione nel monitor dopo aver completato
una waitC, non può essere sicuro che s>0, perché un altro processo prima di lui
potrebbe essere entrato nel monitor e avere già decrementato s. Dunque, Q dovrà,
prima di decrementare s, ricontrollare che la condizione s>0 sia verificata: se lo
è, potrà proseguire e decrementare (e quindi entrare in CS); altrimenti, dovrà ri-
sospendersi su notZero. In sostanza, con la semantica Java, nell’operazione sopra,
l’if deve essere rimpiazzato da un while per garantire la safety. Con S&C, il codice
corretto dell’operazione wait diventa perciò
while (s=0)
waitC(notZero)
s ← s=1
Questa osservazione ha validità generale. Lo schema di impiego della waitC con
politica IRR è
if (not b)
waitC(cond)
c
dove è possibile assumere che la condizione booleana b è vera quando c viene eseguito.
Con politica S&C questo schema diventa
while (not b)
waitC(cond)
c
Vediamo dunque che le politiche S&C sono più complicate da gestire e reintrodu-
cono forme di attesa attiva che possono portare ad inefficienza, in ambienti ad alta
5
contention.
monitor M
integer x ← 0
condition notZero
operation op1
x←1
signalC(notZero)
if x>0
x ← x-1
operation op2
if x≤0
waitC(notZero)
x ← x-1
p q
p1: M.op1 q1: M.op2
Siamo interessati a verificare se l’espressione x≥ 0 è un invariante del programma.
Supponiamo prima che la semantica della resumption sia IRR. Il diagramma stati-
transizioni del programma è il seguente: esso mostra che l’invariante è soddisfatto.
6
Assumiamo ora una politica S&C (dal momento che ci sono solo due processi coinvol-
ti, non c’è differenza in questo caso tra le due varianti viste di S&C). Il diagramma
risultante è il seguente: come si vede, esso è diverso dal precedente, e l’invariante
cui siamo interessati non è soddisfatto. Il problema è che il processo sbloccato non
ricontrolla la condizione x≤ 0 quando riprende ad eseguire dentro il monitor.
7
non pieno corrispondono naturalmente a due distinte condition variables. Ecco l’al-
goritmo risultante. Tralasciamo la prova di correttezza di questo algoritmo, che è
abbastanza ovvia.
Algorithm: Producer-consumer (finite buffer, monitor)
monitor PC
operation append(datatype V)
if buffer is full
waitC(notFull)
append(V, buffer)
signalC(notEmpty)
operation take()
datatype W
if buffer is empty
waitC(notEmpty)
W ← head(buffer)
signalC(notFull)
return W
producer consumer
datatype D datatype D
loop forever loop forever
p1: D ← produce q1: D ← PC.take
p2: PC.append(D) q2: consume(D)
8
dal fatto che le risorse necessarie vanno acquisite l’una dopo l’altra, sequenzialmente.
Idealmente, vorremmo atomicamente: controllare se ci sono abbastanza risorse (due
forchette) e in quel caso acquisirle tutte; altrimenti attendere. Ora, le operazioni dei
monitor sono, per definizione, soggette ad una esecuzione in ME; che, dal punto di
vista dei possibili risultati, è equivalente ad una esecuzione atomica. Allora è naturale
implementare l’acquisizione e il rilascio di risorse come operazioni di un monitor.
Nella soluzione presentata di seguito, le forchette sono rappresentate indiretta-
mente: abbiamo un vettore di cinque posizioni, fork, dove fork[i] conta in ogni mo-
mento quante forchette ha a disposizione il filosofo Pi (dunque 0, 1 o 2). Solo nel
caso in cui fork[i]=2 , il filosofo Pi potrà procedere all’acquisizione. In caso contrario,
Pi si dovrà sospendere su una condition variable OKtoEat[i]. Si noti che, all’atto
dell’acquisizione o rilascio delle risorse, i contatori dei vicini vanno aggiornati di con-
seguenza. Inoltre, in caso di rilascio, bisogna considerare se è il caso di svegliare un
vicino eventualmente in attesa (questo è possibile solo se, in seguito al rilascio, esso
ha a disposizione due forchette). L’algoritmo risultante è il seguente. Si noti che
questo è un caso in cui una signalC può essere eseguita non come ultima istruzione
di una operazione.
9
Algorithm: Dining philosophers with a monitor
monitor ForkMonitor
operation takeForks(integer i)
if fork[i] 6= 2
waitC(OKtoEat[i])
fork[i+1] ← fork[i+1] − 1
fork[i−1] ← fork[i−1] − 1
operation releaseForks(integer i)
fork[i+1] ← fork[i+1] + 1
fork[i−1] ← fork[i−1] + 1
if fork[i+1] = 2
signalC(OKtoEat[i+1])
if fork[i−1] = 2
signalC(OKtoEat[i−1])
philosopher i
loop forever
p1: think
p2: takeForks(i)
p3: eat
p4: releaseForks(i)
La soluzione presentata garantisce DF, ma non SF. Infatti, due filosofi possono
cospirare per affamare il loro comune vicino. Nello scenario presentato di seguito, P1 e
P3 cospirano contro P2 . In breve, quello che succede è che, dopo che P1 ha acquisito le
forchette e P3 ha acquisito le forchette, P2 prova ad acquisirle, e viene (correttamente)
sospeso. A questo punto, P1 rilascia le forchette, ma senza svegliare P2 al quale
manca ancora una forchetta, quindi le ri-acquisisce immediatamente. Poi la stessa
cosa fa P3 . D’ora in avanti la cosa si ripete all’infinito (stati da 4 a 7 nello scenario
sottostante, con le ovvie abbreviazioni). Ne ha origine una computazione in cui P2
è sempre blocked. Dunque, la computazione è fair: non essendo P2 continuamente
abilitato, ai fini della fairness non è richiesto che venga prima o poi eseguito.
10
n phil1 phil2 phil3 f0 f1 f2 f3 f4
1 take(1) take(2) take(3) 2 2 2 2 2
2 release(1) take(2) take(3) 1 2 1 2 2
3 release(1) take(2) and release(3) 1 2 0 2 1
waitC(OK[2])
4 release(1) (blocked) release(3) 1 2 0 2 1
5 take(1) (blocked) release(3) 2 2 1 2 1
6 release(1) (blocked) release(3) 1 2 0 2 1
7 release(1) (blocked) take(3) 1 2 1 2 2
11
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 17 - 29/11/2022
1 Cascade unblocking
Come abbiamo visto, nella politica IRR, un processo P sbloccato dall’interno di una
condition variable, a seguito di una signalC, riprende ad eseguire immediatamente
dentro il monitor, per completare la sua operazione. Nel corso della sua esecuzione
dentro il monitor, P potrebbe a sua volta sbloccare un secondo processo Q da una
condition variable: anche Q riprende la sua esecuzione dentro il monitor immedia-
tamente. E cosı̀ via. In questo caso, si ha quello che viene chiamato uno sblocco a
cascata di processi, o cascade unblocking (P sblocca Q che sblocca R ...). Si noti
che, come conseguenza della semantica IRR, tutti i processi sbloccati completano la
loro esecuzione dentro il monitor atomicamente: la cascade è cosı̀ rappresentata nel
diagramma stati-transizioni come una singola transizione. Vediamo un semplice
esempio.
1
Algorithm: Esempio di cascade unblocking
monitor M
condition cv
integer x←0
operation op1
if x=0
waitC(cv)
x←x-1
signalC(cv)
operation op2
x←2
signalC(cv)
P Q R
loop forever loop forever loop forever
p1: M.op2 q1: M.op1 r1: M.op1
Considerazioni simili si applicano alla transizione dal secondo stato della terza riga.
2
• p1, q1, r1, x = 0, [ ]
P P R
P
P
Q, R P
Q, R P Q
R
3
monitor unicamente allo scopo di sincronizzarsi, mentre l’accesso vero e proprio al
DB è eseguito fuori dal monitor (dunque, non è un’operazione del monitor). Allo
scopo, il monitor mette a disposizione quattro operazioni: due per i lettori, StartRead,
EndRead, e due per gli scrittori, StarWrite, EndWrite. Il codice risultante è quello che
segue.
monitor RW
integer readers ← 0
integer writers ← 0
condition OKtoRead, OKtoWrite
operation StartRead
if writers 6= 0 or not empty(OKtoWrite)
waitC(OKtoRead)
readers ← readers + 1
signalC(OKtoRead)
operation EndRead
readers ← readers − 1
if readers = 0
signalC(OKtoWrite)
operation StartWrite
if writers 6= 0 or readers 6= 0
waitC(OKtoWrite)
writers ← writers + 1
operation EndWrite
writers ← writers − 1
if empty(OKtoRead)
then signalC(OKtoWrite)
else signalC(OKtoRead)
reader writer
p1: RW.StartRead q1: RW.StartWrite
p2: read the database q2: write to the database
p3: RW.EndRead q3: RW.EndWrite
4
1. Le operazioni StartRead e StartWrite implementano le regole di esclusione enun-
ciate (Safety). I processi che devono ritardare il loro accesso al DB si sospen-
dono su opportune condition variables, una per i lettori, OKtoRead, e una per
gli scrittori, OKtoWrite.
3 Monitors in Java
Abbiamo già accennato al fatto che i monitor in Java seguono una politica S&C di
tipo E = W < S. Ci sono però alcuni aspetti specifici di Java, che vanno analizzati
con cura. Richiamiamo preliminarmente alcune considerazioni generali, già viste in
5
una lezione precedente, circa il meccanismo di ME built-in in Java, quello dei blocchi
synchronized.
Java fornisce un meccanismo built-in per forzare l’atomicità di un blocco di co-
mandi: i blocchi synchronized. Un blocco synchronized ha due parti: il riferimento
ad un oggetto che funge da lock (in Inglese, lucchetto) e un blocco di codice B che si
dice guardato dal lock stesso, e che contiene gli accessi alle variabili condivise da più
threads.
synchronized (lock ) {
B // Accedi a variabili condivise
}
Ogni oggetto, in Java, è dotato di una variabile booleana implicita, detta lock :
essa dice, in ogni momento, se l’oggetto è libero oppure occupato (v. oltre).
6
Si noti che tale gestione dei lock è affidata completamente alla JVM e non è vi-
sibile al programmatore. Una volta dichiarato un blocco come synchronized, l’esecu-
zione del blocco in ME, come sopra descritta, non ha bisogno di essere programmata
esplicitamente.
Quando l’intero corpo del metodo di un oggetto è contenuto in un blocco synchro-
nized, guardato dall’oggetto stesso, si dice che il metodo è synchronized. Esiste
una sintassi specifica per dichiarare questi metodi, che usa la keyword synchronized
come qualificatore, e cioè
In Java, non esiste una classe Monitor. Tuttavia, alla luce di quanto esposto
sopra, potremmo definire una classe monitor in Java come segue.
Definizione 1 (monitor Java). Un monitor in Java è un oggetto che è istanza di
una classe dove tutti i metodi sono dichiarati synchronized, e tutti campi private.
Si noti che questa è una pura e semplice convenzione, in quanto in Java non esiste
un modo per forzare sintatticamente o mediante il typing il vincolo sopra enunciato.
Passiamo ora alle condition variables. In effetti, a differenze dei monitor classici,
i monitor Java (nel senso chiarito prima) non possono disporre di vere e proprie
condition variables. Semplicemente, ad ogni oggetto è associato un unico wait set di
thread sospesi su quell’oggetto. Un thread entra nel wait set chiamando la primitiva
wait (l’analogo di waitC nei monitor classici) dall’interno di un metodo synchronized
dell’oggetto, e ne esce a seguito di una notify (simile a signalC) o notifyAll chiamate
da un altro thread. Più precisamente, abbiamo i seguenti elementi.
7
wait(). La wait() va chiamata dall’interno di un metodo synchronized. Sospen-
de l’esecuzione del thread che la chiama, e lo inserisce nel wait set: il thread
diventa sospeso, dunque non eseguibile. Il thread appena sospeso rilascia auto-
maticamente il lock sul monitor, permettendo ad altri di entrare. Solo quando
il thread riprenderà l’esecuzione dentro il monitor (v. sotto), la wait() sarà
considerata completata. L’esecuzione di wait() può sollevare una eccezione di
tipo InterruptedException (se il thread chiamante viene interrotto mentre è nel
wait set).
Fin quı̀ per quanto riguarda il supporto alla mutua esclusione e alle attese messo
a disposizione dal linguaggio. Discutiamo ora alcune conseguenze della semantica di
queste primitive a livello di programmazione.
1. Un thread sbloccato, che riprende l’esecuzione dentro il monitor, non può assu-
mere che la condizione che sta aspettando sia automaticamente vera. Questa,
come già discusso, è una conseguenza delle politiche S&C, che danno scarso
o nessun controllo sull’ordine ed il momento in cui i thread sbloccati ripren-
dono ad eseguire dentro il monitor. Quando il thread riprende l’esecuzione,
8
la condizione potrebbe essere cambiata. Il thread, dunque, dovrà come prima
cosa ricontrollare la condizione stessa: se essa è vera, il thread potrà andare
avanti ad eseguire dentro il monitor, altrimenti il thread chiamerà nuovamente
la wait(), ritornando cosı̀ nel wait set. Perciò lo schema corretto di utilizzo
di wait() prevede, come già discusso, di inserirla all’interno di un ciclo che ha
come condizione di permanenza la negazione della condizione, in questo modo
(qui viene omesso per brevità il try-catch):
synchronized method1() {
while (! booleanExpression)
wait();
//Dopo il while , è possibile assumere booleanExpression true
}
2. Nel momento in cui una certa condizione diventa vera, se possono esserci più
thread in attesa del verificarsi di condizioni diverse, essi vanno tutti sblocca-
ti, con notifyAll(). Questa è una conseguenza del fatto che, invece di diverse
condition variables, una per ogni condizione, esiste un unico wait set, sul quale
confluiscono tutti i thread in attesa. L’esecuzione di notify() sblocca uno qual-
siasi tra questi thread, ma non è detto che sia quello in attesa della condizione
appena resa vera. In questo caso è quindi più sensato sbloccare tutti i thread
con notifyAll():
synchronized method2() {
// Rendi booleanExpression true
notifyAll ();
}
I thread sbloccati vanno tutti all’esterno del monitor. Tra tutti questi, il thread
che acquisirà il lock avrà la possibilità di riprendervi l’esecuzione; come prima
cosa, esso dovrebbe (ri)controllare che la condizione che sta attendendo risulti
vera: se non lo è, il thread si dovrebbe (ri)sospendere nel wait set (v. punto
precedente), dando la possibilità a qualche altro thread all’esterno di entrare nel
monitor. L’uso di notify(), in situazioni in cui sarebbe richiesto notifyAll(), può
avere conseguenze deleterie, come l’instaurarsi di un deadlock (lo vedremo in
un prossimo esercizio). Solo in alcuni casi particolari, l’uso di notify() al posto
di notifyAll() può risultare sicuro, portando ad una riduzione di contention e di
context switch, e quindi ad un guadagno di efficienza: quando, per esempio,
tutti i thread nel wait set sono in attesa della stessa condizione (oppure quando
ce n’è in attesa al più uno).
9
3. In generale, non abbiamo alcuna garanzia di SF. Questa è una conseguenza
della mancanza di politica FIFO per il wait set. Solo in alcune situazioni par-
ticolari la SF può essere ancora garantita. In ogni caso, se c’è alta contention
(tanti thread sbloccati che vogliono acquisire il lock del monitor), la politica
E = W < S porta con sé potenziali inefficienze: parziale attesa attiva (ci-
cli while), ripetuti context switch (quando acquisiscono il lock thread la cui
condizione non è ancora vera).
Riferimenti bibliografici
[1] D. Lea. Concurrent Programming in Java: Design Principles and Patterns, 2/e.
ISBN 0-201-31009-0. Addison Wesley, 1999.
10
1 class PCMonitor {
2 private final int N = 5;
3 private int Oldest = 0, Newest = 0;
4 private volatile int Count = 0;
5 private int Buffer [] = new int[N];
6
7 synchronized void Append(int V) {
8 while (Count == N)
9 try {
10 wait();
11 } catch (InterruptedException e) {}
12 Buffer [ Newest] = V;
13 Newest = (Newest + 1) % N;
14 Count = Count + 1;
15 notifyAll ();
16 }
17 synchronized int Take() {
18 int temp;
19 while (Count == 0)
20 try {
21 wait();
22 } catch (InterruptedException e) {}
23 temp = Buffer[Oldest];
24 Oldest = (Oldest + 1) % N;
25 Count = Count = 1;
26 notifyAll ();
27 return temp;
28 }
29 }
11
/* Copyright (C) 2006 M. Ben-Ari. See copyright.txt */
class PCMonitor {
private final int N = 5;
private int Oldest = 0, Newest = 0;
private volatile int Count = 0;
private int Buffer[] = new int[N];
1 class RWMonitor {
2 private volatile int readers = 0;
3 private volatile boolean writing = false ;
4
5 synchronized void StartRead() {
6 while (writing ) wait();
7 readers = readers + 1;
8 notifyAll ();
9 }
10 synchronized void EndRead() {
11 readers = readers = 1;
12 if (readers == 0) notifyAll ();
13 }
14
15 synchronized void StartWrite () {
16 while (writing || (readers != 0)) wait();
17 writing = true;
18 }
19
20 synchronized void EndWrite() {
21 writing = false ;
22 notifyAll ();
23 }
24 }
1
2 Altri esempi sui monitor in Java
Per questi esercizi, si veda anche il materiale (codice) messo a disposizione sulla pagina Moodle del
corso.
Esercizio 2. Scrivere una classe TestRWMonitor.java che testa la classe RWMonitor.java vista in
precedenza. Nel costruttore della classe TestRWMonitor.java, viene istanziato un monitor e vengono
istanziati e messi in esecuzione 3 processi lettori e 3 processi scrittori. Il metodo main() della classe
si limita ad invocare tale costruttore. Lanciare il programma ed osservarne il risultato.
Soluzione. La Figura 2 presenta una possibile soluzione. Si noti l’uso del metodo di classe
Thread.sleep(int ms): esso sospende il thread che lo invoca per ms di millisecondi (può lanciare
una eccezione se il thread viene interrotto), dando la possibilità a qualche altro thread di andare in
esecuzione. Qui ms viene scelto casualmente, allo scopo di forzare ed osservare un po’ di interleaving.
Dopo aver compilato, lanciamo il programma dal command prompt, ed osserviamo un possibile
output.
Writer 1 starts writing
Writer 1 ends writing
Writer 1 starts writing
Writer 1 ends writing
Reader 1 starts reading
Reader 3 starts reading
Reader 2 starts reading
Reader 3 ends reading
Reader 1 ends reading
Reader 3 starts reading
Reader 2 ends reading
Reader 3 ends reading
Writer 3 starts writing
Writer 3 ends writing
Writer 3 starts writing
Writer 3 ends writing
Writer 2 starts writing
Writer 2 ends writing
Writer 2 starts writing
Writer 2 ends writing
Reader 1 starts reading
Reader 2 starts reading
Reader 1 ends reading
Reader 2 ends reading
Esercizio 3. In maniera simile a quanto fatto per l’esercizio precedente, scrivere una classe PCwith-
Monitor.java per testare la classe PCMonitor.java vista in precedenza. Nel costruttore della classe,
viene istanziato un monitor PCMonitor, ed istanziati e messi in esecuzione 1 processo produttore
che produce 20 dati, e 2 processi consumatori che consumano 10 dati ciascuno. Il metodo main()
della classe, si limita ad invocare tale costruttore. Lanciare il programma ed osservarne il risultato.
Esercizio 4 (notify vs notifyAll). Questo esercizio serve ad illustrare concretamente la differenza tra
notify e notifyAll in Java. Un monitor incapsula due variabili intere, x e y, inizializzate a 0, e offre
tre metodi:
2
inc(): incrementa o x o y, in base ad una scelta casuale;
decx(): se x>0, decrementa x; altrimenti, se è già avvenuto un decremento (di y) non fa nulla
ed esce; se x==0 sospende l’invocante, in attesa che una delle due condizioni diventi vera;
decy(): se y>0, decrementa y; altrimenti, se è già avvenuto un decremento (di x) non fa nulla
ed esce; se x==0 sospende l’invocante, in attesa che una delle due condizioni diventi vera;
Un programma concorrente che usa tale monitor è composto da un thread I, che invoca inc una sola
volta e poi termina, più due thread, Dx e Dy, che invocano rispettivamente decx e decy, una sola
volta e poi terminano. Quando tutti questi tre thread sono terminati, viene stampata una scritta,
come Program terminated o simile.
Scrivere la classe, MyMonitor.java, ed il programma concorrente che la usa come specificato,
TestMyMonitor.java. I metodi devono prevedere opportune sospensioni e notifiche. Il monitor farà
anche uso di una variabile booleana done, per registrare se un decremento di una delle variabili è
già avvenuto. Il programma deve garantire che: (1) un incremento ed un decremento della stessa
variabile siano effettuati; (2) nessuna variabile assuma valori negativi; (3) tutti i threads terminino
correttamente (DF), cioè la scritta finale venga sempre stampata.
Soluzione. Presentiamo prima una versione non corretta del monitor (Figura 3). Indicheremo
poi le modifiche necessarie a correggere l’errore.
La relativa classe di test istanzia un monitor e i tre processi (Figura 4). Se lanciamo questo
programma diverse volte, possiamo ottenere esecuzioni che terminano correttamente. Prima o poi
otterremo però una esecuzione che porta ad un deadlock, come quella che produce questo output.
Starting processes ...
Waiting to decrement x...
Waiting to decrement y...
y incremented.
Waiting to decrement x...
È facile capire dall’output riportato cosa è successo: i thread Dx e Dy sono stati eseguiti per pri-
mi, hanno cercato di eseguire il decremento, e sono stati sospesi dalle rispettive wait() nel wait
set. Quindi il thread I è andato in esecuzione, ha scelto (in modo casuale) di incrementare y ed
ha eseguito notify(), terminando. La notify() ha però prelevato dal wait set il processo sbagliato,
per cosı̀ dire, cioè Dx. Questi è rientrato nel monitor e ha rieseguito il controllo della condizione,
come prescritto dal while: non essendo la condizione ancora verificata (infatti è ancora x==0 e
done==false), Dx ha rieseguito la wait(), che lo sospeso nuovamente nel wait set. A questo punto
abbiamo un processo terminato (I) e due nel wait set (Dx, Dy). Dunque, non c’è modo di terminare
il programma correttamente come da specifica (deadlock).
Il problema è dovuto naturalmente all’impiego (non corretto) della notify() in inc(): essa deve
essere rimpiazzata da notifyAll(). Con questa modifica, sia Dx che Dy vengono prelevati dal wait
set. Se il primo a rientrare nel monitor è il processo sbagliato (Dx nell’esempio sopra), poco male,
esso finirà nel wait set, ma darà modo all’altro di entrare nel monitor, e completare tutti i metodi
in sospeso. È invece corretto l’uso di notify() in decx e decy, dal momento che, quando essa viene
chiamata, al più un processo può essere nel wait set.
Esercizio 5. Risolvere il problema dell’esercizio precedente usando i monitor classici, con politica
IRR.
3
3 Condition variables in Java
Abbiamo definito un monitor Java come un’istanza di una classe in cui tutti i metodi sono stati
qualificati come synchronized e i campi private. Questa definizione è giustificata dal dal fatto che
ogni oggetto Java possiede una variabile lock implicita, che indica se il monitor (oggetto) è libero
o occupato. Quando un thread invoca un metodo synchronized di un oggetto, attende che il lock
dell’oggetto sia libero, quindi atomicamente lo rimette ad occupato (in altre parole, acquisisce il
lock), prima di procedere effettivamente ad eseguire il metodo. Il lock viene implicitamente rilasciato
dal thread o alla fine dell’esecuzione del metodo synchronized, oppure nel caso venga invocato il
metodo wait() dell’oggetto monitor, che ha l’effetto di sospendere il thread invocante nel wait set
del monitor e, appunto, di rilasciarne il lock. Questa gestione della mutua esclusione è semplice,
perché solleva il programmatore dal dover esplicitamente programmare l’acquisizione e il rilascio
del lock. Tuttavia essa ha delle serie limitazioni.
Comprensibilità del codice. Essendo previsto un solo wait set, la gestione della segnalazione
è rudimentale e si riduce spesso ad un uso massiccio di notifyAll(). Questo ha ripercussioni
sulla comprensibilità del codice.
Efficienza. Anche l’efficienza può risultare pregiudicata da spin loop (controllo all’interno di
un ciclo della precondizione per rimanere nel monitor) e context switch ripetuti, in caso di
alta contention.
Fairness. Non è possibile controllare l’ordine con il quale i processi sbloccati dal wait set
riprendono la loro esecuzione all’interno del monitor.
Mostreremo in questa lezione come programmare in Java dei monitor con delle vere condition
variables, che supportano una gestione sofisticata delle segnalazioni, facendo uso di alcune interfacce
e classi del package java.util.concurrent.locks.
L’idea è di programmare un monitor come una sezione critica: l’esecuzione del corpo dei metodi
(operazioni) del monitor avviene dentro la sezione critica. A guardia della sezione critica poniamo
un lock esplicito, cioè un lock la cui gestione deve essere esplicitamente prevista dal program-
ma. Precisamente, useremo come lock un oggetto della classe ReentrantLock. Tale classe è una
implementazione dell’interfaccia Lock. Un oggetto mylock di classe ReentrantLock si instanzia cosı̀
ReentrantLock mylock new ReentrantLock()
e offre i seguenti metodi.
mylock.lock(). Attende che mylock sia libero, poi atomicamente lo acquisisce. In generale,
può comportare attesa attiva.
mylock.unlock(). Rilascia mylock.
Sono previsti altri metodi, per la descrizione dei quali si rimanda alla documentazione della classe
o al libro [1]. Una condition variable mycond, come istanza di Condition, mette a disposizione, tra
gli altri, i seguenti metodi, che dovrebbero essere invocati solo previa acquisizione del lock mylock
a cui è associata mycond.
4
mycond.await(). Sospende il thread invocante sulla condition variable mycond e rilascia
il mylock sul monitor. Solleva eccezione InterruptedException se il thread invocante viene
interrotto durante l’attesa in mycond.
mycond.signal(). Sblocca (rende eseguibile) un thread da mycond, ponendolo però fuori dal
monitor (E = W < S). Quando il thread sbloccato riguadagnerà l’accesso del monitor,
riprenderà l’esecuzione dal comando successivo alla await().
mycond.signalAll(). Simile alla precedente, ma sblocca tutti i thread in mycond, ponendoli
fuori dal monitor.
Non si fa alcuna assunzione sulle politiche di ordinamento dei thread in attesa su mylock e su
mycond, che sono arbitrarie. Discuteremo in seguito gli aspetti relativi alla fairness. I metodi offerti
dal monitor, non più da dichiarare come synchronized, seguono dunque uno schema tipico, tenendo
presente che ad ogni condition variable è associata una condizione booleana, o precondition, sulle
variabili di stato, cioè quelle incapsulate nel monitor.
1. acquisisci il lock;
2. all’interno di un loop, controlla se la precondition per rimanere nel monitor è vera; se no,
sospenditi sulla condition variable associata alla precondition;
3. esegui il corpo vero e proprio del metodo, assumendo la precondition inizialmente vera;
4. se una o più preconditions sono diventate vere, esegui le segnalazioni sulle rispettive condition
variables;
5. rilascia il lock.
Uno schema di classe Java che implementa un monitor con condition variables è dunque quello
descritto in Figura 6.
Si noti che l’acquisizione e il rilascio del lock vanno previsti esplicitamente, attraverso l’invoca-
zione dei metodi lock() e unlock(). Tuttavia, nel caso in cui venga invocato await(), il lock viene
rilasciato automaticamente1 .
Terminiamo questa introduzione alle condition variable in Java mostrando una nuova versione
del monitor per i produttori-consumatori (Figura 6; si veda anche il codice disponibile sulla pagina
Moodle). Nella nuova versione, si fa uso di due condition variable distinte, notFull e notEmpty. La
politica di segnalazione ora ricalca fedelmente quella della soluzione con monitor classici. Si noti
tuttavia che la semantica di resumption dei monitor Java rimane S&C.
l’esecuzione del metodo, assicurando che in ogni caso il lock venga rilasciato. Tipicamente questo si potrà ottenere
facendo uso di blocchi finally; sorvoliamo qui su questo aspetto.
5
Un lock con disciplina FIFO è detto fair nella terminologia Java. new ReentrantLock(false) è invece
equivalente a new ReentrantLock(). Le condition variables create a partire da un lock, invocando
newCondition(), ereditano la politica di ordinamento del lock a cui sono associate. Quindi, una
condition variable associata ad un lock fair è a sua volta fair, cioè prevede una disciplina FIFO dei
thread sospesi su di essa. Se il lock di un monitor, e dunque le sue condition variables, sono fair,
allora è possibile programmare con relativa facilità soluzioni che garantiscano la SF, sempre a patto
di assumere uno scheduling fair dei thread (assunzione di fairness).
Tuttavia, è bene notare che la fairness ha un prezzo in termini di efficienza. Intanto, la gestione
FIFO delle attese comporta l’impiego di strutture dati aggiuntive e in generale un certo overhead
per la macchina virtuale. In secondo luogo, il numero di context switch complessivi può aumentare,
rispetto ad una gestione non fair. Sostanzialmente, con lock non fair, la politica di acquisizione del
lock è: quando il lock viene rilasciato, verrà assegnato, tra quelli che lo richiedono, al primo thread
che entra in esecuzione (barging). Ciò semplifica molto la gestione dello scheduling dei threads.
Inoltre, il caso in cui l’acquisizione del lock da parte di un certo thread venga ritardata in maniera
indefinita, per quanto teoricamente possibile, statisticamente non si dà. Nel caso di un fair lock, il
barging non è ammesso, essendo la politica strettamente FIFO. Come conseguenza, la gestione dello
scheduling diventa più complicata; in particolare, si può rendere necessario sospendere un thread
attualmente in esecuzione solo per forzare l’ordinamento FIFO di accesso al monitor. [1, Cap.14]
riporta il caso di un’applicazione per la quale, test di performance alla mano, l’adozione di lock
fair comporta un peggioramento delle prestazioni di circa 100 volte, rispetto al lock non fair. La
raccomandazione di [1, Cap.14] è di adottare un fair lock solo se questo è esplicitamente richiesto
dalle specifiche, e di adottare in altri casi lock non fair, affidandosi alla fairness statistica. Nei casi
in cui i vincoli sui tempi di risposta di un thread sono stringenti (es. applicazioni embedded, che
interagiscono con sensori ed attuatori), conviene più affidarsi ai package che permettono la gestione
del tempo reale che alla fairness.
Riferimenti bibliografici
[1] B. Goetz, T. Peierls, J. Bloch, J. Bowbeer, D. Holmes, D. Lea. Java Concurrency in Practice,
Addison-Wesley, 2006.
6
1 import java . util . Random;
2
3 class TestRWMonitor {
4 Random rand = new Random();
5 final int Values = 3;
6 RWMonitor monitor = new RWMonitor();
7
8 class Reader extends Thread {
9 int Name;
10 Reader(int ID) { Name = ID; }
11
12 public void run() {
13 for (int I = 1; I < Values; I ++) {
14 try {
15 Thread.sleep (rand. nextInt (2));
16 }
17 catch (InterruptedException e) {};
18 monitor. StartRead();
19 System.out.println (”Reader ” + Name + ” starts reading ”);
20 Thread.yield ();
21 System.out.println (”Reader ” + Name + ” ends reading ”);
22 monitor. EndRead();
23 try {
24 Thread.sleep (rand. nextInt (2));
25 }
26 catch (InterruptedException e) {};
27 }
28 }
29 }
30
31 class Writer extends Thread {
32 int Name;
33 Writer(int ID) { Name = ID; }
34
35 public void run() {
36 for (int I = 1; I < Values; I ++) {
37 monitor. StartWrite ();
38 System.out.println (”Writer ” + Name + ” starts writing ”);
39 Thread.yield ();
40 System.out.println (”Writer ” + Name + ” ends writing ”);
41 monitor. EndWrite();
42 }
43 }
44 }
45
46 TestRWMonitor() {
47 Reader R1 = new Reader(1);
48 Reader R2 = new Reader(2);
49 Reader R3 = new Reader(3);
50
51 Writer W1 = new Writer(1);
52 Writer W2 = new Writer(2);
53 Writer W3 = new Writer(3);
54
55 R1.start (); W1.start();
56 R2.start (); W2.start();
57 R3.start (); W3.start();
58 }
59
60 public static void main(String[] args ) {
61 TestRWMonitor tm = new TestRWMonitor();
62 } 7
63 }
9
1 import java . util . concurrent. locks . ReentrantLock;
2 import java . util . concurrent. locks . Condition;
3
4
5 /* Monitor con ReentrantLock e Condition: schema */
6
7 class MonitorWithConditionScheme {
8
9 /* dich. variabili di stato incapsulate (es. buffer , count, etc .) */
10
11
12 final ReentrantLock lock = new ReentrantLock();
13 final Condition cond1 = lock.newCondition(); /* cond1 associata a precondition1 (dipende da variabili di stato ) */
14 final Condition cond2 = lock.newCondition(); /* .... */
15
16
17
18
19
20 public myType method1(...) throws InterruptedException {
21 lock . lock ();
22 while ( ! precondition1 ){
23 cond1.await();
24 }
25
26 /* dopo while, assumere precondition1 true */
27
28 /*... */
29
30 if (precondition2 )
31 cond2.signal ();
32 lock . unlock();
33 }
34
35
36
37 }
.
10
1 class PCMonitorCond { /* Inner class : monitor PCMonitorCond */
2
3 private final ReentrantLock lock = new ReentrantLock();
4 private final Condition notFull = lock.newCondition();
5 private final Condition notEmpty = lock.newCondition();
6
7 private final int N = 5;
8 private volatile int Oldest = 0, Newest = 0;
9 private volatile int Count = 0;
10 private volatile int Buffer [] = new int[N];
11
12 public void Append(int V) {
13 lock . lock ();
14 while (Count == N)
15 try {
16 notFull . await();
17 } catch (InterruptedException e) {}
18 Buffer [ Newest] = V;
19 Newest = (Newest + 1) % N;
20 Count = Count + 1;
21 notEmpty.signal();
22 lock . unlock();
23 }
24
25 public int Take() {
26 int temp;
27 lock . lock ();
28 while (Count == 0)
29 try {
30 notEmpty.await();
31 } catch (InterruptedException e) {}
32 temp = Buffer[Oldest];
33 Oldest = (Oldest + 1) % N;
34 Count = Count = 1;
35 notFull . signal ();
36 lock . unlock();
37 return temp;
38 }
39 }
11
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 19 - 6/12/2022
monitor RW
integer readers ← 0
integer writers ← 0
condition OKtoRead, OKtoWrite
operation StartRead
if writers 6= 0 or not empty(OKtoWrite)
waitC(OKtoRead)
readers ← readers + 1
signalC(OKtoRead)
operation EndRead
readers ← readers − 1
if readers = 0
signalC(OKtoWrite)
operation StartWrite
if writers 6= 0 or readers 6= 0
waitC(OKtoWrite)
writers ← writers + 1
operation EndWrite
writers ← writers − 1
if empty(OKtoRead)
then signalC(OKtoWrite)
else signalC(OKtoRead)
reader writer
p1: RW.StartRead q1: RW.StartWrite
p2: read the database q2: write to the database
p3: RW.EndRead q3: RW.EndWrite
1
1 Correttezza dei lettori-scrittori con monitor
classici
Dimostriamo di seguito la correttezza dell’algoritmo lettori-scrittori con monitor
classici (IRR), riportato nella pagina precedente. Definiamo le seguenti variabili
di stato
def
R = n. di lettori attualmente in fase di lettura.
def
W = n. di scrittori attualmente in fase di scrittura.
(b) W ≤ 1.
(c) (W = 1) ⇒ (R = 0).
2
Esecuzione di una EndWrite, che chiama signalC(OktoRead), sbloccando
un lettore. Per ipotesi induttiva (b), nello stato di partenza W ≤ 1 e
dunque W = 1. La EndWrite completa l’esecuzione, decrementando W
e portandolo dunque a 0. La successiva signalC(OktoRead) innesca una
cascade unblocking di lettori, che però non modificano W . Nello stato
d’arrivo avrò dunque R > 0 ma anche W = 0, e dunque l’implicazione è
valida.
Veniamo ora alla prova di SF. Il lemma chiave è il seguente. Esso ci dice che se
una condition variable è non vuota, c’è un motivo. Nel caso di lettori in attesa, ci
sono delle scritture in corso oppure pendenti. Nel caso di scrittori in attesa, ci sono
delle letture o scritture in corso.
3
2. Comandi che possono modificare in falsa la conclusione. Potenzialmente, sono
i comandi che possono rendere W = 0 e/o svuotare la coda OkT oW rite. Ci
sono due possibilità
Una EndRead che esegua una signalC(OktoWrite) con OktoW rite non vuo-
ta. Il processo writer sbloccato riprenderà la sua esecuzione, completando
la propria StartWrite e dunque incrementando writers. Come risultato,
nello stato d’arrivo W > 0, e dunque la conclusione è vera.
Una EndWrite, che decrementa writers = W . A questo punto, o
empty(OktoRead), e allora anche la premessa è falsa e lo rimarrà nel-
lo stato d’arrivo. Oppure ¬empty(OktoRead), e in tal caso viene ese-
guita una signalC(OktoRead). Questo innesca una cascade unblocking
dei readers, che svuota OktoRead. Come risultato, nello stato d’arrivo
empty(OktoRead), e dunque la premessa è falsa.
(W 6= 0) ∨ ¬(empty(OktoW rite)) .
Ora, se in tale stato vale W 6= 0, vuol dire che c’è in corso una scrittura. Per
fairness, tale scrittura prima o poi terminerà, e prima o poi verrà quindi invocata
4
una EndWrite: essendo la coda dei lettori non vuota, tale EndWrite eseguirà una
signalC(OktoRead). Ma ciò è assurdo, perché va contro quanto affermato in (1).
Viceversa, se nello stato in questione vale ¬(empty(OktoW rite)), allora possiamo
applicare il Lemma precedente, parte (b), e trovare che in tale stato vale
(W 6= 0) ∨ (R 6= 0) .
5
Riferimenti bibliografici
[1] AA.VV. Chalmers University, Gothenburg, Course of Principles of Con-
current Programming HT19, https://www.cse.chalmers.se/edu/course/
TDA384_LP1/exercises/#Java, 2019.
6
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 20 - 13/12/2022
1
una situazione in cui per un processo diventerà impossile ottenere le risorse richieste.
Il codice relativo alle soluzioni in Java è disponibile sulla pagina Moodle del corso.
monitor ResourceAllocation
integer R ← N
condition array[1..2] OktoTake
operation request([1..2] k)
if R < k
waitC(OktoTake[k])
R←R-k
if R≥ 1
signalC(OktoTake[1])
operation release([1..2] k)
R←R+k
if (R≥ 2)∧ empty(OktoTake[1])
signalC(OktoTake[2])
else
signalC(OktoTake[1])
user1 user2
loop forever loop forever
p1: ResourceAllocation.request(1) q1: ResourceAllocation.request(2)
p2: use the resource q2: use the resources
p3: ResourceAllocation.release(1) q3: ResourceAllocation.release(2)
Il codice dovrebbe essere auto-esplicativo, tranne forse che per l’if finale nell’ope-
razione di request. Qui, la signalC(OktoTake[1]) ha lo scopo di innescare una (breve)
cascade unblocking: se il processo sta completando una request(1) a seguito di una
signalC eseguita dentro una release(2), ci sono infatti due risorse disponibili.
Ci si potrebbe chiedere perché, nella request, non viene considerata la possibilità
di sbloccare un processo da OktoTake[2], se OktoTake[1] è vuota. La risposta è che,
2
non appena due risorse si rendono disponibili a seguito di una release e OktoTake[1] è
vuota e OktoTake[2] è non vuota, un processo viene sbloccato da OktoTake[2] già dalla
stessa release, e dunque le due risorse vengono immediatamente riassegnate. Dunque,
in ogni stato, se ci sono almeno due risorse disponibili allora OktoTake[2] è vuota.
Una cosa simile si può dire di OktoTake[1]: se c’è almeno una risorsa disponibile, essa
è vuota. In altre parole, il seguente è un invariante del programma
(R ≥ 1 ⇒ empty(OktoT ake[1])) ∧ (R ≥ 2 ⇒ empty(OktoT ake[2])) . (1)
Sfruttando questo invariante, si vede allora che, nel momento in cui si arriva ad
eseguire l’if finale nella request, o perché si è superato direttamente il primo if, o
perché sbloccati da una condition variable a seguito di una release, se OkToTake[1] è
vuota e R ≥ 2, allora anche OkToTake[2] è vuota.
Per quanto riguarda la Safety, è immediato vedere che R ≥ 0 vale sempre, come
conseguenza del primo if della request. D’altra parte, sia #Uk , per k = 1, 2, una
variabile di stato che conta il numero di processi che hanno acquisito k risorse, senza
ancora rilasciarle, cioè sono alle locazioni 2 o 3 del programma. Allora il seguente è
un invariante facile da dimostrare
R = N − #U1 − 2 · #U2 = N − A . (2)
Poiché #Uk ≥ 0, se ne deduce che R ≤ N sempre.
Per quanto riguarda l’assenza di deadlock, si vede che nessun processo P di tipo 1
(cioè richiedente 1 risorsa) può rimanere bloccato. Infatti, se supponiamo per assurdo
che ciò accada. Allora P rimane per sempre nella coda OktoTake[1]: essendo questa
una coda FIFO, ciò implicha che da un certo stato in poi non vengono più eseguite
signalC(OktoTake[1]). Inoltre, per l’invariante (1) (primo congiunto), avremo sempre
R = 0 da quello stato in avanti. E dunque, per (2), in uno qualsiasi di tali stati,
o #U1 > 0 oppure #U2 > 0, ovvero c’è almeno un processo che ha acquisito delle
risorse senza ancora rilasciarle. Tale processo, per fairness, prima o poi invocherà una
release, che eseguirà una signalC(OktoTake[1]): ma ciò va contro quanto si è assunto
prima. Abbiamo cosı̀ dimostrato che il programma soddisfa la SF per ogni processo
di tipo 1, e dunque a fortiori l’assenza di deadlock.
In generale, la SF per i processi di tipo 2 (che richiedono due risorse) non varrà.
A mo’ di controesempio, si consideri un sistema con N = 3 risorse inizialmente dispo-
nibili, con tre processi di tipo 1 e un processo di tipo 2. Si consideri poi la seguente
sequenza di comandi, che dà origine ad una computazione fair (con le ovvie abbre-
viazioni; l’operazione in grassetto tra parentesi porta alla sospensione dell’invocante,
e quindi non è completata)
req1 (1), req2 (1), req3 (1), req4 (2) , rel1 (1), req1 (1), rel2 (1), req2 (1), rel3 (1), req3 (1), ...
3
Si noti tuttavia che, considerando tutte le possibilità future a partire da un qualsiasi
stato, è sempre possibile in linea di principio per un processo di tipo 2 riuscire ad
acquisire le risorse richieste (ad esempio, se tutti i processi di tipo 1 che attualmente
usano una risorsa la rilasciano prima, che arrivino ulteriori richieste). Detto in altri
termini: non si arriva mai ad una situazione di impossibilità futura di acquisizione
delle risorse, come richiesto.
Una soluzione che garantisca una vera e propria SF anche per i processi di tipo 2
si può ottenere alternando fasi di tipo 1 a fasi di tipo 2, in maniera simile a quanto
visto nella soluzione al problema dei lettori-scrittori.
4
1 class ResourceAllocationMonitor {
2
3 private volatile int R = 5;
4 private volatile int [] WaitingToTake = {0,0};
/* due contatori di threads in attesa di risorse */
5
6 synchronized void request(int k, int Name) {
7 WaitingToTake[k=1]++;
8 while ( R<k || (k==2 && WaitingToTake[0]>0) )
9 try {
10 System.out.println (”User process ” + Name + ” waiting for ” + k + ” resources ... ”);
11 wait();
12 } catch (InterruptedException e) {}
13 R = R = k;
14 WaitingToTake[k=1]==;
15 System.out.println (”User process ” + Name + ” acquired ” + k + ” resources. ”);
16 if (R>=1)
17 notifyAll ();
18 }
19
20 synchronized void release (int k, int Name) {
21 R = R+k;
22 System.out.println (”User process ” + Name + ” released ” + k + ” resources . ”);
23 notifyAll ();
24 }
25
26 }
5
User process 3 acquired 2 resources .
User process 5 acquired 2 resources .
User process 1 acquired 1 resources .
User process 4 waiting for 2 resources ...
User process 2 waiting for 1 resources ...
User process 5 released 2 resources .
User process 5 waiting for 2 resources ...
User process 2 acquired 1 resources .
User process 4 waiting for 2 resources ...
User process 5 waiting for 2 resources ...
User process 2 released 1 resources .
User process 2 acquired 1 resources .
User process 5 waiting for 2 resources ...
User process 4 waiting for 2 resources ...
User process 3 released 2 resources .
User process 3 acquired 2 resources .
User process 4 waiting for 2 resources ...
User process 5 waiting for 2 resources ...
User process 3 released 2 resources .
User process 5 acquired 2 resources .
User process 4 waiting for 2 resources ...
User process 1 released 1 resources .
User process 1 acquired 1 resources .
User process 4 waiting for 2 resources ...
User process 5 released 2 resources .
User process 4 acquired 2 resources .
User process 1 released 1 resources .
User process 4 released 2 resources .
User process 4 acquired 2 resources .
User process 2 released 1 resources .
User process 4 released 2 resources .
Program terminated correctly .
Anche questo programma Java soddisfa la Safety e la DF, ma ci sono delle differenze
rispetto ai monitor classici. In presenza di loop infiniti (while(true) al posto di for
nei thread), in Java si può solo dimostrare che, infinite volte, processi di tipo 1
otterranno la risorsa richiesta. Questa non è la vera SF per processi di tipo 1, che
è: ogni processo di tipo 1 avrà la risorsa richiesta infinite volte. Infatti, in Java non
c’è alcuna garanzia su come è gestita l’acquisizione del lock del monitor. Anche
6
in presenza di uno scheduler (weakly) fair, dunque, un certo processo in attesa di
acquisire il lock potrebbe non essere mai selezionato per entrare nel monitor.
7
1 import java.util . concurrent. locks . ReentrantLock;
2 import java.util . concurrent. locks . Condition;
3
4
5 class ResourceMonitorWithConditions {
6
7 private volatile int R = 5;
8
9 private final ReentrantLock lock = new ReentrantLock();
10 private final Condition[] OktoTake = { lock. newCondition(), lock . newCondition() };
11 private volatile int WaitingOktoTake1 = 0;
12
13
14 void request (int k, int Name) throws InterruptedException {
15 lock . lock ();
16 if (k==1) WaitingOktoTake1++;
17 while ( R<k ) {
18 System.out.println (”User process ” + Name + ” waiting for ” + k + ” resources ... ”);
19 OktoTake[k=1].await();
20 }
21 R = R = k;
22 if (k==1) WaitingOktoTake1==;
23 System.out.println (”User process ” + Name + ” acquired ” + k + ” resources. ”);
24 if (R>=1)
25 OktoTake[0].signal ();
26 lock . unlock();
27 }
28
29 void release (int k, int Name) {
30 lock . lock ();
31 R = R+k;
32 System.out.println (”User process ” + Name + ” released ” + k + ” resources . ”);
33 if (R>=2 && WaitingOktoTake1==0)
34 OktoTake[1].signal ();
35 else OktoTake[0].signal ();
36 lock . unlock();
37 }
38 }
8
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 21 - 14/12/2022
1
vengono completati simultaneamente: questo implica che chi dei due processi,
tra sender e receiver, è pronto per primo, dovrà attendere l’altro. In questo
senso, send e receive sincrone possono essere temporaneamente bloccanti per i
processi che le invocano. Nel caso asincrono, come già visto nel problema dei
produttori-consumatori, esiste un buffer (da qualche parte) che permette ai due
processi di procedere, entro certi limiti, in maniera indipendente.
La scelta tra tra modalità sincrona e asincrona si basa molto sulle caratte-
ristiche del problema da risolvere. La sincronia è sicuramente più facile da
implementare sia in hardware che in software (non c’è buffer), ma essa in pra-
tica implica un elevato grado di accoppiamento tra i due processi, che devono
lavorare quasi sempre alla stessa velocità. Quando tale condizione non sussiste,
è opportuno adottare una modalità asincrona.
Flusso dei dati. Il passagio dei dati può essere unidirezionale (sender →
receiver) oppure bidirezionale (sender → receiver + receiver → sender). Le
primitive su canale seguono la prima filosofia, quella di rendez-vous la secon-
da. Per quest’ultima, il caso tipico è quello di una interazione client-server,
nella quale un client, dopo aver invocato un servizio passandogli gli argomenti
richiesti, attende la risposta.
2
2 Canali
Nella classificazione precedente, i canali ricadono nei casi: sincrono/asincrono, sim-
metrico, unidirezionale. Trattiamo per primo il caso sincrono. La dichiarazione
channel of type ch
istanzia un canale sincrono di nome ch, sul quale possono essere trasmessi valori
di tipo type. Questo canale può essere riferito da tutti i processi nello scopo della
dichiarazione. Il canale viene usato chiamando su di esso le primitive di send e receive
sincrone, secondo la sintassi seguente
3
Abbiamo fin qui considerato il caso dei canali sincroni. Un canale asincrono
viene dichiarato specificando anche la sua capacità, cioè la dimensione del buffer che
accoglie i messaggi in transito. Ricalcando la sintassi Promela, dove i canali sono
supportati, un canale di capacità N è dichiarato con questa sintassi
channel of type ch = N
La semantica dei canali asincroni è semplice: la send deposita un messaggio nel buffer,
la receive lo preleva. Tuttavia, send e receive eseguite con buffer, rispettivamente,
pieno e vuoto, non danno errore. Più semplicemente, se il buffer è pieno, la send è
bloccante; l’effetto della prossima receive includerà allora anche lo sblocco del sender
e, atomicamente, il completamento della send. Analogamente, la receive è bloccante
finché il buffer è vuoto. Un canale sincrono può essere visto come un canale asincrono
con capacità 0.
Una soluzione con canali asincroni al problema del produttore-consumatore con
buffer finito, si ottiene semplicemente rimpiazzando, nel programma precedente, la
dichiarazione channel of type ch con la dichiarazione di canale asincrono channel of
type ch = N. Non sono necessarie ulteriori strutture di sincronizzazione.
4
il caso di tre persone che devono occuparsi di lavare una pila di piatti sporchi: essi
possono organizzarsi in una pipeline, nella quale il primo prende un piatto dalla pila,
lo insapona e poi lo passa al secondo; il secondo lo sciacqua e poi lo passa al terzo; il
terzo lo asciuga e poi lo deposita su un tavolo. Si noti che per il passaggio del piatto
da uno stadio al successivo della pipeline non è previsto un buffer: ciascuno passa
il piatto direttamente o nelle mani della persona successiva o sul tavolo. Questo
comportamento corrisponde, in un programma, all’impiego di canali sincroni.
Consideriamo un classico problema di programmazione, quello di Conway. Un
processo compress riceve, da un processo generatore, una sequenza infinita di carat-
teri, uno alla volta. Il compito di compress è di comprimere la sequenza, rimpiazzando
ogni run (sequenza di lettere consecutive identiche) di m lettere, con 2 ≤ m ≤ m,
con la cifra m seguita da una singola lettera. La sequenza cosı̀ ottenuta viene pas-
sata, un carattere alla volta, ad un processo output, il quale semplicemente inserisce
un carattere newline (ritorno a capo) ogni K caratteri. Per esempio, se K = 4 e la
sequenza ricevuta in ingresso da compress è
aaaabcaabbd...
la sequenza che esce da compress sarà
4abc2a2bd...
mentre la sequenza che esce da output sarà
4abc
2a2b
d...
La sequenza cosı̀ ottenuta viene passata, sempre un carattere alla volta, ad un pro-
cesso collettore. Si possono organizzare questi processi in una pipeline. Si osservi la
figura sottostante, dove non sono rappresentati il processo generatore e il collettore.
5
nel primo caso, finché arrivano caratteri uguali, compress non passerà caratteri
ad output, e questo realizza la compressione;
nel secondo caso, il run viene compresso se la sua lunghezza è > 1, e viene
comunque passato ad output l’ultimo carattere previous memorizzato, mentre
previous viene aggiornato a c.
Per la precisione, per quanto riguarda il codice di compress, si osservi che:
all’inizio di ogni iterazione del loop, la variabile n conta il numero di caratteri
consecutivi uguali ricevuti dopo il primo carattere del run;
è prevista una lunghezza massima MAX=9 dei run che si possono leggere,
raggiunta la quale il run fin qui letto va comunque compresso.
Algorithm: Conway’s problem
constant integer MAX ← 9
constant integer K ← 4
channel of char inC, pipe, outC
compress output
char c, previous ← 0 char c
integer n ← 0 integer m ← 0
inC ⇒ previous
loop forever loop forever
p1: inC ⇒ c q1: pipe ⇒ c
p2: if (c = previous) and q2: outC ⇐ c
(n < MAX − 1)
p3: n←n+1 q3: m←m+1
else
p4: if n > 0 q4: if m >= K
p5: pipe ⇐ intToChar(n+1) q5: outC ⇐ newline
p6: n←0 q6: m←0
p7: pipe ⇐ previous q7:
p8: previous ← c q8:
Riferimenti bibliografici
[1] C.A.R. Hoare. Communicating Sequential Processes, Prentice Hall International,
1985. Il PDF di una versione aggiornata è disponibile gratuitamente alla pagina
http://www.usingcsp.com/.
6
byte fridge = 1;
byte milk = 0;
byte note = 0;
byte nw = 0;
byte waitToDrink = 0;
byte wC = 0;
ltl sa { [] ( milk<2 ) }
ltl sa2 { [] ( milk>=0 ) }
ltl li { []<>( milk==1) }
ltl li2 { [] ( (nw > 0) -> <>(nw==0) ) }
ltl li3 { [] ( (wC > 0) -> <>(wC==0) ) }
L’elemento di riga i e colonna j della matrice A, aij , viene utilizzato nel calcolo di tre
elementi della matrice risultato C, cioè ci1 , ci2 , ci3 . Ciascuno di questi viene ottenuto
moltiplicando la riga i di A per la colonna j = 1, 2, 3 di B. Cosı̀, per esempio,
1
l’elemento a22 = 5 di A viene usato per nel calcolo di c21 , c22 , c23 :
1
c21 = 4 5 6 × 0 = 6 · 1 + 5 · 0 + 4 · 1 = 10
1
0
c22 = 4 5 6 × 1 = 6 · 0 + 5 · 1 + 4 · 0 = 5
0
2
c23 = 4 5 6 × 2 = 6 · 0 + 5 · 2 + 4 · 2 = 18 .
0
4. propagare il valore b2j verso il processo 8, in modo che esso lo possa utilizzare
2
Source Source Source
2 2 0
0 1 0
1
? 0
? 1
?
4,2,6 3,2,4 3,0,0 0,0,0
Result 1 2 3 Zero
2 2 0
0 1 0
1
? 0
? 1
?
10,5,18 6,5,10 6,0,0 0,0,0
Result 4 5 6 Zero
2 2 0
0 1 0
1
? 0
? 1
?
16,8,30 9,8,16 9,0,0 0,0,0
Result 7 8 9 Zero
2 2 0
0 1 0
1
? 0
? 1
?
Sink Sink Sink
I processi Multiplier sono impiegati nella griglia centrale. Ai bordi, identificati qui
con le quattro direzioni cardinali, abbiamo altri quattro tipi di processi:
Source a Nord: forniscono gli elementi di riga j di B ai processi che li utilizzano,
cioè quelli di colonna j di A, per j = 1, 2, 3;
Zero a Est: forniscono le somme parziali iniziali, pari a 0
Sink a Sud: raccolgono gli elementi delle righe di B, che vengono fatti percolare
da Nord, dai Multiplier dell’ultima riga. Il loro scopo è semplicemente di con-
sentire ai Multiplier dell’ultima riga di comportarsi esattamente come gli altri
Multiplier.
Result a Ovest: raccolgono, uno alla volta, gli elementi di riga i del risultato,
per i = 1, 2, 3. Potranno poi stamparli o farne un altro uso.
Diamo dunque la descrizione del processo Multiplier generico, essendo quella degli
altri processi banale. Si noti che le variabili FirstElement (elemento di A di cui il
processo è responsabile) e quelle di tipo canale North, East, South, West di questo
processo generico, dovranno essere opportunamente istanziate, con valori che dipen-
dono dalla posizione che il processo andrà ad occupare nella griglia centrale. Si noti
che in questa configurazione, ogni multiplier eseguirà il ciclo N = 3 volte. In gene-
rale, se ogni processo multiplier è assegnato ad un distinto processore, il programma
esegue l’intera moltiplicazione in un tempo proporzionale ad N .
3
Algorithm: Multiplier process with channels
channel of integer North, East, South, West ← · · ·
integer FirstElement← · · ·
integer Sum, integer SecondElement
loop forever
p1: North ⇒ SecondElement
p2: East ⇒ Sum
p3: Sum ← Sum + FirstElement · SecondElement
p4: South ⇐ SecondElement
p5: West ⇐ Sum
either
ch1 ⇒ x1
c1
c2
...
or
ch2 ⇒ x2
c’1
c’2
...
Una sola delle due alternative viene eseguita, quella la cui receive è pronta per
prima; l’altra alternativa viene scartata. Nel caso nessuna receive sia pronta, si
attende che una la diventi. Se tutte e due le receive sono pronte, se ne sceglie una
4
nondeterministicamente. Impiegando l’input selettivo, possiamo programmare una
versione più efficiente, benché funzionalmente equivalente, del processo Multiplier.
Algorithm: Multiplier with channels and selective input
integer FirstElement
channel of integer North, East, South, West
integer Sum, integer SecondElement
loop forever
either
p1: North ⇒ SecondElement
p2: East ⇒ Sum
or
p3: East ⇒ Sum
p4: North ⇒ SecondElement
p5: South ⇐ SecondElement
p6: Sum ← Sum + FirstElement · SecondElement
p7: West ⇐ Sum
5
solo il codice dei filosofi regolari e della forchetta. Al solito, uno dei cinque filosofi
dovrà comportarsi in maniera irregolare per evitare il rischio di deadlock.
4 Canali in Java
E’ possibile programmare con i canali in Java servendosi dell’interfaccia Blocking-
Queue definita nel package java.util.concurrent. Le istanze di BlockingQueue sono og-
getti buffer, che offrono operazioni di inserimento e prelievo potenzialmente bloccanti:
i metodi put() e take() (esistono altri metodi molto utili in pratica, per i quali si riman-
da alla documentazione o a [1, Cap.13]). L’interfaccia ha diverse implementazioni,
tra le quali le classi
6
1 import java . util . concurrent. SynchronousQueue;
2
3 class Conway {
4
5 final SynchronousQueue<Character> pipe = new SynchronousQueue<Character>();
6 final SynchronousQueue<Character> inC = new SynchronousQueue<Character>();
7 final SynchronousQueue<Character> outC = new SynchronousQueue<Character>();
8
9 final char[] myinputsequence = ”aaayuaaauuaabbd0”.toCharArray();
// NB: ’0’ marca la fine sequenza
10
11 final static int MAX = 9;
12 final static int K = 4;
13
14 class Compress extends Thread {
15 public void run() {
16 try{
17 Character c, previous = ’0’;
18 int n = 0;
19 previous = inC.take();
20 for (;;) {
21 c = inC.take();
22 if (c==previous && n<MAX=1)
23 n++;
24 else {
25 if (n>0) {
26 pipe. put(Character. forDigit (n+1, 10));
27 n=0;
28 };
29 pipe. put(previous );
30 previous = c;
31 }
32 }
33 } catch (InterruptedException e) {}
34 }
35 }
36
37
38 class Output extends Thread {
39 public void run() {
40 try {
41 Character c = ’0’;
42 int m = 0;
43 for (;;) {
44 c = pipe.take();
45 outC.put(c);
46 m++;
47 if (m>=K){
48 outC.put(’
7 \n’);
49 m=0;
50 }
51 }
52 } catch (InterruptedException e) {}
53 }
54 }
8
campo myinputsequence della classe (il carattere 0 marca la fine della sequenza e non
viene considerato come appartenente ad essa).
Terminiamo con una versione Java della soluzione al problema produttore-
consumatore con canali asincroni (Figura 3). La versione con canali sincroni si ottie-
ne semplicemente rimpiazzando ArrayBlockingQueue con SynchronousQueue. Si veda
anche il codice disponibile online sulla pagina Moodle del corso.
Riferimenti bibliografici
[1] B. Goetz, T. Peierls, J. Bloch, J. Bowbeer, D. Holmes, D. Lea. Java Concurrency
in Practice, Addison-Wesley, 2006.
9
1 /* ArrayBlockingQueue implementa BlockingQueue */
2 import java . util . concurrent. ArrayBlockingQueue;
3
4 /* Producer=consumer con ArrayBlockingQueue */
5
6 class ProducerConsumerAsyncChannel {
7
8 final ArrayBlockingQueue<Integer> ch = new ArrayBlockingQueue<Integer>(3);
9
10 class Producer extends Thread {
11 public void run() {
12 for (int I = 1; I < 5; I ++) {
13 try{
14 ch. put(I );
15 }
16 catch (InterruptedException e) {}
17 }
18 }
19 }
20
21 class Consumer extends Thread {
22 public void run() {
23 int c=0;
24 for (int I = 1; I < 5; I ++) {
25 try {
26 c=ch.take();
27 } catch (InterruptedException e) {}
28 System.out.println (”Item ” + c + ” transmitted . ”);
29 }
30 }
31 }
32
33 ProducerConsumerAsyncChannel() {
34 Producer P = new Producer();
35 Consumer C = new Consumer();
36 P.start (); C.start ();
37 try{
38 P.join (); C.join ();
39 } catch (InterruptedException e) {}
40 System.out.println (”Program terminated correctly . ”);
41 }
42
43 public static void main(String[] args ) {
44 new ProducerConsumerAsyncChannel();
45 }
46 }
1 La primitiva di rendezvous
Il rendezvous è una modello di comunicazione a scambio di messaggi che coinvolge due
processi, il calling e l’accepting. I due processi si sincronizzano, dandosi appuntamento in
un luogo virtualmente rappresentato dal nome di un servizio, detto entry. Come in ogni
appuntamento, il primo dei due che arriva, aspetta l’altro. Tuttavia, a differenza che nella
vita reale, è presente qui una asimmetria. Infatti, mentre il calling conosce naturalmente
l’identità dell’accepting e il nome del servizio, o entry, che desidera invocare, l’accepting
non conosce, in generale, l’identità dei potenziali processi calling, che possono essere molto
numerosi. Questa primitiva si presta bene, come facilmente intuibile, alla programmazione
di architetture client-server, in cui:
il calling è il client;
l’accepting è il server ;
client server
integer parm, result integer p, r
loop forever loop forever
p1: parm ← . . . q1: do something
p2: server.service(parm, result) q2: accept service(p, r)
p3: use(result) q3: r ← do the service(p)
L’esecuzione del rendezvous vede due fasi, la prima in cui le informazioni viaggiano dal client
al server (richiesta), la seconda in cui viaggiano dal server al client (risposta). Vediamo
l’esecuzione nel dettaglio, con l’aiuto del diagramma temporale sottostante.
Il client, dopo aver calcolato i parametri attuali, o argomenti, e averli copiati nella sua
variabile locale parm, invoca il servizio service del server, chiamando server.service (istante
1
t1 ). Immaginiamo ora che il server non sia ancora pronto ad eseguire la corrispondente
accept, perché impegnato ad eseguire do something. In tal caso, il client deve attendere,
rimanendo bloccato sull’invocazione server.service, fino a quando il server sarà pronto ad
eseguire la accept (istante t2 ). Viene qui completata la prima fase del rendezvous: il valore
di parm viene passato al server e copiato nella variabile locale p. Ora il server ha le infor-
mazioni necessarie per lo svolgimento del servizio richiesto, specificato nel blocco seguente,
in questo caso indicato genericamente con do the service(p). Quando la risposta è pronta,
essa viene inviata al client: sintatticamente, questo corrisponde nel server all’assegnamento
r← · · · . Come effetto, il valore di r viene copiato nella result del client (istante t3 ). Nell’in-
tervallo da t2 a t3 viene dunque eseguito il servizio, e il client dovrà ancora attendere. Al
tempo t3 la sincronizzazione è stata completata, e il client e il server possono riprendere
ciascuno la propria esecuzione, in maniera indipendente.
Naturalmente è anche possibile che il server arrivi al rendezvous prima del client, nel
qual caso è il server a dover attendere che un client sia pronto alla prima sincronizzazione.
Il client dovrà poi comunque attendere l’esecuzione del servizio da parte del server. Il
diagramma temporale sottostante si modificherà di conseguenza (esercizio).
t1 t2 t3
calling
6
parameters results
accepting ?
time →
Dal punto di vista della classificazione delle primitive message passing, il rendezvous può
dunque essere visto come ricadente nel caso sincrono, asimmetrico, bidirezionale.
Vediamo una semplice istanza dello schema precedente: un sistema client-server dove il
server offre un servizio di square, che ricevuto un numero intero restituisce il suo quadrato.
Algorithm: Client-server squaring
client server
integer x, result integer p, r
loop forever loop forever
p1: x ← produce q1: do something
p2: server.service(x, result) q2: accept service(p, r)
p3: write(result) q3: r ← p*p
2
In questo esempio l’esecuzione del servizio è molto semplice. In altri casi essa potrebbe
essere complessa e assorbire a lungo il server. In un sistema con tanti client, tutti i client
verrebbero di conseguenza ritardati, con un impatto negativo sul throughput, cioè sul nume-
ro di richieste servite per unità di tempo. In casi come questi, è opportuno considerare la
possibilità di implementare un servizio multithreaded. In sostanza, all’arrivo di una nuova
richiesta, il server crea un nuovo processo, che verrà eseguito concorrentemente a quelli già
attivi, e che si occuperà di eseguire il servizio do the service(p) per quella richiesta. Il server
sarà cosı̀ subito disponibile per accettare nuove richieste, da parte di altri client. Que-
sta architettura può migliorare il throughput, a patto di avere sufficienti risorse hardware
(CPU) sul lato del server, in modo da garantire un certo grado di parallelismo vero.
Mostriamo una versione multithreaded del programma precedente, con due client an-
ziché uno solo. Introduciamo un nuovo comando atomico per la creazione dinamica di
processi:
L’esecuzione di questo comando crea e rende eseguibile una nuova istanza del tipo processo
P(x1,x2,...) con parametri attuali v1, v2, .... Il nuovo processo sarà eseguito in parallelo a
quelli già esistenti, ivi compreso il processo che ha eseguito lo spawn. Nel programma che
segue, il simbolo & accanto ad un parametro formale indica che il passaggio del parametro
è effettuato con modalità by reference (per variabile).
C’è ancora un elemento piuttosto comune nella programmazione di sistemi client server.
Infatti, un server può offrire servizi differenti, disponibili per i client ad entries differenti.
In tal caso, il server deve rimanere in ascolto di possibili richieste di servizio su tutte queste
entries. Questo si può descrivere, semanticamente, con l’analogo per la accept dell’input
(receive) selettivo, ovvero il comando either ... or:
3
either
accept service1 (p, r )
r ← do the service1(p)
or
accept service2 (p, r )
r ← do the service2(p)
La semantica di questo comando è analoga a quella dell’input selettivo: il primo ren-
dezvous pronto per essere iniziato, determina l’alternativa che viene eseguita; l’altra viene
scartata. In caso di più rendezvous pronti, una sola alternativa tra quelle pronte viene scel-
ta, in maniera nondeterministica. A mo’ di esempio, ecco un semplice sistema client-server
dove il server offre due servizi, uno di squaring, uno di cubing (elevamento al cubo). In
questo esempio, non usiamo il multithreading per il server. Dunque, ad ogni iterazione del
loop principale del server, una e una sola richiesta viene servita.
4
L’architettura client-server con rendezvous viene quindi implementata tramite il se-
guente schema.
3 Rendezvous in Java
Il rendezvous viene tipicamente impiegato in architetture client-server. Nel caso di pro-
grammi Java, bisogna distinguere due scenari di applicazione principali.
Scenario locale. Il client e il server risiedono fisicamente sulla stessa macchina, che
potrebbe essere un sistema multitasking, oppure un multiprocessore. Per qualche
ragione (leggibilità del codice o altro), si preferisce uno stile di programmazione che
eviti l’uso esplicito delle variabili globali. In tal caso esistono varie possibilità. Una,
che illustreremo di seguito, è quella di utilizzare lo schema di implementazione tramite
canali visto in precedenza.
Collochiamoci dunque nello scenario locale e ricordiamo che i canali sincroni in Java
sono oggetti della classe SynchronousQueue. Il tipo coppia (integer, channel of integer) viene
qui reso tramite la classe Param, che contiene due campi.
5
class Param { // Tipo parametro di chiamata = argomento + canale di ritorno
Integer arg;
SynchronousQueue<Integer> retCh;
6
1 import java. util . concurrent. SynchronousQueue;
2 import java. util . Random;
3
4 /* Client =server con Rendez Vous */
5
6 class ClientServerRV {
7
8 final Random rand = new Random();
9 final SynchronousQueue<Param> accept = new SynchronousQueue<Param>();
// richieste arrivano su canale accept
10
11 class Client extends Thread {
12 int id ;
13 Client (int myid){ this . id = myid; }
14
15 public void run() {
16 try {
17 Integer result ;
18 Param par = new Param(0, new SynchronousQueue<Integer>());
19 for (int p = 1; p <6 ; p++) {
20 par. arg = p;
21 accept. put(par); // accept <= (p, retCh)
22 System.out.println (”Client ” + id + ”: waiting for answer to ” + p + ” ... ”);
23 result = (par.retCh). take(); // retCh => result
24 System.out.println (”Client ” + id + ”: received result ” + result );
25 }
26 }
27 catch (InterruptedException e) {}
28 }
29 }
30
31 class Server extends Thread {
32 public void run() {
33 try {
34 Param received;
35 for (;;) {
36 received = accept.take(); // accept => received
37 System.out.println (”Server : elaborating answer for request ” + received. arg + ” ... ”);
38 Thread.sleep (rand. nextInt (500));
39 (received . retCh). put( received . arg * received . arg); // retCh <= (received.arg)ˆ2
40 }
41 }
42 catch (InterruptedException e) {}
43 }
44 }
45
46 ClientServerRV() {
47 Client C1 = new Client(1);
48 Client C2 = new Client(2);
49 C1.start ();
50 C2.start ();
51 new Server(). start ();
52
53 try {
54 C1.join ();
55 C2.join ();
56 } catch (InterruptedException e) {}
57
58 7
System.out.println (”Clients terminated. ”);
59 }
60
61 public static void main(String[] args ) {
62 new ClientServerRV();
63 }
64 }
SpawnedThread(Param par) {
this . received = par;
}
8
4.1 Dai canali sincroni ai monitor
Faremo vedere che le primitive di send e receive su un generico canale sincrono possono
essere tradotte (in un certo senso, compilate) in un linguaggio che supporta i monitor ma
non i canali. La cosa è, a ben vedere, abbastanza intuitiva. Il monitor incapsula una
variabile buf, che viene impiegata per passare l’informazione dal sender al receiver. Le
primitive send e receive corrispondono ad operazioni del monitor. Per garantire che esse
vengano completate nello stesso momento (in un unica transizione), l’idea è che il primo tra
il sender e il receiver che chiama la rispettiva operazione aspetterà l’altro, sospendendosi allo
scopo in una opportuna condition variable del monitor: OkSender o OkReceiver. Il secondo
che arriva avrà cura di sbloccare il primo, segnalandolo. In questo scenario, supponiamo
per semplicità di avere un solo sender e un solo receiver che condividono il canale.
Algorithm: Synchronous channel (1 sender, 1 receiver) with a monitor
monitor ch
integer buf
condition OkSender, OkReceiver
operation receive
if ¬ empty(OkSender)
signalC(OkSender)
else waitC(OkReceiver)
return buf
operation send(integer x)
buf ← x
if ¬ empty(OkReceiver)
signalC(OkReceiver)
else waitC(OkSender)
sender receiver
integer x integer y
loop forever loop forever
p1: x← produce q1: y←ch.receive
p2: ch.send(x) q2: use(y)
Esercizio 2. Si mostri come modificare l’algoritmo precedente per il caso in cui sul canale
possono ricevere e inviare un numero arbitrario di processi.
9
L’idea è quella di vedere un semaforo come un processo sem, che ascolta da due canali
sincroni, wait e signal. Il processo possiede una variabile locale S, che corrisponde al n. di
permessi attuali del semaforo. Un terzo canale go è utilizzato per sospendere i processi
che hanno eseguito una comunicazione su wait, quando S è uguale a zero. Il numero dei
processi sospesi è indicato da una variabile waiting. Non viene garantita alcuna disciplina
di ordinamento dei processi sospesi, quindi stiamo in effetti implementando un weak se-
maphore. Un processo che vuole eseguire una wait(sem) esegue una send sul canale wait e
poi si mette in attesa di un via libera (receive) da go. Un processo che vuole eseguire una
signal(sem) semplicemente esegue una send sul canale signal.
10
Università di Firenze - CdS Triennale in Informatica
Soluzione. Viene omessa la descrizione dei generatori. Si noti in Sum l’uso dell’input
selettivo, che evita di ritardare inutilmente il più veloce tra i processi 2Times e 3Times.
Algorithm: combine integer sequences
channel of integer in2, in3, out2, out3, out
1
Più precisamente, il generico processo Fp , stampa una sola volta p, dopodiché, ripetuta-
mente, riceve un intero n e, se esso non è un multiplo di p, lo passa al processo successivo
della pipeline. Se un successivo non esiste (Fp è al momento l’ultimo della pipeline), crea
prima un processo successivo Fn , a cui si connette.
Descrivere l’algoritmo di Eratostene nel linguaggio usato nel corso. Il programma preve-
derà la definizione di un tipo processo F(integer p, channel of integer in), dove in rappresenta
il canale dal quale il processo riceve, una alla volta, gli interi. Si farà uso opportuno del
comando spawn.
Programmarne poi una versione in Java.
Soluzione. Si noti l’uso del flag booleano last per indicare se il processo è correntemente
l’ultimo della pipeline oppure no.
Algorithm: Eratosthenes
write(p)
loop forever
in ⇒ n
if (n mod p) ̸= 0
if ¬ last
next ⇐ n
else last←false
spawn F(n,next)
generator pipeline
integer x←2
loop forever
p1: in2 ⇐ x q1: spawn F(2,in2)
p2: x← x+1 q2:
La versione Java dello stesso algoritmo, limitata alla generazione di primi ≤ 200, è
riportata in Figura 1. Si veda anche il codice messo a disposizione sulla pagina Moodle del
corso.
2
1 import java . util . concurrent. SynchronousQueue;
2
3 /* Crivello di Eratostene con pipeline */
4
5 class Eratosthenes {
6
7 class F extends Thread { // F(p,in ) : processo filtro per p, riceve interi da canale in
8
9 final Integer p;
10 final SynchronousQueue<Integer> in;
11 private boolean last = true; // alla creazione , è l ’ ultimo della pipe
12
13 F(int myp, SynchronousQueue<Integer> myin) { this.p = myp; this.in = myin; }
// costruttore binario
14
15 public void run() {
16 try {
17 System.out.println (p);
// stampa p, una volta sola
18 SynchronousQueue<Integer> next = new SynchronousQueue<>();
// nuovo canale per prox. filtro
19 Integer n;
20 while (true) {
21 n = in. take(); // ricevi n da in
22 if (n % p > 0){ // se n non multiplo di p
23 if (! last ) next. put(n);
// se F non è ultimo inoltra n al prossimo F
24 else {
25 last = false ;
// altrimenti crea e fai partire nuovo F
26 new F(n,next).start (); // che diventa ultimo della pipe
27 }
28 }
29 }
30 }
31 catch (InterruptedException e) {}
32 }
33 }
34
35 class Generator extends Thread {
36 final SynchronousQueue<Integer> out;
37
38 Generator(SynchronousQueue<Integer> myout){ this.out = myout; }
39
40 public void run() {
41 try {
42 for (int n = 2; n < 200; n++) {
43 out. put(n);
44 }
45 }
46 catch (InterruptedException e) {}
47 }
48 }
49
50 Eratosthenes() {
51 SynchronousQueue<Integer> in2 = new SynchronousQueue<>();
52 new Generator(in2). start ();
53 new F(2,in2). start (); // inizialmente , solo filtro per 2 è presente
54 }
55
56 public static void main(String[] args ) {
57 new Eratosthenes(); 3
58 }
59 }
ch. poll ()
ha lo stesso effetto dell’invocazione ch.take(), se eseguita in uno stato in cui un altro thread
è pronto a comunicare su ch (eseguire una corrispondente ch.put()); altrimenti, restituisce
il valore null.
La soluzione deve rispettare i seguenti vincoli:
una sola richiesta alla volta può essere servita da parte del server (ovvero, in ogni
momento al più un client viene servito dal server);
alla fine di ogni esecuzione di uno dei due servizi viene incrementato e poi stampato
un contatore count;
4
Qui, alt1 e alt2 rappresentano due sotto-thread, generati dal thread Server. Essi so-
no eseguiti in parallelo, e sono in continuo ascolto sui – eseguono il polling dei – canali
acceptSqare e acceptCube, rispettivamente. Per garantire che una e solo una delle alterna-
tive venga scelta, il polling dei canali va inserito all’interno un ciclo, il corpo del quale va
eseguito in mutua esclusione, esso cioè rappresenta una sezione critica. La variabile condi-
visa done, inizialmente a false, ha la funzione di informare il sotto-thread che rappresenta
l’alternativa scartata che deve desistere, e va acceduta in mutua esclusione.
Fatte queste premesse, possiamo riportare e commentare il codice della classe fonda-
mentale, che è Listener, una inner class di Server. Listener è la classe dei sotto-thread
generati dal server e rappresentanti le alternative di either; perciò alt1 e alt2 di cui sopra
sono entrambi di classe Listener. In particolare, si noti che
new Listener(mychannel, myservice) crea un thread che fa il polling del canale mychan-
nel. Se l’alternativa corrispondente viene selezionata, tale thread eseguirà il servizio
rappresentato dal thread myservice;
Listener si presuppone essere dichiarata nello scopo della variabile booleana done,
all’interno della classe Server (ricordiamo che classe Param, vista nella lezione prece-
dente, corrisponde al tipo coppia (integer, channel of integer));
5
class Listener extends Thread {
SynchronousQueue<Param> channel;
Thread service ;
6
Università di Firenze - CdS Triennale in Informatica
1 Informazioni generali
È permesso consultare solo il materiale didattico messo a disposizione dal docente. Tutte le risposte
ai quesiti vanno giustificate. Il punteggio assegnato ad ogni esercizio è riportato tra parentesi.
Tempo a disposizione: 2 ore.
2 Quesiti
1) (8pt) Si consideri il programma concorrente sotto descritto.
Algorithm: Concurrent algorithm
integer n ← 1
P Q
p1: while n < 1 q1: while n >= 0
p2: n←n+1 q2: n←n−1
(a) Costruire uno scenario weakly fair in cui il programma non termina.
(b) Formalizzare in LTL le seguenti proprietà.
(ϕ) Prima o poi il programma termina.
(ψ) Se il programma non termina mail, allora n assume il valore 0 infinite volte.
Soluzione. (a) Un possibile scenario è il seguente. Nella tabella, la quinta riga riprodotta è uguale
alla prima, e dunque siamo in presenza di un ciclo. Inoltre tutti e due i processi vengono eseguiti
all’interno del ciclo. Tale ciclo dà perciò origine ad una computazione weakly fair infinita.
cP cQ n
p1 q1 1
p1 q2 1
p1 q1 0
p2 q1 0
p1 q1 1
.. .. ..
. . .
(b1) ϕ = ♢((cP = nil) ∧ (cQ = nil)).
(b2) ψ = ¬ϕ ⇒ (♢□(n = 0)).
1
2) (8pt) Rispetto al programma dell’esercizio precedente, dire, giustificando la risposta, se le
seguenti formule sono invarianti.
(a) p2 ⇒ (n < 1).
(b) (p1 ∨ p2) ⇒ (n < 1).
Soluzione. (a) p2 ⇒ (n < 1).
Dimostriamo che la formula data è un invariante per induzione computazionale. Il caso base è
ovvio. Per il passo induttivo, trattandosi di una implicazione dobbiamo solo controllare
i comandi che possono modificare in vera la premessa; è il solo comando p1, che naturalmente
porta a p2 solo se n < 1, cosa che permane vera nello stato d’arrivo della transizione;
i comandi che possono modificare in falsa la conseguenza; è il solo comando p2, che però
falsifica anche la premessa, dunque l’implicazione è vera nello stato d’arrivo della transizione.
(b) (p1 ∨ p2) ⇒ (n < 1). Questa formula è falsa per esempio nello stato iniziale, dunque non è un
invariante.
3) (8pt) (a) Scrivere la forma abbreviata della soluzione con semafori al problema del produttore-
consumatore, con buffer di capacità 1 (NB: si intende che i comandi che usano take, append, produce
e consume diventano dei commenti e possono essere omessi).
(b) Disegnare il diagramma stati-transizioni della soluzione data nel punto precedente.
Soluzione. (a)
2
• p1, q1, (1, ∅), (0, ∅)
P C
p2, q1, (0, ∅), (0, ∅) p1, q1 : B, (1, ∅), (0, {C})
P
P
C
p1, q1, (0, ∅), (1, ∅) p2, q1 : B, (0, ∅), (0, {C})
P P
C
C
C P
4) (8pt) Il parcheggio di un centro commerciale ha una capienza di 42 posti auto e due ingressi
indipendenti, uno per le auto e uno per i camper. Un camper occupa due posti auto. Un veicolo
può entrare da uno dei due ingressi, se c’è posto, altrimenti attende. Dopo la sosta, il veicolo
lascia il parcheggio da un’uscita. Programmare il sistema come un programma concorrente in cui i
veicoli sono processi, e il parcheggio è un monitor che offre le operazioni enterCar (ingresso auto),
enterCamper (ingresso camper) e exit (uscita). Il monitor incapsula, fra l’altro, una variabile intera
free che conta il numero di posti auto attualmente liberi. Sono previste condition variable OkCar e
OkCamper per la sospensione dei veicoli in attesa di entrare. I camper hanno precedenza sulle auto.
Assumere politica IRR. Per ciascuna delle due categorie di veicoli, discutere se il sistema soddisfa
SF.
Soluzione. Una possibile soluzione è la seguente.
3
Algorithm: Shopping mall
monitor Parking
integer free ← 42
condition OkCar
condition OkCamper
operation enterCar
if (free <1) ∨¬ empty(OkCamper)
waitC(OkCar)
free←free-1
if free≥ 1
signalC(OktoCar)
operation enterCamper
if free < 2
waitC(OkCamper)
free←free-2
operation exit(integer k)
free←free+k
if (free≥ 2) ∧ ¬ empty(OkCamper)
signalC(OkCamper)
else
if empty(OkCamper)
signalC(OkCar)
car camper
loop forever loop forever
p1: Parking.enterCar q1: Parking.enterCamper
p2: shop q2: shop
p3: Parking.exit(1) q3: Parking.exit(2)
Si vede che la SF per i camper è soddisfatta. Infatti, in una qualsiasi computazione, se un
camper è sospeso sulla coda (FIFO) OkCamper, l’unico modo per cui può rimanervi per sempre
è che non siano più eseguite primitive signalC(OkCamper) da un certo punto in poi. Ma questo è
impossibile, perché, dal codice, l’uscita di un camper o di due auto consecutive provoca sempre una
signalC(OkCamper), se questa è non vuota. Inoltre, l’ingresso di nuove auto è bloccato.
Viceversa, la SF per le auto non è garantita. Per esempio, in uno scenario con 22 camper e
un’auto, possiamo avere questa sequenza infinita di comandi, che genera una computazione nella
quale l’auto è starved (i comandi tra parentesi sono quelli che causano una sospensione)
enterCamper1, ..., enterCamper21, (enterCamper22), (enterCar1), exitCamper1, (en-
terCamper1), exitCamper2, (enterCamper2),..., exitCamper22, (enterCamper22), exit-
Camper1,...
5) (8pt) Programmare in Java una classe monitor per l’Esercizio 4. Precisare innanzitutto se
si intende utilizzare il meccanismo dei metodi synchronized oppure il package java.util.concurrent.
Discutere la SF.
Soluzione. Utilizzando il package java.util.concurrent, una soluzione possibile è la seguente. Si
4
noti l’uso della variabile contatore waitingCampers, per contare i camper in attesa di entrare (che
siano sospesi su OkCamper o fuori dal monitor in attesa di riacquisire lock).
1 import java . util . concurrent. locks . ReentrantLock;
2 import java . util . concurrent. locks . Condition;
3
4 class Parking {
5 int free = 42;
6
7 final ReentrantLock lock = new ReentrantLock();
8 final Condition OkCar = lock . newCondition();
9 final Condition OkCamper = lock. newCondition();
10 int waitingCampers = 0;
11
12 void enterCar(int Name) throws InterruptedException {
13 lock . lock ();
14 while ( free <1 || waitingCampers>0) {
15 System.out.println (”Car ” + Name + ” waiting...”);
16 OkCar.await();
17 }
18 free == ;
19 System.out.println (”Car ” + Name + ” entered parking. ”);
20 if (free >=1)
21 OkCar.signal ();
22 lock . unlock();
23 }
24
25 void enterCamper(int Name) throws InterruptedException {
26 lock . lock ();
27 waitingCampers++;
28 while (free <2) {
29 System.out.println (”Camper ” + Name + ” waiting...”);
30 OkCamper.await();
31 }
32 waitingCampers ==;
33 free = free = 2;
34 System.out.println (”Camper ” + Name + ” entered parking. ”);
35 lock . unlock();
36 }
37
38 void exit (int Name, int k) {
39 lock . lock ();
40 free =free+k;
41 if (k==1)
42 System.out.println (”Car ” + Name + ” exits parking. ”);
43 else
44 System.out.println (”Camper ” + Name + ” exits parking. ”);
45 if (free >=2 && waitingCampers>0)
46 OkCamper.signal();
47 else
48 if (waitingCampers==0) OkCar.signal();
49 lock . unlock();
50 }
51 }