Sei sulla pagina 1di 238

Università di Firenze - CdS Triennale in Informatica

Programmazione Concorrente A.A. 2022-2023 Lezione 1 - 27/9/2022

Una panoramica sulla programmazione


concorrente
Docente: Prof. Michele Boreale (Disia)

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.

• Modalità d’esame: prova scritta sugli argomenti del corso.

• Pagina Moodle del corso: vi si potranno reperire il materiale didattico sup-


plementare (es. note del docente, slides, articoli, testi d’esame degli degli anni
passati) e tutte le altre informazioni utili, come per esempio il programma det-
tagliato, messe via via a disposizione dal docente. Gli annunci sul corso (es.
eventuali spostamenti delle lezioni) verranno prevalentemente diramati trami-
te il forum news della pagina. È dunque altamente consigliata l’iscrizione al
portale, usando la chiave di registrazione da richiedere al docente.

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.

2 Multitasking, multiprocessori, multicomputer,


sistemi distribuiti
Vediamo alcuni scenari reali dove troviamo la concorrenza all’opera (si vedano le
figure 4 e 5). Il multitasking è una semplice generalizzazione del sistema I/O -
Elaborazione visto nell’esempio precedente. Come nel caso di un comune PC, si ha
a disposizione una sola CPU, sui cui vanno in esecuzione a turno i vari processi. Il
parallelismo, come già discusso, è in questo caso solo apparente. Il tempo della CPU

4
I/O

Elaborazione
6 6
start I/O end I/O
time →

Figura 2: I/O ed Elaborazione.

Figura 3: Processi di I/O ed Elaborazione.

Figura 4: Multitasking (a sinistra) e multiprocessore (a destra).

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

rete. Un insieme di client (es. browser), in esecuzione su PC di un insieme di utenti,


che accedono alla server farm di una grande organizzazione (es. Amazon) costitui-
scono un esempio di sistema distribuito. Anche in questo caso, la comunicazione
tra i processi è possibile solo attraverso lo scambio di messaggi. Rispetto al ca-
so multicomputer, tuttavia, possono sorgere qui ulteriori problematiche relative alla
performance e all’affidabilità: esse nascono dal fatto che, in dipendenza dallo stato
della rete, i messaggi possono avere un tempo di consegna (latenza) non trascurabile,
o addirittura venire persi. Tali aspetti complicano notevolmente la programmazione
dei sistemi distribuiti. In questo corso non ci occuperemo però di questi aspetti.

3 Sincronizzazione e nondeterminismo: la sfida


della programmazione concorrente
Se i processi di un programma concorrente fossero completamente indipendenti tra
loro, la programmzione concorrente sarebbe un compito relativamente semplice: il
tutto consisterebbe nell’implementare uno scheduler che distribuisca ai vari proces-
si le risorse necessarie alla loro esecuzione (tempo della CPU, pagine di memoria,
periferiche,...).
Nella realtà, anche nei programmi concorrenti più semplici, i processi devono
interagire, ovvero comunicare e sincronizzarsi tra loro. Questo è richiesto sia per
una corretta gestione delle risorse condivise (aree di memoria, file, etc.), sia per
cooperare allo svolgimento di compiti comuni. Ad esempio, quando un carattere viene
generato dalla tastiera, il processo I/O deve decidere se inviarlo ad un processo Word
Processor; il quale può a sua volta doverlo inviare al processo che gestisce la finestra
dove viene visualizzato un documento di testo. I prossimi due esempi illustrano
il tipo di problematiche che possono insorgere, in situazioni di sincronizzazione e
cooperazione.

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;

2. se non c’è disponibilità, stampa ’No disponibilità’ ed esci;

3. se prezzo ok per cliente, DB[d] ← cl(i) (assegna il volo al cliente i).


Chiediamoci cosa può succedere se i due client, C1 e C2 , eseguono questo codice
in maniera concorrente, con la medesima data d, e con un unico posto disponibile.
Se C1 viene eseguito completamente prima che inizi C2 , il posto verrà assegnato a
C1 ; analogamente, se C2 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 C1 senza apici, di C2 con apici):
1,1’,2,2’,3,3’.
Alla fine dell’esecuzione, ambedue i client credono di aver avuto il posto assegnato;
ma in effetti il posto risulta assegnato al solo C2 : questo non è certamente un risultato
corretto.
Esempio 3. Per fare un altro esempio, in un contesto di cooperazione, si supponga di
voler contare il numero di persone che entrano in uno stadio, da due ingressi separati.
Ogni ingresso ha un tornello, che scatta al passaggio di una persona. Ciascun tornello
è posto sotto il controllo di un processo, che incrementa una variabile contatore
globale c, secondo questo codice:
1. aspetta scatto tornello;

2. leggi c e copialo in una variabile locale xi ;

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;

2. esegui nuovamente il programma, con il medesimo input;

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:

correttezza = il programma dà risultato atteso in tutte le sequenze di


esecuzione possibili

Questa nozione di correttezza rende la programmazione concorrente estremamente


ardua. Sopratutto ci dice che non possiamo accontentarci di ragionamenti intuitivi
di correttezza, o di soli test sperimentali. Noi affronteremo queste problematiche su
due fronti, introducendo, sul modello interleaving

1. primitive linguistiche che permettono di programmare diverse forme di sincro-


nizzazione e controllare il nondeterminismo (e di limitare, quindi, le possibili
esecuzioni a sole quelle benigne);

2. tecniche di analisi e verifica che permetteranno in molti casi di condurre dimo-


strazioni rigorose di correttezza dei programmi sviluppati.

4 Un piano del corso


Ecco quali saranno i principali argomenti trattati nel corso.

• Introdurremo il modello interleaving: un modello semplice, ma rigoroso e


generale, della esecuzione dei programmi concorrenti.

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.

• Basandoci sulle primitive linguistiche ed il modello interleaving, faremo vede-


re degli algoritmi concorrenti che risolvono alcuni problemi di sincroniz-
zazione e comunicazione fondamentali, come la sezione critica, i produttori-
consumatori, i lettori-scrittori, i filosofi a cena.

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

• Vedremo come alcuni di questi concetti prendono corpo in un linguaggio di


programmazione concreto e largamente impiegato, Java.

Riferimenti bibliografici
[1] G. R. Andrews. Foundations of Multithreaded, Parallel, and Distributed
Programming, Addison-Wesley, 2000.

[2] M. Ben-Ari, Principles of Concurrent and Distributed Programming, 2/e,


Addison-Wesley, 2006.

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

1 Definizione del modello


Assumiamo di sapere, dalla programmazione sequenziale, quali siano i comandi ato-
mici, cioè quelle istruzioni che vengono eseguite dalla CPU in maniera non interrom-
pibile: assegnazioni di valori a variabili, valutazioni di espressioni booleane e salti
(c’è qualche limitazione sulla valutazione delle espressioni, su cui torneremo).

Definizione 1 (programma concorrente). Un processo è un programma sequenziale,


il cui codice è costituito da una sequenza di comandi atomici. Un programma con-
corrente è un insieme finito di processi, ciascuno caratterizzato da un identificatore,
P1 , ..., Pn .

Il comportamento di un programma concorrente viene visto nel modello interlea-


ving come l’esecuzione di una sequenza di comandi atomici, detta computazione: la
sequenza è ottenuta intercalando (interleaving) in modo arbitrario i comandi atomici
dei singoli processi. Per poter definire in maniera precisa la nozione di computazione,
dobbiamo prima introdurre qualche altro concetto. Il prossimo dovrebbe risultare
familiare dallo studio dell’architettura dei calcolatori.

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.

Dunque, dato un programma concorrente, esiste un control pointer per cia-


scun processo. Ad un dato istante dell’esecuzione, la situazione è simile a quella
rappresentata nella figura sotto:

1
p3, . . .
6cp p

p1, r1, p2, q1  q2, . . .


6cp q
I
@
@
@ r2, . . .
6cp r

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.

Esempio 1 (sequenze di esecuzione). Consideriamo un programma composto da due


processi, P = p1, p2 e Q = q1, q2, dove non specifichiamo cosa siano i vari comandi
atomici. Le possibili sequenze di esecuzione sono 6, ovvero le seguenti:
p1→q1→p2→q2,
p1→q1→q2→p2,
p1→p2→q1→q2,
q1→p1→q2→p2,
q1→p1→p2→q2,
q1→q2→p1→p2.
Un esempio di esecuzione non ammessa è: p1→q2→p2→q1 . Infatti, in Q l’esecu-
zione di q2 non può precedere quella di q1.

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

Notiamo quanto segue.


• Dopo l’intestazione con il nome del programma, segue una parte riservata alla
dichiarazione delle variabili globali, che vengono anche inizializzate.
• Ogni colonna descrive un processo (nell’esempio, due), che può dichiarare al
suo interno delle proprie variabili locali.
• Ogni riga che comincia con una etichetta come p1, q1 etc. corrisponde ad un
comando atomico.
Per completare la descrizione del modello, dobbiamo descrivere l’effetto dell’ese-
cuzione di una sequenza di comandi del programma sulla memoria del calcolatore.
Questo ci porta ad introdurre il concetto di stato.
Definizione 3 (stato). Lo stato di un programma concorrente è una tupla (record),
contenente un campo per ogni variabile globale o locale definita nel programma. Ogni
campo contiene un valore per la variabile corrispondente.
In altre parole, uno stato è una fotografia della memoria, ristretta alle variabili
del programma, in un certo istante. Si noti che anche i valori dei control pointer,
in quanto variabili locali, devono essere specificati nello stato. Per esempio, si con-
sideri il seguente programma, che essendo costituito da un solo processo, è in effetti
sequenziale.
Algorithm: Trivial sequential program
integer n ← 0
integer k1 ← 1
integer k2 ← 2
p1: n ← k1
p2: 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.

Quindi per esempio

s → s0

dove s0 = (cpP = p2, n = 1, k1 = 1, k2 = 2), è la transizione corrispondente all’ese-


cuzione del comando p1 a partire dallo stato s, nel programma di cui sopra. Infine,
una computazione è semplicemente una sequenza di transizioni.

Definizione 5 (computazione). Una computazione è una sequenza finita o infinita di


stati collegati da transizioni: s0 →s1 →s2 → · · · . Una computazione si dice completa
se è infinita, oppure se è finita e l’ultimo stato non ha transizioni possibili.

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:

1. si disegna un nodo corrispondente allo stato iniziale s0 ; esso è contrassegnato


da un arco entrante;

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à;

3. se al passo 2 non è possibile aggiungere nuovi nodi o nuovi archi, la costruzione


del grafo è terminata; altrimenti si ripete il passo 2.

Ad esempio, il diagramma stati-transizioni del Trivial sequential program visto


prima è il seguente.

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

Questo programma dà origine a questo diagramma stati transizioni

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.

• Multitasking. In questo caso è lo scheduler che si occupa di operare l’inter-


leaving, che quindi è reale. Ma tale interleaving è veramente arbitrario? In
realtà no, perché l’ampiezza degli intervalli temporali nel time-slicing può es-
sere dell’ordine di millisecondi: un tempo che per la CPU è lunghissimo, e che
le permette di eseguire moltissime istruzioni di un processo. Computazioni in
cui, per esempio, i comandi di due processi si alternano strettamente, in pra-
tica non hanno mai luogo. Potremmo raffinare il modello interleaving, magari
introducendo una nozione esplicita di tempo, in modo da bandire certe compu-
tazioni irrealistiche? In teoria sı̀, ma non sarebbe una buona idea. Il modello
risultante sarebbe più complicato. Inoltre esso sarebbe meno robusto: le con-
clusioni sulla correttezza del programma dipenderebbero dal fattore tempo (per
es., lunghezza degli intervalli di time-slicing, velocità della CPU rispetto alle
periferiche, etc.), ed un eventuale cambiamento nell’hardware o nell’algoritmo
di scheduling ci costringerebbe a ripensare la correttezza del programma.
Il modello interleaving, d’altra parte, ignora il tempo e ci permette di ragionare
in termini di semplici sequenze. Il fatto che noi consideriamo più computazioni
di quelle effettivamente possibili è una posizione conservativa, che non inficia
la correttezza delle conclusioni che possiamo trarre.

• Multiprocessori. Chiaramente l’assunzione interleaving non corrisponde alla


realtà: l’esecuzione dei processi ha luogo su varie CPU simultaneamente, e il
parallelismo è vero, non apparente come suggerirebbe il modello interleaving.

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.

• Multicomputer. Anche in questo caso l’esecuzione è veramente parallela, e


valgono considerazioni simili a quelle fatte nel caso precedente. Si noti che
ora non c’è contention sulla memoria, ma ci può essere sulla infrastruttura di
comunicazione tra le varie CPU, con conflitti che vengono risolti in maniera
sequenziale a livello hardware.

• Sistemi distribuiti. Valgono considerazioni simili al caso precedente. Tut-


tavia, se gli aspetti di affidabilità e performance dell’applicazione sono critici,
allora il puro modello interleaving risulta insufficiente per ragionare sulla cor-
rettezza del programma. Bisognerà arricchire il modello con ulteriori aspetti,
quali latenza e fallimenti dei nodi e della rete, e quindi stocasticità e probabilità.
Come già detto, noi non ci occuperemo di questi aspetti nel corso.

8
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 3 - 4/10/2022

Ancora sul modello interleaving.


Concorrenza in Java
Docente: Prof. Michele Boreale (Disia)

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. assegnamenti x←e, dove e è un’espressione del tipo appropriato per la variabile


x;

2. valutazioni di espressioni booleane e salti. A livello di linguaggio assembly, tali


comandi corrispondono alla valutazione di una condizione booleana b, seguita
eventualmente da salto. Nel nostro linguaggio ad alto livello, abbiamo i seguenti
comandi:

• if b: se la condizione booleana b è vera, si salta al ramo then specificato


nel codice del programma, altrimenti si va al comando atomico che segue
l’if nel codice;
• while b: se la condizione booleana b è vera, si salta al corpo del while
specificato nel codice (si rimane nel ciclo); altrimenti si salta al comando
atomico successivo al ciclo while (si esce dal ciclo);
• do K times, dove K è una costante intera: questo comando corrisponde al
familiare costrutto for i=1 to K, con la differenza sintattica che la variabile
di iterazione i viene qui mantenuta implicita. La condizione booleana che
viene controllata è quella per rimanere nel ciclo, ovvero che i≤ K;

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:

Algorithm: Assignment statements with one global reference


integer n ← 0
p q
integer temp integer temp
p1: temp ← n q1: temp ← n
p2: n ← temp + 1 q2: n ← temp + 1

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:

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 ?
p2: n←temp+1 q2: n←temp+1 0 0 0
(end) q2: n←temp+1 1 0 0
(end) (end) 1 0 0

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

2 Correttezza: Safety e Liveness


Abbiamo già accennato al fatto che il normale procedimento di debugging per i pro-
grammi sequenziali non è applicabile cosı̀ e com’è ai programmi concorrenti. La
ragione risiede essenzialmente nel nondeterminismo, a causa del quale risulta molto
difficile garantire che venga prodotta la stessa computazione in due esecuzioni di-
verse, anche partendo da condizioni iniziali identiche. Questo impedisce spesso di
comprendere se un dato bug è stato eliminato grazie ad una modifica apportata al
codice, oppure semplicemente non si è manifestato nell’ultima esecuzione osservata.
In effetti, abbiamo già detto che tutte le computazioni possibili, secondo il codice
dato, devono essere considerate quando si ragiona sulla correttezza di un programma
concorrente.

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.

Definizione 2 (Safety e Liveness). Sia P un predicato fissato sugli stati. Introdu-


ciamo due tipi di proprietà di correttezza, relative a P.

• Safety. In ogni computazione completa, la proprietà P deve essere sempre


(cioè in ogni stato) vera.

• Liveness. In ogni computazione completa, la proprietà P è prima o poi (cioè


in qualche stato) vera.

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:

• Safety: sempre, un puntatore del mouse è mostrato sullo schermo.

• Liveness: se premo il tasto destro del mouse, prima o poi un menù viene
mostrato sullo schermo.

Spesso, le proprietà di Safety interessanti prendono la seguente forma, dove C è un


certo evento ’cattivo’ di interesse

Safety = Non accade mai l’evento C.

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 il programma va in blocco.

• 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’

Liveness = Prima o poi accade l’evento B.

Ad esempio

• Se un processo del SO richiede l’accesso ad un’area protetta, prima o poi l’avrà.

• Prima o poi, tra quelli che lo richiedono, un processo scriverà nel DB.

• Un certo processo prima o poi terminerà.

• Se T > 1000 allora prima o poi T ≤ 1000.

• Dopo ogni rosso, prima o poi, il verde si accenderà.

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

while true do display-cursor

o, relativamente ad un qualsiasi evento cattivo C

while true do nothing

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.

Esercizio 1. Si consideri il programma Assignment statements with one global refe-


rence e si dica se esso rispetta le seguenti proprietà: (1) prima o poi il programma
termina; (2) vale sempre n < 2; (3) prima o poi n = 2. Per ciascuna proprietà, si
dica se essa è di Safety o 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

si legge, in sintassi Java-like, come

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.

Esempio 2. Consideriamo questo programma concorrente.

7
Algorithm: Stop the loop A

boolean flag ← false


p q
integer n ← 0
p1: while flag = false q1: flag ← true
p2: n←1−n q2:
Domandiamoci se il programma concorrente si ferma necessariamente. Ovvero, chie-
diamoci se esso rispetta la proprietà di Liveness: prima o poi, tutti i control pointer
valgono nil. Ora, secondo la semantica fin qui considerata, questa proprietà è falsa,
perché la sequenza infinita di comandi
p1, p2, p1, p2, · · ·
dà origine ad una computazione infinita, che non porta mai a uno stato finale.
Tuttavia, non è questo il significato intuitivo che noi vorremmo dare al program-
ma: vediamo che il comando q1 è continuamente pronto per essere eseguito (abilita-
to), ma non è mai eseguito. In alcune circostanze, questo è irragionevole: ad esempio
in un sistema multitasking, qualsiasi politica di scheduling prima o poi darebbe a Q
una chance di essere eseguito, cosa che porterebbe il programma a terminare.
Definiamo le sequenze come quella vista nell’esempio precedente come unfair :
dall’Inglese, non eque. La non equità consiste nel fatto di non dare mai ad un certo
processo (Q nell’esempio) la possibilità di essere eseguito, nonostante esso sia pronto
ad esserlo (abilitato). Più formalmente, abbiamo
Definizione 3 (fairness). Una computazione unfair è una computazione infinita in
cui, da un certo punto in poi, il comando atomico di un certo processo è sempre
abilitato, ma mai eseguito.
Una computazione fair è una computazione non unfair.
Lavorare sotto assunzione di fairness vuol dire considerare solo le computazioni
fair, quando si verifica la correttezza di una proprietà.
Lavorare sotto assunzione di fairness vuol dire dunque ignorare le computazioni
unfair. Da questo punto di vista, l’assunzione costituisce un’eccezione alla regola
dell’interleaving (considerare tutte le computazioni possibili, dato il codice). Il pro-
gramma precedente, per esempio, termina correttamente sotto assunzione di fairness.
D’ora in avanti, salvo diversa indicazione, noi lavoreremo sempre sotto assunzione di
fairness.
Esercizio 2. Disegnare il diagramma stati-transizioni completo del programma 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.

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

Una versione Java di questo programma è descritta nella Figura 1. Osserviamo


quanto segue.

1. Un processo in Java è un oggetto di classe Thread. Nell’esempio, la classe


Count può essere usata per creare oggetti di tipo Thread, perché estende la
classe Thread (linea 1).

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

4. Nel metodo main() della classe è possibile dichiarare e istanziare thread di


classe Count, usando il costruttore di classe (linee 13-14). Si noti tuttavia che
istanziare un thread non lo rende ipso facto eseguibile.

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

6. Se vogliamo essere certi della terminazione di p, dobbiamo invocare il suo meto-


do join() (linea 18): esso mette in attesa il thread che lo invoca (in questo caso
main()), fino al momento in cui p è terminato. Discorso simile per q (linea 19).
Si noti che metodo join() va sempre invocato all’interno di un blocco try-catch,
perché la sua esecuzione può, in generale, sollevare un’eccezione.

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 }

Figura 1: Listing della classe Count.java.

11
/* Copyright (C) 2006 M. Ben-Ari. See copyright.txt */
class Count extends Thread {
static volatile int n = 0;

public void run() {


int temp;
for (int i = 0; i < 10; i++) {
temp = n;
n = temp + 1;
}
}

public static void main(String[] args) {


Count p = new Count();
Count q = new Count();
p.start();
q.start();
try { p.join(); q.join(); }
catch (InterruptedException e) { }
System.out.println("The value of n is " + n);
}
}
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 4 - 6/10/2022

Il problema della Sezione Critica


Docente: Prof. Michele Boreale (Disia)

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

Occorre dunque un meccanismo di sincronizzazione tra persone, cioè regole comuni


per aprire e chiudere la porta, e lasciar passare chi è in attesa. Questa discussione
conduce a proporre il seguente schema di soluzione.
Algorithm: Critical section problem
global variables
p q
local variables local variables
loop forever loop forever
NCS NCS
preprotocol preprotocol
CS CS
postprotocol postprotocol
Qui NCS=non-critical section e CS=critical section. NCS rappresenta qualsiasi com-
putazione interna del processo, che non riguarda l’accesso alla CS. Nell’esempio pre-
cedente, NCS potrebbe essere l’attesa del cliente (passo 1). CS è invece l’accesso
alla risorsa condivisa, DB nell’esempio (passi 2 e 3). Pre-protocol è la richiesta di
accesso alla CS; essa dovrà ritardare il processo che la esegue, se la CS è occupata.
Post-protocol è la fase successiva alla CS, con la quale si passa il diritto di accedere
alla CS a qualche altro processo in attesa, se c’è. Assumeremo qui che

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.

L’assunzione 1 è facile da garantire. L’assunzione 2 è ragionevole, perché le CS sono


tipicamente delle brevi sezioni di codice, studiate per essere eseguite in maniera ve-
loce, in modo da non rallentare troppo altri processi in attesa. D’altra parte, si noti
che non richiediamo che NCS debba progredire. In effetti, la NCS eseguita da un
processo potrebbe non terminare mai, per vari motivi: il nodo su cui si trova in ese-
cuzione il processo potrebbe fallire; oppure potrebbe semplicemente non presentarsi
per lungo tempo la necessità per il processo in questione di accedere alla CS (si pensi
al caso in cui all’agenzia non si presentano clienti). In tutti questi casi, è ragionevole
richiedere che il programma concorrente nel suo complesso continui a funzionare cor-
rettamente per i rimanenti processi. Per il resto, non faremo particolari assunzioni
su quello che precisamente fanno NCS e CS.
Quello che faremo ora sarà di raffinare lo schema proposto sopra per arrivare ad
una soluzione che dovrà rispettare i seguenti requisiti di correttezza.

• Mutua Esclusione (ME). Non capita mai che due processi siano contempo-
raneamente all’interno della CS.

• Assenza di Deadlock (Deadlock Freedom, DF). Non capita mai si arrivi


ad una situazione in cui per uno o più processi risulti impossibile uscire dal
pre- o post-protocol.

• Assenza di Starvation (Starvation Freedom, SF). Se un certo processo


vuole entrare in CS (arriva al pre-protocol), prima o poi quel processo entrerà
in 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.

Algorithm: First attempt


integer turn ← 1
p q
loop forever loop forever
p1: non-critical section q1: non-critical section
p2: await turn = 1 q2: await turn = 2
p3: critical section q3: critical section
p4: turn ← 2 q4: turn ← 1

Ci sono vari metodi per dimostrare o confutare la correttezza di un programma


concorrente. Introduciamo ora quello basato sulla costruzione e l’esplorazione del
diagramma stati-transizioni del processo. Osserviamo prima di tutto che uno stato
di questo programma è una tripla (cP , cQ , turn), dove cP e cQ possono assumere
uno tra 4 valori possibili (è escluso il valore nil, visto che i processi non possono
terminare), e turn uno di 2 valori possibili. Dunque, nel diagramma ci potranno
essere al massimo 4 × 4 × 2 = 32 stati diversi. In realtà, non è affatto detto che
questi stati si manifestino tutti. Anzi, noi speriamo, visto che richiediamo la ME,
che non si presentino gli stati dove questa è violata, ovvero (p3, q3, 1) e (p3, q3, 2). In
effetti, costruendo il diagramma in maniera esplicità, si vede che tali stati non sono
presenti, dunque la ME è soddisfatta. Il diagramma completo è mostrato di seguito.

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
       
  

La costruzione manuale di questo diagramma richiede naturalmente una notevole


fatica. Esiste un modo più compatto per rappresentare la stessa informazione e ragio-
nare più agevolmente sulla ME? La risposta è sı̀. Infatti, ai fini dello studio della
ME , si può ridurre il n. di stati da analizzare studiando un algoritmo abbreviato. Per
il nostro ragionamento, infatti, non ha alcuna importanza cosa siano concretamente
NCS e CS: possiamo allora sostituire le relative linee di codice con dei commenti.
A quel punto, possiamo anche considerare NCS e CS come assorbite dai comandi
atomici successivi, cioè le linee 2 e 4 nel codice dei processi. Dopo un’opportuna
rinumerazione delle linee, arriviamo dunque al seguente algoritmo abbreviato.
Algorithm: First attempt (abbreviated)
integer turn ← 1
p q
loop forever loop forever
p1: await turn = 1 q1: await turn = 2
p2: turn ← 2 q2: turn ← 1

In questa versione abbreviata, P è in CS se si trova in p2 (ovvero: si accinge ad uscire


dalla CS, ma non l’ha ancora fatto), Q è in CS se si trova in q2. La violazione di
ME corrisponde quindi alla presenza di stati (p2, q2, j), per j = 1, 2, nel diagramma.
Costruire esplicitamente il diagramma della versione abbreviata è molto più agevole,
e permette di rendersi conto che la violazione della ME in effetti non ha luogo.

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,  $

& turn = 2 % & turn = 1 %

& % & %

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    

È anche possibile costruire lo scenario in forma tabellare


Process p Process q wantp wantq
p1: non-critical section q1: non-critical section false false
p2: wantp←true q1: non-critical section false false
p2: wantp←true q2: wantq←true false false
p3: await wantq=false q2: wantq←true true false
p3: await wantq=false q3: await wantp=false true true
Si noti che la natura del deadlock prevede il raggiungimento di una situazione sen-
za possibilità di uscita, in questo caso rappresentata dallo stato (p3, q3, true, true).
Questa forma di deadlock, dove i processi coinvolti continuano ad eseguire comandi
atomici (del pre-protocol, in questo caso) senza possibilità di avanzare, è detta li-
velock. Essa è diversa dalla starvation, dove, per una particolare computazione, un
processo che vorrebbe entrare viene sempre escluso dalla CS.

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

' $ ' $ '? $


p5: wantp←true,  p5: wantp←true,  p4: wantp←false,
q3: while wantp, q5: wantq←true, q5: wantq←true,
& false,true % & false,false % & true,false %
Si noti che questo ciclo non è un deadlock/livelock: nel diagramma completo si
vede che è sempre possibile uscire dal ciclo, seguendo transizioni (non rappresentate
nella figura sopra) che portano fuori, in particolare in CS. La computazione che viola
la SF è dunque dovuta ad un interleaving sfortunato (o maligno), non all’impossibilità
di uscire dal ciclo. Basta una deviazione dall’esecuzione perfettamente alternata per
uscire dal ciclo. Per esempio, partendo dallo stesso stato (p3, q3, true, true) di cui
sopra, ed eseguendo Q due volte di seguito e poi P , si ottiene che P entra in CS. Il
frammento di diagramma corrispondente è
' $ ' $ ' $
p3: while wantq, - p3: while wantq, p3: while wantq,
q3: while wantp, q4: wantq←false, - q5: wantq←true,
&true, true %&true, true %&true, false %
r
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.

Ripeti per sempre


NCS
Entra in AC
Ripeti finché non sei solo in AC:
Se non è tuo turno di stare in AC:
Esci da AC
Aspetta tuo turno di stare in AC
Rientra in AC
CS
Cedi diritto di stare in AC
Esci da AC e CS

Informalmente, notiamo quanto segue, rispetto ad un processo P che voglia


entrare in CS.

1. Se il P si trova da solo in AC, può entrare in SC, sia che abbia diritto o meno
di restare in AC.

2. Se Q si trova in AC e P ha il diritto di restare in AC, P entrerà in CS nel


momento in cui si ritroverà solo in AC: questo prima o poi avverrà sicuramente,

11
perché Q, che non ha diritto, dovrà prima o poi uscire e aspettare il proprio
turno fuori da AC.

3. Se Q si trova in AC e P non ha ancora il diritto di restare in AC, P dovrà


prima poi uscire e aspettare il proprio turno fuori da AC, dando cosı̀ modo a
Q di entrare in CS e prima o poi di uscire; a quel punto P si vedrà passato il
diritto.

Opportunamente codificato, in termini di operazioni sulle variabili turn, wantp e


wantq, questo schema diventa l’algoritmo seguente. Esso viene considerato come la
prima soluzione corretta al problema della Sezione Critica che sia stata proposta.
Viene attribuito da Dijkstra al matematico olandese Theodorus Dekker, [1].

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
L’algoritmo soddisfa ME, DF e SF. Questo si può provare costruendo esplicitamente
il diagramma degli stati e analizzandolo. Tuttavia, noi daremo una prova deduttiva
(logica) di correttezza, che rimandiamo però al momento in cui avremo introdotto le
tecniche di verifica logica.

Esercizio 1. Programmare in Java l’algoritmo Quarto Tentativo.

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

Comandi atomici complessi.


Blocchi synchronized in Java.
Docente: Prof. Michele Boreale (Disia)

1 Comandi atomici complessi


Abbiamo visto come risolvere il problema della sezione critica usando solo comandi
atomici semplici, che rispettano la restrizione LCR, presenti notevoli difficoltà. Se
rilasciamo tale assunzione, e ammettiamo che il nostro hardware sia in grado di
eseguire atomicamente una load e una store, il problema diventa più semplice da
risolvere. I comandi atomici che ammettono più di un accesso alla memoria globale
verranno detti comandi atomici complessi.
Vediamo due esempi di uso dei comandi atomici complessi. Premettiamo che i
programmi che vedremo hanno uno scopo più che altro illustrativo delle potenzialità
di tali comandi. Le soluzioni ottenute, tuttavia, non soddisferanno la SF.
Ecco un primo esempio. Un’istruzione che si trova in alcuni linguaggi assembly
è quella di test-and-set, che può essere cosı̀ definita. Di seguito, common e local
rappresentano, rispettivamente, una variabile globale e una locale di un programma.
test −and−set(common, local) = h local ← common
common ← 1 i
Dunque, copio il valore della variable globale common in local, e atomicamente pongo
common a 1. Quando usiamo le parentesi angolate < ... >, vogliamo sottolineare che
le operazioni tra esse contenute sono eseguite atomicamente. Vediamo una soluzione
al problema della sezione critica che usa test-and-set. Il significato della variabile
common qui è:

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.

Algorithm: Critical section problem with test-and-set


integer common ← 0
p q
integer local1 integer local2
loop forever loop forever
p1: non-critical section q1: non-critical section
repeat repeat
p2: test-and-set(common, local1) q2: test-and-set(common, local2)
p3: until local1 = 0 q3: until local2 = 0
p4: critical section q4: critical section
p5: common ← 0 q5: common ← 0

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.

Esercizio 1. Descrivere esplicitamente uno scenario (in forma tabellare) in cui SF


viene violata per il programma precedente.

Un altro comando atomico complesso piuttosto comune è quello di exchange, che


semplicemente scambia tra loro i valori di due variabili in maniera atomica.
exchange(common, local) = h integer temp
temp ← common
common ← local
local ← temp i
Una soluzione basata su exchange è la seguente. In questo caso, la convenzione sui
valori di common è diversa:

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)

Anche questa soluzione rispetta ME e DF, ma non SF.


Queste soluzioni, e altre simili, che si basano su loop di attesa attiva, sui sistemi
uniprocessore (multitasking) possono comportare un serio problema di contention, e
quindi di inefficienza. Il motivo, come già discusso, è che parte del tempo della CPU
viene impiegato nell’esecuzione di cicli di attesa, che non contribuiscono a far progre-
dire il programma e sottraggono tempo prezioso all’esecuzione di istruzioni utili (dei
processi non in attesa). Per ovviare a questo problema, introdurremo in una succes-
siva lezione soluzioni basate sull’attesa passiva. Ciò richiederà però l’introduzione di
un’altra primitiva linguistica, quella dei semafori.

2 Blocchi synchronized e Mutua Esclusione in Ja-


va
Java fornisce un meccanismo built-in per forzare l’atomicità di un blocco di comandi:
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
}

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 }

Figura 1: Listing della classe CountCS.java.

6
class CountCS extends Thread {
static volatile int n = 0;
static volatile Object lock = new Object();

int temp;
int id; // campo identita' del thread

CountCS(int myid){ // costruttore, inizializza il campo id


this.id=myid;
}

public void run() {


for (int i = 0; i < 100000; i++) {
//System.out.println("Process " + id + " in NCS"); // NCS
synchronized(lock){ // CS = blocco synchronized
temp = n;
n = temp + 1;
}
}
}

public static void main(String[] args) {


CountCS p = new CountCS(1);
CountCS q = new CountCS(2);
p.start();
q.start();
try {
p.join();
q.join();
}
catch (InterruptedException e) { }
System.out.println("The value of n is " + n);
}
}
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 6 - 13/10/2022

Esercizi sul modello interleaving.


Docente: Prof. Michele Boreale (Disia)

1 Esercizi sul modello interleaving


Esercizio 1. Si consideri il seguente algoritmo concorrente, dove c1, c’1,... denotano
generici comandi atomici, che non comportano salti.
Algorithm: Generico programma concorrente

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.

(a) Una computazione completa è originata da una sequenza di 8 comandi. Una


volta determinate le posizioni dei comandi di P in questa sequenza, la sequenza
stessa è univocamente determinata. Si consideri il numero di possibili modi di
scegliere 4 posizioni da un insieme di 8. Esso risulta pari al n. di sottoinsiemi
di 4 elementi in un insieme di 8 elementi, dunque pari al coefficiente binomiale
 
8 8!
= = 70.
4 4!(8 − 4)!

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

Abbiamo f (10, 10) ≈ 2.357 × 1092 . Come termine di confronto, il numero


complessivo di atomi nell’universo è stimato in approssimativamente 1080 [1].

Esercizio 2. Costruire il diagramma stati-transizioni per il seguente programma


concorrente. Analizzando il diagramma, dimostrare o confutare la seguente proprietà:
il valore finale di n è sempre 0.
Algorithm: Stop the loop A
integer n ← 0
boolean flag ← false
p q
p1: while flag = false q1: flag ← true
p2: n←1−n q2:

Soluzione. Il diagramma è il seguente. Da esso si evince che ci sono due possibili


stati finali, con diversi valori di n. Dunque la proprietà richiesta non è rispettata.

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.

Esercizio 4. Riconsideriamo il problema precedente, con la richiesta ulteriore che le


locazioni di indice negativo e positivo di f vengano controllate in maniera alternata.
Questo può servire, per esempio se è noto che gli zero sono vicini all’origine, a evitare
di scandagliare troppo a lungo una direzione trascurando l’altra. Nei due algoritmi
proposti di seguito faremo uso della seguente forma estesa del comando await:
await b c
dove b è una espressione booleana e c un comando atomico. La semantica è la
seguente: se b è falso ricomincia, altrimenti esegui atomicamente c. Ovvero, in una
linea di programma pj, questo comando atomico è equivalente a
pj : h if (not b) goto pj else c i
Naturalmente ci stiamo ponendo in una situazione in cui l’hardware sottostante
supporta questo tipo di comandi atomici.

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.

Esercizio 5. Considerare il seguente algoritmo concorrente.

Algorithm: Algoritmo concorrente B


integer n ← 0
p q
p1: while n < 2 q1: n←n+1
p2: write(n) q2: n←n+1

Rispondere alle seguenti domande.

(a) Costruire tre scenari che diano origine alle seguenti sequenze di output: 012,
002, 02.

(b) Ci sono computazioni complete dove il valore 2 non appare nell’output?

(c) Quante volte il valore 2 può apparire nell’output?

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

genera la sequenza di output composta dal solo 0.


Per quanto riguarda (c), è chiaro che 2 può comparire al più una volta in una
sequenza: a partire dallo stato della computazione in cui n diventa 2, p2 potrà essere
eseguito al più una volta.
Per quanto riguarda (d), nel caso di computazioni fair, è chiaro che 1 può apparire
un numero arbitrario (ma finito) di volte, semplicemente eseguendo P un numero
arbitrario (ma finito) di volte dopo q1 e prima di eseguire q2. Nelle computazioni
non fair, 1 può comparire anche un numero infinito di volte.

Esercizio 6. Considerare il seguente algoritmo concorrente.

Algorithm: Stop the loop B


integer n ← 0
boolean flag ← false
p q
p1: while flag = false q1: while flag = false
p2: n←1−n q2: if n = 0
p3: q3: flag ← true

Rispondere alle seguenti domande.

(a) Costruire uno scenario in cui il programma termina.

(b) Quali sono i possibili valori finali di n quando il programma termina?

(c) Il programma termina in tutti gli scenari fair?

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

Esercizio 7. Si programmi in Java il quarto tentativo di soluzione al problema della


sezione critica, sotto riportato. Si assuma che, nel caso di P

NCS = stampa “NCS: P”;


CS = stampa “Begin CS: P”; stampa “End CS: P”

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

Soluzione. Il codice Java è riportato in Figura 1. Si noti che:

ˆ 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);

ˆ per la classe FourthAttempt, che estende Thread, definiamo un costruttore di due


argomenti: questi corrispondono, rispettivamente, al nome ed all’identificatore
numerico da assegnare al thread, al momento della creazione.

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 }

Figura 1: Listing della classe FourthAttemp.java.

10
class FourthAttempt extends Thread {

private static volatile boolean[] want = {false, false};


private String myname;
private int id;

public FourthAttempt(String x, int j){


myname=x;
id=j;
}

public void run() {


for (int i = 0; i < 10; i++) {
System.out.println("NCS: " + myname); /* NCS */
want[id]=true;
while (want[1-id]) {
want[id]=false;
want[id]=true;
}
System.out.println("Begin CS: " + myname); /* CS */
System.out.println("End CS: " + myname); /* CS */
want[id]=false;
}
}

public static void main(String[] args) {


FourthAttempt p = new FourthAttempt("p",0);
FourthAttempt q = new FourthAttempt("q",1);
p.start();
q.start();
try { p.join(); q.join(); }
catch (InterruptedException e) { }
System.out.println("End of program");
}
}
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 7 - 20/10/2022

Esercizi sulla sezione critica


e sui comandi atomici complessi.
Docente: Prof. Michele Boreale (Disia)

1 Esercizi sulla sezione critica


Esercizio 1. Disegnare il diagramma degli stati per la versione abbreviata dell’algo-
ritmo Secondo Tentativo, sotto riportata. Analizzando il diagramma, dire se valgono
la Mutua Esclusione, l’Assenza di Deadlock e la Starvation Freedom.

Algorithm: Secondo tentativo (abbreviato)


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

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

p3, q2, t, f p2, q3, f, t


Q P

Q P
p3, q3, t, t

Esercizio 2. Disegnare il diagramma degli stati per la versione abbreviata dell’al-


goritmo Terzo Tentativo, sotto riportata. Analizzando il diagramma, dire se valgono
la Mutua Esclusione e l’Assenza di Deadlock.

Algorithm: Terzo tentativo (abbreviato)


boolean wantp ← false, wantq ← false
p q
loop forever loop forever
p1: wantp ← true q1: wantq ← true
p2: await wantq = false q2: await wantp = false
p3: wantp ← false q3: wantq ← false

Soluzione. Simile al precedente. Dal diagramma, si evince che la ME vale, mentre


la DF no, per la presenza di uno stato senza possibilità di uscita (verso CS). A
fortiori, dunque, non vale la SF, che è una proprietà più forte della DF.

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.

2 Esercizi sui comandi atomici complessi


Esercizio 5. Si chiede di dare una soluzione che usi il comando atomico fetch-&-add,
definito da
fetch-&-add(common,local,x) =
local ← common
common ← common+x

Si può ignorare la proprietà di SF.


Soluzione. Una possibile soluzione prevede di impiegare lo stesso schema visto
per il caso della test-and-set. Anche qui avremo dunque un ciclo di attesa attiva,
che testa la variable common, fino a quando non si trova che common=0 (CS libera).
Questo si può ottenere usando fetch-and-add con x=1 all’interno del ciclo di attesa.
L’algoritmo risultante è il seguente.

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.

Soluzione. Una soluzione è presentata in Figura 1. Si noti che ogni accesso


alla variabile condivisa common è all’interno di un blocco synchronized guardato dal
medesimo lock, incluso l’accesso nel post-protocol.
Esercizio 7. Si consideri una versione del programma in cui l’assegnamento common
= 0 non è incluso in un blocco synchronized. Spiegare come questo potrebbe portare
ad una situazione di deadlock (NB: l’esecuzione di common = 0 da parte di un thread
potrebbe risultare intercalata tra l’esecuzione delle linee 22 e 23 da parte di un altro
thread...).

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

static volatile Object lock = new Object();


// ogni accesso a common va dentro blocco
// synchronized guardato da lock

int local;
int id; // campo identit� del thred

CSFetchAndAdd(int myid){ //costruttore, inizializza i campi del thread


this.local=0;
this.id=myid;
}

public void run() {


for (int i = 0; i < 10; i++) {
System.out.println("Process " + id + " in NCS"); // NCS
do { // pre-protocol
synchronized(lock){ // Fetch&Add = blocco synchronized:
local = common; // esecuzione atomica degli assegnamenti
common = common + 1;
}
} while (local !=0);
System.out.println("Process " + id + " starts CS"); // CS
System.out.println("Process " + id + " ends CS");
synchronized(lock){ // post-protocol
common = 0; // accesso a common guardato da lock
}
}
}

public static void main(String[] args) {


CSFetchAndAdd p = new CSFetchAndAdd(1);
CSFetchAndAdd q = new CSFetchAndAdd(2);
p.start();
q.start();
try {
p.join();
q.join();
}
catch (InterruptedException e) { }
System.out.println("Program terminated.");
}
}
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 8 - 25/10/2022

Introduzione alla verifica logica


Docente: Prof. Michele Boreale (Disia)

1 Specifica logica di proprietà di correttezza


Abbiamo detto che i programmi concorrenti possono contenere errori subdoli, per i
quali il processo di debugging basato su test, che applichiamo al caso sequenziale,
non può produrre garanzie di correttezza (nondeterminismo). Questa è la ragione per
la quale, oltre che dei test, dobbiamo anche servirci di metodi di specifica e verifica
formali, che possono dare garanzie di correttezza.
Il formalismo di specifica da cui partiremo è una semplice logica proposizionale.
Dato un programma concorrente, le formule di questa logica sono formate usando
ˆ come proposizioni atomiche, asserzioni circa il valore delle variabili dei processi
(es: turn = 2, o wantp = true, o a ≥ 10,...), inclusi i control pointer (es.
cP = p3).

ˆ come connettivi logici i familiari operatori booleani: ¬, ∧, ∨.


Per esempio, consideriamo 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
Possiamo considerare la formula logica
def 
A = (cP = p1) ∧ (cQ = q1) ∧ ¬wantp ∧ ¬wantq .

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.

Gli invarianti sono dunque particolari proprietà di Safety. Non possiamo


esprimere invece proprietà interessanti di Liveness come invarianti .

2 Prove induttive di invarianti


Gli invarianti possono essere dimostrati formalmente usando una tecnica di prova
chiamata principio di induzione computazionale. Essa può essere cosı̀ formulata. Sia
A una formula logica proposizionale. Si consideri questo procedimento in due fasi.

(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 di induzione computazionale afferma che:

Se (a) e (b) possono essere dimostrati, allora la proprietà A vale in tutti


gli stati raggiungibili del programma.

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.

Esempio 2. Consideriamo l’algoritmo di Dekker, sotto riportato. Provare che la


formula
def
A = (turn = 1) ∨ (turn = 2)
è un invariante del programma.

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.

– I comandi atomici diversi da p9, q9 lasciano il valore di turn inalterato.


Dunque, essendo per ipotesi induttiva A vera in s, essa continua ad essere
vera anche nel nuovo stato s′ .
– p9: rende vera turn = 2 in s′ , dunque A è vera in s′ . Analogamente si
procede per quanto riguarda l’esecuzione di q9.

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:

1. i comandi la cui esecuzione può modificare in vera la premessa, cP = p3;

2. i comandi la cui esecuzione può modificare in falsa la conclusione, wantp =


true.

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

Le considerazioni dell’esempio precedente hanno validità generale.

Quando la formula logica che vogliamo provare essere un invariante è una


implicazione, A ⇒ B, nel passo induttivo vanno esplicitamente analizzati
solo

5
ˆ i comandi che possono modificare in vera la premessa, A;
ˆ i comandi che possono modificare in falsa la conseguenza, B.

In questi due casi, la dimostrazione deve verificare che:

ˆ se la premessa A diventa effettivamente vera nello stato s′ , allora anche la


conseguenza B lo è (primo caso);

ˆ se la conseguenza B diventa effettivamente falsa nello stato s′ , allora anche la


premessa A lo è (secondo caso).

Infatti, in tutti gli altri casi, l’esecuzione del comando non può rendere falsa la formula
A ⇒ B in uno stato successivo.

3 Esempio: prova induttiva di ME in Terzo Tentativo


Sappiamo che il Terzo Tentativo non è una soluzione accettabile al problema della
Sezione Critica, perché esso non soddisfa la DF. È tuttavia istruttivo far vedere come
dimostrare la ME per questo semplice algoritmo. Procediamo allora alla prova della
ME del Terzo Tentativo. La prova si serve di un risultato intermedio, o lemma, facile
da dimostrare per induzione computazionale. Nel seguito useremo questo tipo di
abbreviazione

la formula p1 ∨ p2 ∨ · · · ∨ pn viene abbreviata come p1..n.


def
Lemma 1. G = (p3..5 ⇒ wantp) è un invariante per Terzo Tentativo.

Prova Procediamo per induzione computazionale. Il caso base è banale, visto che
la premessa dell’implicazione è falsa. Vediamo il passo induttivo.

ˆ Il comando che può modificare in vera la premessa è p2: ma la sua esecuzione


rende vera anche la conseguenza nello stato d’arrivo s′ .

ˆ Il comando che può modificare in falsa la conseguenza è p5: la sua esecuzione


rende tuttavia falsa anche la premessa nello stato d’arrivo s′ .

Vale anche il viceversa di G, anche se non lo useremo nella prova di ME.

6
def
Lemma 2. H = (wantp ⇒ p3..5) è un invariante per Terzo Tentativo.

Prova Simile al precedente lemma. Procediamo per induzione computazionale. Il


caso base è banale, visto che la premessa dell’implicazione è falsa. Vediamo il passo
induttivo.

ˆ Il comando che può modificare in vera la premessa è p2: la sua esecuzione rende
tuttavia vera anche la conseguenza.

ˆ Il comando che può modificare in falsa la conseguenza è p5: la sua esecuzione


rende tuttavia falsa anche la premessa.

Naturalmente, lemmi analoghi ai due precedenti valgono anche nel caso di Q.


def
Teorema 3 (ME per Terzo Tentativo). I = ¬(p4 ∧ q4) è un invariante per Terzo
Tentativo.

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

Introduzione alla Linear Temporal Logic (LTL)


Docente: Prof. Michele Boreale (Disia)

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

è vera in certi stati ma non in altri.


Le logiche temporali permettono di descrivere formalmente tali cambiamenti del
valore di verità, e ragionare in maniera rigorosa su di essi. Si tratta di estensioni
della logica proposizionale con certi operatori temporali.
Noi concentreremo il nostro studio su una particolare logica, nota come Linear
Temporal Logic, o LTL. Questa logica è ampiamente usata nel campo della verifica
automatica di programmi concorrenti, anche in ambito industriale. Gli operatori
temporali di LTL sono quattro:

• always (sempre) 2

• eventually (prima o poi) ♦

• until (finché) U

• next (prossimo stato) .

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

Ovvero: sempre, P e Q non sono contemporaneamente in CS.

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.

La formula ♦A, da leggere prima o poi A, è vera se A è vera in almeno


uno stato della computazione.

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)

e analogamente per SF (Q). Dunque


def
SF (3) = SF (P ) ∧ SF (Q)

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

2 (p4 ⇒ (¬q4 U p5)) .

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.

2 ((x = 1) ⇒ ♦(x = 2))


2 ((x = 1) ⇒ (x = 2))

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.

2.1 Prima o poi, definitivamente


La formula
♦2A
(da leggere ♦(2A)) vuol dire, alla lettera

prima o poi, si arriva in uno stato da cui vale 2A.

Ovvero, espandendo 2A

prima o poi, si arriva in uno stato da cui vale sempre A.

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 →

Ad esempio, è possibile caratterizzare le computazioni con un Deadlock, per il Terzo


Tentativo, in questo modo
♦2(p3 ∧ q3) .

2.2 Infinitamente spesso


La formula
2♦A
vuol dire, alla lettera

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.

Ovvero, A è vero infinite volte nella computazione (se la computazione è infinita).


Graficamente, l’andamento di A è qualcosa del tipo
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.

2.3 Dualità e altre leggi logiche


Nel caso della logica proposizionale, esiste una dualità tra gli operatori and a or,
mediata dalla negazione (la Legge di De Morgan). Esiste una dualità del genere
anche tra always ed eventually in LTL. Indicando con ⇔ l’equivalenza logica tra
formule, è facile convincersi che

¬2A ⇔ ♦¬A
¬♦A ⇔ 2¬A .

Consideriamo la prima delle due leggi.

¬2A ⇔ non è vero che sempre A

ma questo si può scrivere anche

7
¬2A ⇔ prima o poi ¬A ⇔ ♦¬A.

La seconda equivalenza si argomenta in maniera simile

¬♦A ⇔ non è vero che prima o poi A ⇔ sempre ¬A ⇔ 2¬A.

Esistono altre leggi interessanti, per esempio, le seguenti sono abbastanza ovvie ( ⇒
indica qui l’implicazione logica tra formule)

• ♦2A ⇒ 2♦A ((prima o poi definitivamente vero) implica (infinitamente


spesso vero));

• 22A ⇔ 2A (idempotenza di 2)

• ♦♦A ⇔ ♦A (idempotenza di ♦);

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

Esempio 1. Un semaforo per la regolazione del traffico ad un incrocio è controllato


da un semplice programma concorrente, che alterna ciclicamente il valore della varia-
bile colore del semaforo tra i valori 0 (verde=V ), 1 (giallo=G), 2 (rosso=R). In ogni
stato, sono previsti dei cicli di attesa, in numero non definito a priori. Il diagramma
degli stati risultante è il seguente.

• V G R

Un programma che genera questo diagramma è, per esempio, il seguente.

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

• Sempre, immediatamente dopo V non ci sarà R:


2 (V ⇒ ¬R) .

• Infinitamente spesso V si ripete:


2♦V .

• Sempre, se sono su R prima o poi arriverà V , e nel frattempo non ci sarà G:


2 (R ⇒ (¬G) U V ) .

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 U B se e solo se ∃i ≥ 0: σi  B e ∀j, con 0 ≤ j < i, σj  A.

• σ A se e solo se σ1  A.

(NB: nella clausola per ∨, “oppure” non è ovviamente da interpretarsi in senso


esclusivo: tutti e due i disgiunti possono essere veri).

Esercizio 4. Si dimostri che ♦A si può esprimere come (è equivalente a) true U A.

A questo punto possiamo definire formalmente cosa vuol dire che un programma
soddisfa (o rispetta) una formula.

Definizione 3 (soddisfacibilità tra programmi e formule). Sia P r un programma


concorrente ed A una formula LTL. Diciamo che il programma P r soddisfa A, ovvero
P r è corretto rispetto a A, e scriviamo P r  A, se e solo se, per tutte le computazioni
(fair) σ complete il cui primo stato è lo stato iniziale di P r, abbiamo che σ  A.

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

Verifica formale: prove deduttive e model checking


Docente: Prof. Michele Boreale (Disia)

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.

2 Una prova deduttiva dell’algoritmo di Dekker


Consideriamo l’algoritmo di Dekker, sotto riportato. Esso è piuttosto complesso:
le varie argomentazioni informali che possono essere usate per convincersi della sua

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.

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

La prova si servirà del seguente risultato intermedio, la cui dimostrazione è una


semplice induzione computazionale, che viene lasciata per esercizio.
Lemma 1. I seguenti sono invarianti per Dekker.
1. (turn = 1) ∨ (turn = 2).

2. wantp ⇔ (p3..5 ∨ p8..10).

3. wantq ⇔ (q3..5 ∨ q8..10).


Formalizziamo in LTL la SF per Dekker
def
SF = 2(p2 ⇒ ♦p8) ∧ 2(q2 ⇒ ♦q8) .

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

♦2(wantp ∧ turn = 1) . (6)

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.

3 Introduzione al model checking


Un model checker è uno strumento di verifica automatica, che può aiutare gli svi-
luppatori nelle dimostrazioni di correttezza dei programmi concorrenti. In linea di
massima, un model checker implementa un algoritmo che, presi in input una for-
mula A (la proprietà desiderata) ed un programma concorrente P r, descritto in un
opportuno linguaggio, può dare in output uno di due possibili risultati:

ˆ la risposta ’Sı̀’, se P r  A;

ˆ la risposta ’No’ ed un controesempio, sotto forma di computazione σ di P r tale


che σ 6 A, altrimenti.

Accenniamo qui brevemente ai principi che sottostanno al model checking. L’algo-


ritmo di verifica del model checking si basa su una semplice idea: il diagramma degli
stati del programma, spesso indicato anche come spazio degli stati, viene esplorato
in maniera sistematica, a partire dallo stato iniziale, alla ricerca di violazioni della
proprietà data. La ricerca è, almeno in teoria, esaustiva. Se non vengono trovate
violazioni, allora si restituisce la risposta ’Sı̀’ (la proprietà è vera per il programma);
altrimenti si restituisce ’No’ (la proprietà è violata), insieme alla computazione tro-
vata che viola la proprietà data. Si deve notare che la ricerca esaustiva è possibile
solo se il diagramma degli stati del programma è finito, perciò d’ora in avanti ci
metteremo in questa ipotesi.

Safety Consideriamo prima il caso di una proprietà di Safety, come un invariante


della forma 2A, dove A è una formula proposizionale. In tal caso, è sufficiente
esplorare lo spazio degli stati alla ricerca di uno stato che viola A, cioè soddisfa ¬A,
se esso esiste. Si deve notare che, in questo caso, non è necessario costruire per

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.

C’è tuttavia un accorgimento da adottare per rendere questo procedimento efficiente.


Infatti, se durante la visita si incontra un nodo (stato) già visitato in precedenza, il
relativo sottografo non deve essere visitato di nuovo. Allo scopo, si mantiene una
struttura dati, detta hashtable, che tiene traccia degli stati già visitati. Si noti che
non necessariamente uno stato già visitato si trova sul percorso radice-nodo corrente,
e quindi sulla pila (si noti, nella figura sottostante, il caso stato 6 = stato 3). È
fondamentale notare che la visita termina se si trova uno stato che viola la proprietà
A. In tal caso, non sarà stato necessario esplorare l’intero diagramma, che magari è
molto grande.

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

4 Model checking in pratica: Promela e SPIN


Introduciamo ora la metodologia del model checking attraverso una serie di esempi
pratici di verifica. L’ultimo di essi consisterà in una verifica (alternativa) dell’Algo-
ritmo di Dekker. Ci serviremo qui del tool Erigone, una parziale re-implementazione
con finalità didattiche di SPIN, ad opera di Mordechai Ben-Ari. Il tool e le istru-
zioni per la sua installazione ed il suo utilizzo possono essere scaricati dalla pagina
dell’autore [3]; si veda anche il materiale messo a disposizione sulla pagina Moodle
del corso. Questi esempi ci serviranno anche per introdurre gli elementi essenziali del
linguaggio di specifica Promela. Esso è, semanticamente, assai simile al linguaggio
1
Ci sono classi particolari di sistemi per le quali, impiegando opportune strutture dati e ac-
corgimenti, questo limite può essere superato. Per esempio, specifiche di sistemi hardware molto
regolari.

7
semplificato che abbiamo utilizzato nel corso, dal quale si discosta per l’aggiunta di
un po’ di zucchero sintattico.

Esempio 1 (race condition). Ecco un un listing Promela che specifica: (1) un


programma composto da due processi, p e q; e (2) una proprietà LTL norace.
1 byte x = 1;
2 byte y = 0;
3 bool term = 0;
4
5 ltl norace { []( term => (y==5)) }
6
7 active proctype p() {
8 x=x+1;
9 x=x+1;
10 }
11
12 active proctype q() {
13 y=10/x;
14 term=1;
15 }

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

verification terminated=never claim terminated,


line=9,statement={x=x+1},
Run a guided simulation to see the counterexample

La frase never claim terminated è il modo di SPIN/Erigone di riportare che


una violazione di safety è stata trovata. Erigone suggerisce di eseguire una guided
simulation per osservare la computazione che porta allo stato di violazione. Faccia-
molo, cliccando sul bottone “Guided”. Il risultato che apparirà sulla finestra destra
dell’applicazione è il seguente (al netto di informazioni non rilevanti).
execution mode=simulation,
simulation mode=guided,trail number=0,total steps=100,
Process Statement term x y
q 13 y=10/x 0 1 0
p 8 x=x+1 0 1 10
q 14 term=1 0 2 10
p 9 x=x+1 1 2 10
1 3 10
simulation terminated=valid end state,
line=9,statement={x=x+1},
Come si può vedere, la computazione incriminata è qui rappresentata in forma
tabellare, cioè come uno scenario: è la tabella con intestazioni delle colonne term,

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.

Esempio 2 (fairness). Consideriamo questo codice Promela, che specifica ancora


due processi e una semplice proprietà di fairness.
1 byte n = 0;
2 bool flag = false;
3
4 ltl f { <>flag }
5
6 active proctype p() {
7 do
8 :: flag => break;
9 :: else => n = 1 = n;
10 od
11 }
12
13 active proctype q() {
14 flag = true
15 }
Introduciamo qui due costrutti di Promela. Il costrutto

do
...
od

è la sintassi Promela per il ciclo

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

ha la stessa semantica di if b then S1 else S2. Infine, l’esecuzione di break provoca


l’uscita dal ciclo do ... od più interno.
Sottoponiamo ora la verifica della formula f ad Erigone. Questa è una proprietà di
Liveness, che può essere verificata o tramite il bottone “Accept” o tramite il bottone
“Fairness”. Entrambi ricercano dei cicli di accettazione, ma nel secondo caso ci si
restringe a sole computazioni fair. Cliccando su “Fairness”, otterremo questa risposta.

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.

Esempio 3 (quarto tentativo). Analizziamo il quarto tentativo con Erigone. La


versione Promela è quella in Figura 3. Essa contiene anche la specifica di due formule
LTL. La formula LTL mutex esprime la ME (notare: ! = ¬, && = ∧), ed Erigone ci
dirà che essa è rispettata, se usiamo il bottone “Safety”. Per quanto riguarda la DF,
notiamo che SPIN (e dunque Erigone) può effettuare un controllo della presenza di
stati di deadlock, dai quali non è possibile uscire (invalid states nella terminologia
SPIN): è sufficiente, prima di aver dichiarato nel codice qualsiasi proprietà, cliccare
su “Safety”, che ci darà questo risultato.

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

Eseguendo una simulazione guidata, come suggerito, troviamo la computazione in-


criminata. Essa consiste di un lungo trail, che qui omettiamo, e di un ciclo di
accettazione che viene cosı̀ descritto da Erigone.
csp csq wantp wantq
start of acceptance cycle
P 13 wantQ 0 0 1 1
Q 28 wantP 0 0 1 1
Q 29 wantQ = false 0 0 1 1
P 14 wantP = false 0 0 1 0
Q 30 wantQ = true 0 0 0 0
P 15 wantP = true 0 0 0 1
0 0 1 1
simulation terminated=end of acceptance cycle,
line=15,statement={wantP = true},
Ancora una volta, il problema è una esecuzione alternata di P e Q, che continuano
a tentare (es. wantP=true) e poi desistere (wantP=false), perché si accorgono che
anche l’altro vorrebbe entrare (questo è il contenuto del ciclo di accettazione).

Esempio 4 (Dekker). Infine, la versione Promela di Dekker in Figura 4. Si noti,


prima di tutto, il comando

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

che è equivalente al comando di attesa attiva

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 }

5 Tecniche di verifica a confronto


Terminiamo questa lezione riassumendo i pro e contro delle tecniche di verifica che
abbiamo esaminato.
Le prove di correttezza deduttive devono essere condotte manualmente, spesso
sono difficili e ad hoc. D’altra parte, la dimensione del sistema, cioè la dimensione
dello spazio degli stati, non costituisce per esse un particolare problema. In alcuni

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 }

Figura 1: Listing del programma Promela fourth.pml.

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

Attesa passiva: semafori


Docente: Prof. Michele Boreale (Disia)

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.

ˆ Possono risultare inefficienti, in ambienti multitasking con alta contention. In-


fatti, per via dell’attesa attiva, parte del tempo delle CPU viene impiegato
nell’esecuzione di cicli di attesa, che non fanno progredire il programma, ma
servono solo a ritardare il processo che attende di entrare in CS.

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

3 Definizione dei semafori


La metafora del semaforo è abbastanza facile da comprendere. Ogni processo può
essere visto come un automobilista, e una risorsa condivisa o sezione critica, come
un incrocio o un ponte. Un semaforo è una struttura posta a guardia dell’incrocio.
Tuttavia, in questa metafora il semaforo non è temporizzato: invece, la gestione
dell’incrocio è affidata alla cooperazione tra gli automobilisti, che si sincronizzano
attraverso il semaforo. Ogni automobilista, prima di entrare nell’incrocio, guarda
il semaforo: se esso è rosso, egli si mette in fila e attende, magari insieme ad altri;
se è verde, egli può entrare nell’incrocio, ma il suo passaggio può comportare il
passaggio al rosso del semaforo (incrocio occupato). Viceversa, un automobilista che
esce dall’incrocio, deve segnalare di averlo fatto: questo può comportare il passaggio

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:

1. nell’esecuzione della wait, il ramo else non fa avanzare il control pointer di


P, che quindi rimane sulla wait stessa fino al successivo sblocco (wait non
completata);

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.

4 Sezione critica con semafori


Vediamo un primo esempio di uso dei semafori. Con l’uso dei semafori la soluzione al
problema della CS diventa molto più semplice ed efficiente ed è riportata qui sotto. Si
pone un semaforo inizializzato a 1 a guardia della CS. Il pre-protocol è semplicemente
una wait sul semaforo. Se esso è verde, il processo richiedente, diciamo P , accede
alla CS e, atomicamente, pone il semaforo a 0 (rosso). Se l’altro processo Q prova ad
accedere alla CS mentre il semaforo è rosso, esso verrà posto in attesa sulla coda del
semaforo. Quando il processo P uscirà da CS, eseguirà una signal che avrà l’effetto
di risvegliare Q e, atomicamente, metterlo direttamente in CS, per effetto dell’azione
della signal sul control pointer di Q.

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)

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)

Il diagramma versione abbreviata è il seguente.


' $' $' $
p1: wait(S), - p2: signal(S), p2: signal(S),
q1: wait(S),  q1: wait(S), - q1: blocked,
(1, ∅) (0, ∅) (0, {q})
& p&
@% & % %
I
@ @
I
@ @
@@ @
@@' $ @' $
@@ q@
R p1: wait(S),
@ p1: blocked,
q2: signal(S), - q2: signal(S),
(0, ∅) (0, {p})
& %& %

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:

ˆ S←(k,∅) corrisponde in Java alla dichiarazione Semaphore S = new Semapho-


re(k);

ˆ wait(S) corrisponde in Java a S.acquire();

ˆ signal(S) corrisponde in Java a S.release().

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.

ˆ S.acquire() decrementa S se S>0; altrimenti manda il processo che l’ha invocata


in sospensione (fase blocked);

ˆ S.release() incrementa S; allo stesso tempo, sblocca (mette a ready) un processo


sospeso su S, se ce n’è uno. Tale processo dovrà rieseguire la S.acquire(),
quando tornerà effettivamente in esecuzione.

La differenza fondamentale rispetto ai semafori classici, dunque, è che la release()


non fa avanzare il control pointer del processo sbloccato. Dal momento che non c’è
alcuna garanzia che il processo sbloccato sia il primo a eseguire la acquire() dopo il
suo sblocco, è possibile che quando questo succederà, esso troverà ancora S=0 (se
nel frattempo un altro processo ha eseguito la acquire()). Dunque viene introdotta
la possibilità di attesa attiva, costituita dalla (ri)esecuzione ripetuta del metodo
acquire().
È anche possibile istanziare strong semaphore, cioè semafori in cui l’insieme d’at-
tesa sia gestito con una disciplina FIFO: essi sono detti fair nella terminologia Java.
A tal fine, va invocato il costruttore a due argomenti della classe:
Semaphore = new Semaphore(k,flag)
dove flag=false vuol dire semantica di default (quella sopra descritta), flag=true vuol
dire strong semaphore. Si noti tuttavia che, in Java, neanche un semaforo fair
garantisce la SF: come prima, la release comunque non fa avanzare il thread sbloccato,
che dunque dovrà ri-eseguire la acquire (si veda a tal proposito il prossimo esercizio).

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 }

Figura 1: Listing della classe CountSem.java.

Esercizio 1. Riflettere sulle proprietà di Starvation Freedom che possono essere


garantite dai semafori Java. Allo scopo, dire se il programma in Figura 1 soddisfa o
no la SF per i thread p e q. Programmare una versione con semafori fair dello stesso
codice e dire se garantisce la SF.

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

public void run() {


int temp;
for (int i = 0; i < 10; i++) {
try {
s.acquire();
}
catch (InterruptedException e) {}
temp = n;
n = temp + 1;
s.release();
}
}

public static void main(String[] args) {


CountSem p = new CountSem();
CountSem q = new CountSem();
p.start();
q.start();
try { p.join(); q.join(); }
catch (InterruptedException e) { }
System.out.println("The value of n is " + n);
}
}
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 12 - 10/11/2022

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.

Si può pensare che #wait(S) (risp. #signal(S)) viene atomicamente incrementata


ogni volta che una wait(S) (risp. signal(S)) viene completata.
Il significato dell’invariante (b) nel seguente teorema è che ogni wait completata
decrementa di uno il n. di permessi, mentre ogni signal completata lo incrementa di
uno.
Teorema 1 (Invarianti di semaforo). Sia S un semaforo, con un numero di permessi
iniziali k ≥ 0. Le seguenti proprietà sono invarianti, nel senso che valgono in ogni
stato di ogni computazione del programma.
(a) S.V ≥ 0.

(b) S.V = k − #wait(S) + #signal(S).


Prova Per induzione computazionale. Il caso (a) è ovvio. Vediamo il caso (b),
che è una semplice verifica. Nello stato iniziale l’uguaglianza è ovviamente verificata.
Vediamo il passo induttivo. Assumiamo che l’uguaglianza

S.V = k − #wait(S) + #signal(S)

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

#CS = #wait(S) − #signal(S). (2)

(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

#CS + S.V = (#wait(S) − #signal(S)) + (1 − #wait(S) + #signal(S))


= 1

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

è un invariante. Ma questa è proprio la proprietà di ME.


Dimostriamo ora la SF, da cui seguirà direttamente anche la DF. Supponiamo
che, in una certa computazione, da un certo stato in poi P rimanga per sempre bloc-
cato in p2 (ci riferiamo qui alla versione non abbreviata). Questo può solo succedere
se, quando viene eseguito p2, si trova che S.V = 0. Ma per l’invariante (1), questo
implica che #CS = 1. Dunque, Q è in CS, e, per ipotesi, prima o poi ne uscirà
eseguendo q4. Questo avrà l’effetto di sbloccare un processo nella coda di S e porlo
in CS: ma l’unico processo in coda è proprio P .

2 Problemi di tipo “ordine di esecuzione”


Abbiamo visto come risulti piuttosto semplice dare una soluzione al problema della
CS mediante l’uso dei semafori. Considereremo ora alcuni problemi di sincroniz-
zazione e cooperazione più complicati, che possono anch’essi essere risolti in modo
semplice tramite i semafori.
Il primo problema che prendiamo in esame è quello di tipo ordine di esecuzio-
ne. Si tratta di implementare delle relazioni di precedenza tra diversi compiti da
svolgere, ciascuno dei quali è assegnato ad un processo. Alcuni compiti sono indi-
pendenti tra loro e possono essere svolti in parallelo. Altri devono magari attendere

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.

Esempio 1 (Mergesort concorrente). In un algoritmo Mergesort concorrente, l’or-


dinamento delle due metà del vettore è affidato a due processi diversi, sort1 e sort2,
che possono lavorare indipendentemente e dunque in parallelo. La fusione delle due
metà ordinate è affidata ad un terzo processo, merge. Quest’ultimo deve natural-
mente attendere il completamento di sort1 e sort2 prima di iniziare le operazioni di
fusione. L’attesa viene implementata impiegando due semafori, S1 e S2, che sono
inizializzati a 0. Ciascuno dei processi sort, al termine del proprio compito, segna-
lerà sul rispettivo semaforo. Il processo merge potrà iniziare il proprio compito solo
dopo aver completato una wait su ciascuno dei due semafori. Il codice risultante è,
schematicamente, quello sotto riportato.

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.

ˆ Asincrona. Esiste un mezzo di trasmissione, o buffer, dotato di una certa


capacità, che può immagazzinare i dati prodotti e in attesa di essere consumati.
In tal modo, P e C interagiscono non direttamente tra loro, ma con il buffer.
Per il momento, considereremo solo il caso asincrono, rimandando quello sincrono ad
un successivo momento. Graficamente, dunque, la situazione è la seguente.

Il buffer ha lo scopo di disaccoppiare P da Q: i due processi possono procedere


in maniera parzialmente indipendente, cioè senza che l’uno sia costretto ad andare

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. C cerca di eseguire una una take da un buffer vuoto.


Per comodità di esposizione, affronteremo un problema alla volta. Assumeremo dap-
prima che la capacità del nostro buffer sia infinita. In tal modo, il primo dei due
problemi non si presenterà. Quella del buffer con capacità infinita è naturalmente
un’astrazione: essa corrisponde al caso in cui il buffer è talmente grande che l’evento
buffer pieno è assai improbabile (per cui siamo disposti a trascurare questa possibi-
lità). Sotto questa ipotesi, vediamo allora una soluzione al problema che fa uso dei
semafori. Prima però elenchiamo le proprietà che desideriamo, nel caso generale.
1. Safety: non accade mai che P inserisca in un buffer pieno, o C prelevi da un
buffer vuoto.

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.

Algorithm: Producer-consumer (infinite buffer, abbreviated)


infinite queue of dataType buffer ← empty queue
semaphore notEmpty ← (0, ∅)
producer P consumer C
dataType d dataType d
loop forever loop forever
p1: append(d, buffer) q1: wait(notEmpty)
p2: signal(notEmpty) q2: d ← take(buffer)

Un frammento iniziale del diagramma è il seguente (le frecce orizzontali corri-


spondono ai comandi del producer, le rimanenti al consumer).

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]
& %& %& %

Procediamo ora ad una prova deduttiva della correttezza di questo algoritmo


(l’unica possibile, visto che la verifica automatica risulta non praticabile con uno
spazio degli stati infinito). Ci riferiremo di seguito alla forma abbreviata. Definiamo
la variabile di stato
def
#buf f er = n. di elementi nel buffer.

Cerchiamo prima di tutto di esprimere questa variabile in termini delle variabili


numeriche di semaforo, cioè notEmpty.V, #wait, #signal. Intuitivamente, dal mo-
mento che ogni append è accompagnata da una signal e ogni take da una wait, alla
luce dell’invariante del Teorema 1(b), ci aspetteremmo che il valore del semaforo
semplicemente indichi il n. di elementi nel buffer. Cioè ci aspettiamo un invariante
di questo tipo
#buf f er = notEmpty.V .
Tuttavia, è facile constatare che questa uguaglianza non vale in tutti gli stati: si
veda ad es. il secondo stato della prima riga, oppure il terzo della seconda riga, nel
frammento di diagramma precedente. Per avere un’uguaglianza che valga sempre,
cioè in tutti gli stati, dobbiamo anche conteggiare le signal che sono in procinto di
essere eseguite e le take che sono in procinto di essere eseguite. Allo scopo, definiamo
le seguenti variabili di stato ausiliarie, che possono valere 0 o 1:
def
#p2 = 1 se P è in p2, 0 altrimenti
def
#q2 = 1 se C è in q2, 0 altrimenti.

Lemma 3. Il seguente è un invariante per l’algoritmo Produttore-Consumatore ab-


breviato con buffer infinito

notEmpty.V + #p2 = #buf f er − #q2.

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.

Teorema 4. L’algoritmo Produttore-Consumatore abbreviato con buffer infinito è


corretto. Vale a dire, esso soddisfa le seguenti formule LTL

ˆ Safety: 2(q2 ⇒ (#buf f er > 0))

ˆ SF: 2(q1 ⇒ ♦q2).

Prova Vediamo la Safety. Possiamo riscrivere l’invariante del lemma precedente


in questo modo

#buf f er = notEmpty.V + #p2 + #q2.

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.

4 Produttore-consumatore, caso buffer finito


Nel caso di buffer finito, P deve assicurarsi che il buffer sia non pieno prima di fare
una append. Allo scopo, useremo un nuovo semaforo, che chiamiamo notFull. C’è
però una differenza rispetto a prima: dal momento che il buffer ha inizialmente N
posizioni libere, P ha in effetti N permessi disponibili, che può consumare con un
po’ alla volta. Naturalmente, i permessi possono essere rigenerati da C, ma mano
che egli preleva elementi dal buffer con take. L’algoritmo risultante è il seguente.

Algorithm: Producer-consumer (finite buffer, semaphores)


finite queue of dataType buffer ← empty queue
semaphore notEmpty ← (0, ∅)
semaphore notFull ← (N, ∅)
producer consumer
dataType d dataType d
loop forever loop forever
p1: d ← produce q1: wait(notEmpty)
p2: wait(notFull) q2: d ← take(buffer)
p3: append(d, buffer) q3: signal(notFull)
p4: signal(notEmpty) q4: consume(d)

Omettiamo la prova di correttezza di questo algoritmo, che si basa su tecniche


simili a quelle viste nel caso precedente.

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 Il problema dei filosofi a cena


Si tratta di un classico problema di sincronizzazione, anche questo introdotto da
E.W. Dijkstra, intorno al 1965. Il problema si presta a modellare situazioni in cui ogni
processo ha bisogno di due o più risorse distinte per completare un certo compito, ma
le risorse sono in numero limitato. Nel problema, le risorse sono rappresentate dalle
forchette alla destra e alla sinistra di ciascun filosofo; nella realtà, si potrebbe trattare
di risorse logiche o fisiche qualsiasi (es.: pagine di memoria consecutive). In ogni caso,
visto che l’acquisizione delle risorse da parte dei processi avviene sequenzialmente,
c’è il pericolo di arrivare ad uno stato in cui ciascun processo ha acquisito solo una
parte delle risorse a lui necessarie, ma le risorse sono complessivamente esaurite.
Questo può portare ad una situazione di stallo, ovvero, tecnicamente, di deadlock.
Esponiamo di seguito il problema nella formulazione dovuta a Tony Hoare [1].
Un gruppo di filosofi (processi) sono riuniti intorno ad un tavolo, al cui centro è
posto un vassoio di spaghetti. I filosofi non possono parlare direttamente tra loro.
Ogni filosofo alterna fasi in cui pensa a fasi in cui mangia. Per mangiare, però, egli
ha prima bisogno di acquisire due forchette (risorse), quella alla sua sinistra e quella
alla sua destra, una alla volta. Tali forchette sono da condividere con i suoi vicini,
di sinistra e destra, rispettivamente. Finito di mangiare, il filosofo rilascia le due
forchette1 . Si osservi la figura seguente. Per concretezza, fissiamo d’ora in avanti
N = 5 filosofi, P0 , ..., P4 . Tutti gli indici che useremo si intendono mod 5. Si noti
che ogni f orki è contesa tra due i filosofi Pi−1 e Pi , non da altri.
1
In altre illustrazioni del problema, al centro c’è un piatto di riso, e bastoncini al posto delle
forchette.

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.

ˆ ME. In ogni momento, ogni forchetta è in mano ad al più un filosofo.

ˆ 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à.

Vediamo il primo tentativo. L’idea è semplice: ogni forchetta f orki è rappresen-


tata da un semaforo binario fork[i], inizializzato a 1. Se fork[i].V > 0, la forchetta è
libera, altrimenti è acquisita. Dunque acquisire una forchetta corrisponde a comple-
tare una wait, mentre rilasciarla corrisponde ad una signal (che può eventualmente
sbloccare un filosofo in attesa). Si noti che, nel codice, facciamo uso di un array di
cinque semafori.

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

È facile vedere che questo algoritmo soddisfa ME.

Teorema 1. L’algoritmo Dining philosophers - I tentativo, soddisfa ME.

Prova Consideriamo la forchetta i-ma. Definiamo la variabile di stato


def
#Fi = n. di filosofi che possiedono la forchetta i.

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

#Fi = #wait(f ork[i]) − #signal(f ork[i]) (1)

è un invariante del programma (formalmente, questo si dimostra per induzione com-


putazionale). Dall’invariante generale di semaforo applicato a f ork[i].V , abbiamo
allora

f ork[i].V = 1 − #wait(f ork[i]) + #signal(f ork[i])


= 1 − (#wait(f ork[i]) − #signal(f ork[i]) )
= 1 − #Fi

dove nel primo passaggio abbiamo applicato appunto l’invariante generale, e nel
secondo l’invariante sopra, (1), per #Fi . Dunque

#Fi = 1 − f ork[i].V (2)

e siccome f ork[i].V ≥ 0, abbiamo mostrato che

#Fi ≤ 1

è un invariante, che è appunto la ME per la forchetta i-ma.

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.

2 Una soluzione al problema dei filosofi a cena


Presentiamo di seguito una soluzione nella quale il numero di filosofi che entrano
nella sala da pranzo è limitato ad un massimo di quattro. L’idea è semplice: se c’è
almeno un posto vuoto a tavola, il filosofo P che siede alla sinistra di questo posto
non può rimanere bloccato per sempre in attesa della forchetta alla sua destra; d’altra
parte, P non può rimanere bloccato sulla forchetta alla propria sinistra2 , e quindi P
non può rimanere bloccato per sempre. Ecco l’algoritmo, dove l’accesso alla sala da
pranzo viene regolato tramite un semaforo room, che ha inizialmente 4 permessi.

Algorithm: Dining philosophers (second attempt)


semaphore array [0..4] fork ← [1,1,1,1,1]
semaphore room ← 4
loop forever
p1: think
p2: wait(room)
p3: wait(fork[i])
p4: wait(fork[i+1])
p5: eat
p6: signal(fork[i])
p7: signal(fork[i+1])
p8: signal(room)

Teorema 2. L’algoritmo Dining Philosophers – Secondo tentativo è corretto. Cioè,


esso gode delle proprietà di ME, DF, SF.
2
Infatti, se tale forchetta è stata acquisita come forchetta destra dal filosofo Q alla sinistra di P ,
essa verrà rilasciata, dopo che Q ha terminato di mangiare, cosa che sicuramente farà.

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.

Algorithm: Dining philosophers (third attempt)


semaphore array [0..4] fork ← [1,1,1,1,1]
philosopher 4
loop forever
p1: think
p2: wait(fork[0])
p3: wait(fork[4])
p4: eat
p5: signal(fork[0])
p6: signal(fork[4])

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.

3 Classificazione dei semafori


Abbiamo già visto che, nella versione più generale dei semafori, non si fa alcuna
assunzione riguardante la disciplina di prelievo dall’insieme S.L. Il tipo di semafori
risultanti è a volte anche chiamato weak semaphore. Negli strong semaphore, come
abbiamo detto, la disciplina prevista è quella FIFO. Questa disciplina garantisce la
SF in diversi casi, come ad esempio, la soluzione al problema della CS con 3 o più
processi.

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

Si noti che, tecnicamente, la computazione risultante dall’espandere il ciclo è fair,


perché il processo Q esegue wait(S) infinite volte, anche se sempre quando S=0.
Uno dei motivi per cui abbiamo qui introdotto esplicitamente i semafori busy
wait è che la semantica di default dei semafori in Java si avvicina3 a questi (lo stesso
si può dire per Promela).

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

Programmazione Concorrente A.A. 2022-2023 Lezione 14 - 17/11/2022

Esercizi sui semafori.


Docente: Prof. Michele Boreale (Disia)

1 Esercizi sui semafori classici


Esercizio 1. Considerare il seguente algoritmo e rispondere alle successive domande.

Algorithm: Algoritmo con semafori A


semaphore S ← (1, ∅), semaphore T ← (0, ∅)
P Q
p1: wait(S) q1: wait(T)
p2: write(”p”) q2: write(”q”)
p3: signal(T) q3: signal(S)

1. Quali sono i possibili output di questo algoritmo?

2. Quali sono i possibili output se si rimuove il comando wait(S)?

3. Quali sono i possibili output se si rimuove il comando wait(T)?

Soluzione. Questo algoritmo implementa un ordine di esecuzione, in cui P precede Q:


questo ordine è determinato dal semaforo T. Si noti che il semaforo S non blocca P , essendo
inizializzato a 1. Dunque, la risposta alla domanda (a) è che c’è una sola sequenza possibile,
“p” seguito da “q”.
Per quanto riguarda (b), è evidente che la rimozione di wait(S) da P non cambia
alcunché.
Infine, per (c), se si rimuove wait(T), allora l’output dipende da chi per primo tra i due
eseguirà la propria write, dunque abbiamo due sequenze possibili, “p” seguito da “q” o “q”
seguito da “p”.

Esercizio 2. Dire quali sono i possibili output del seguente algoritmo.

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

Modificare questo algoritmo in modo da usare un solo semaforo.


Soluzione. È possibile sostituire S1 e S2 con un unico semaforo S, inizializzato a 0. I
processi sort faranno una signal(S) ciascuno. Il processo merge farà due wait(S) in sequenza.
Esercizio 4 (Agenzia di viaggio). Considerare il seguente problema di tipo ordine di
esecuzione. Un programma concorrente è composto dai processi Client, Flight, Hotel e
Bank. Ciascuno di questi processi esegue un proprio ciclo infinito. In ciascuna iterazione,
Client deve eseguire il comando atomico provideData prima che Flight e Hotel eseguano
i propri comandi atomici bookF e bookH, rispettivamente. Client deve invece eseguire
il comando atomico pay solo dopo che sia Flight che Hotel hanno eseguito i rispettivi
comandi. Bank attende che sia terminata l’esecuzione di pay e poi esegue paymentOk.
Solo a quel punto Client può ricominciare il proprio ciclo. Programmare una soluzione
con semafori di questo problema.
Soluzione. Forziamo l’ordine di esecuzione usando opportuni semafori. Un possibile
programma è il seguente, dove abbiamo ottimizzato l’impiego dei semafori. In particolare,

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:

Esercizio 5. Programmare in Java gli algoritmi degli esercizi 1, 2, 3 e 4.

Esercizio 6 (Competizione su k risorse). Si consideri la seguente variante del problema


dei filosofi. Abbiamo un sistema di 5 processi e 3 risorse, {r0 , r1 , r2 }, per esempio, una
stampante, un file e una connessione TCP. Ciascuna risorsa può essere assegnata ad al più
un processo alla volta. Ciascun processo deve (ripetutamente) svolgere un certo compito,
per il quale ha bisogno prima di acquisire tutte e tre le risorse. Le risorse possono essere
acquisite solo sequenzialmente, cioè una alla volta (non è dunque possibile acquisire due o
più risorse in maniera atomica). Dopo aver svolto il compito, che si assume termini in un
tempo finito, le risorse vengono rilasciate. Dunque, ogni processo esegue per sempre questo
protocollo:

1. acquisisci sequenzialmente ciascuna risorsa necessaria;

2. svolgi il compito, usando le risorse;

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 7 (Produttore-consumatore con semafori in Java). Programmare in Java la


soluzione con semafori al problema del produttore-consumatore. Partire dal schema di
base di programma concorrente in Java riportato in Figura 1.
Soluzione. Una soluzione che riprende lo schema dato è riportata in Figura 2.

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.

Philosopher 3 takes fork 3


Philosopher 1 takes fork 1
Philosopher 4 takes fork 4
Philosopher 0 takes fork 0
Philosopher 2 takes fork 2

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 }

Figura 1: Possibile schema di un programma concorrente in Java.


1 /* Copyright (C) 2006 M. Ben=Ari. See copyright.txt */
2 /* Programmed by Panu Pitkämäki */
3
4 import java. util . concurrent. Semaphore;
5 import java. util . Queue;
6 import java. util . LinkedList ;
7
8 /* Producer=consumer using semaphore */
9 class ProducerConsumer {
10 /* Size of the finite queue */
11 static final int N = 3;
12 /* Semaphores: at the start N empty slots, 0 used slots . */
13 static Semaphore notFull = new Semaphore(N);
14 static Semaphore notEmpty = new Semaphore(0);
15 /* Predefined type: finite queue of produced items */
16
17 static Queue<Integer> queue = new LinkedList<Integer>();
18
19 class Producer extends Thread {
20 public void run() {
21 int c = 1;
22 for (int i = 0; i < 10; i ++) { /* ”for (...)” replaces ”while (true )” */
23 try {
24 notFull . acquire ();
25 } catch (InterruptedException e) {
26 }
27 System.out.println (”Producing item ” + c);
28 queue.add(c++);
29 notEmpty.release();
30 }
31 }
32 }
33
34 class Consumer extends Thread {
35 public void run() {
36 for (int i = 0; i < 10; i ++){
37 try {
38 notEmpty.acquire();
39 } catch (InterruptedException e) {
40 }
41 System.out.println (”Consuming item ” + queue.remove());
42 notFull . release ();
43 }
44 }
45 }
46
47 ProducerConsumer() {
48 Producer p = new Producer();
49 Consumer c = new Consumer();
50 p. start ();
51 c. start ();
52 }
53 7
54 public static void main(String[] args ) {
55 new ProducerConsumer();
56 }
57 }

Figura 2: Listing della classe ProducerConsumer.java.


1 /* Copyright (C) 2006 M. Ben=Ari. See copyright.txt */
2 /* Programmed by Panu Pitkämäki */
3
4 import java. util . concurrent. Semaphore;
5
6 /* Dining philosophers solution */
7 class Dining {
8 /* Semaphore for each fork */
9 static Semaphore[] fork = new Semaphore[5];
10
11 class Philosopher extends Thread {
12 /* Philosopher id */
13 int i ;
14 Philosopher (int i ) { this . i = i; }
15
16 public void run() {
17 for (int j = 0; j < 10; j ++) {
18 /* Think */
19 try {
20 fork [ i ]. acquire ();
21 System.out.println (”Philosopher ” + i + ” takes fork ” + i +”.”);
22 fork [( i + 1) % 5].acquire ();
23 System.out.println (”Philosopher ” + i + ” takes fork ” + ((i + 1) % 5) +”.”);
24 } catch (InterruptedException e) {
25 }
26 /* Eat */
27 System.out.println (”Philosopher ” + i + ” is eating . ”);
28 fork [ i ]. release ();
29 System.out.println (”Philosopher ” + i + ” releases fork ” + i +”.”);
30 fork [( i + 1) % 5].release ();
31 System.out.println (”Philosopher ” + i + ” releases fork ” + ((i + 1) % 5) +”.”);
32 }
33 }
34 }
35
36 Dining() {
37 for (int i =0; i < 5; i ++) {
38 fork [ i ] = new Semaphore(1);
39 }
40 Philosopher P0 = new Philosopher(0);
41 Philosopher P1 = new Philosopher(1);
42 Philosopher P2 = new Philosopher(2);
43 Philosopher P3 = new Philosopher(3);
44 Philosopher P4 = new Philosopher(4);
45 P0.start ();
46 P1.start ();
47 P2.start ();
48 P3.start ();
49 P4.start ();
50 try {
51 P0.join (); P1.join (); P2.join (); P3.join (); P4.join ();
52 }
53 catch (InterruptedException e) {
54 };
55 System.out.println (”Program terminated correctly ! ”);
56 } 8
57
58 public static void main(String[] args ) {
59 new Dining();
60 }
61 }

Figura 3: Listing della classe Dining.java (non soddisfa DF).


1 /* Copyright (C) 2006 M. Ben=Ari. See copyright.txt */
2 /* Programmed by Panu Pitkämäki */
3
4 import java. util . concurrent. Semaphore;
5
6 /* Dining philosophers solution */
7 class DiningAsymmetric {
8 /* Semaphore for each fork */
9 static Semaphore[] fork = new Semaphore[5];
10
11 class Philosopher extends Thread {
12 /* Philosopher id */
13 int i ;
14 Philosopher (int i ) { this . i = i; }
15
16 public void run() {
17 for (int j = 0; j < 10; j ++) {
18 /* Think */
19 try {
20 fork [ i ]. acquire ();
21 System.out.println (”Philosopher ” + i + ” takes fork ” + i +”.”);
22 fork [( i + 1) % 5].acquire ();
23 System.out.println (”Philosopher ” + i + ” takes fork ” + ((i + 1) % 5)
24 +”.”);
25 } catch (InterruptedException e) {
26 }
27 /* Eat */
28 System.out.println (”Philosopher ” + i + ” is eating . ”);
29 fork [ i ]. release ();
30 System.out.println (”Philosopher ” + i + ” releases fork ” + i +”.”);
31 fork [( i + 1) % 5].release ();
32 System.out.println (”Philosopher ” + i + ” releases fork ” + ((i + 1) % 5) +”.”);
33 }
34 }
35 }
36
37
38
39 class PhilosopherReversed extends Thread {
40 /* Philosopher id */
41 int i ;
42 PhilosopherReversed(int i ) { this . i = i; }
43
44 public void run() {
45 for (int j = 0; j < 10; j ++) {
46 /* Think */
47 try {
48 /* Reversed acquire order */
49 fork [( i + 1) % 5].acquire ();
50 System.out.println (”Philosopher ” + i + ” takes fork ” + ((i + 1) % 5) + ”.”);
51 fork [ i ]. acquire ();
52 System.out.println (”Philosopher ” + i + ” takes fork ” + i + ”. ”);
53 } catch (InterruptedException e) {
54 }
55 /* Eat */
56 System.out.println (”Philosopher ” + i + ” is eating . ”);
57 Thread.yield ();
58 fork [( i + 1) % 5].release 9 ();
59 System.out.println (”Philosopher ” + i + ” releases fork ” + ((i + 1) % 5) +”.”);
60 fork [ i ]. release ();
61 System.out.println (”Philosopher ” + i + ” releases fork ” + i +”.”);
62
63 }
64 }
65 }

Figura 4: Listing della classe DiningAsymmetric.java (parte 1).


1
2 DiningAsymmetric() {
3 for (int i =0; i < 5; i ++) {
4 fork [ i ] = new Semaphore(1);
5 }
6 Philosopher P0 = new Philosopher(0);
7 Philosopher P1 = new Philosopher(1);
8 Philosopher P2 = new Philosopher(2);
9 Philosopher P3 = new Philosopher(3);
10 PhilosopherReversed P4 = new PhilosopherReversed(4);
11 P0.start ();
12 P1.start ();
13 P2.start ();
14 P3.start ();
15 P4.start ();
16 try {
17 P0.join (); P1.join (); P2.join (); P3.join (); P4.join ();
18 }
19 catch (InterruptedException e) {
20 };
21 System.out.println (”Program terminated correctly ! ”);
22 }
23
24 public static void main(String[] args ) {
25 new DiningAsymmetric();
26 }
27 }

Figura 5: Listing della classe DiningAsymmetric.java (parte 2).

Esercizio 10. Programmare in Java il problema ordine di esecuzione dell’Esercizio 4.


Lanciare il programma e osservare il risultato.
Soluzione. Una soluzione che riprende lo schema dato è quella riportata nelle Figure 6
e 7.

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 }

Figura 6: Listing della classe Travel.java (parte 1).


11
1 class Bank extends Thread {
2 public void run() {
3 for (int j = 0; j < 4; j ++) {
4 try {
5 s[3]. acquire ();
6 System.out.println (”Bank: paymentOk.”);
7 s[0]. release ();
8 }
9 catch (InterruptedException e) {}
10 }
11 }
12 }
13
14 Travel () {
15 s[0] = new Semaphore(1); /* Initialize semaphores */
16 for (int i =1; i < 4; i ++) {
17 s[ i ] = new Semaphore(0);
18 }
19 Client C = new Client(); /* Instantiate processes */
20 Flight F = new Flight();
21 Hotel H = new Hotel();
22 Bank B = new Bank();
23 C.start (); /* Start processes (=make them ’ready’) */
24 F.start ();
25 H.start ();
26 B.start ();
27 try {
28 C.join (); F.join (); H.join (); B.join (); /* Wait for terminaton of all proceses */
29 }
30 catch (InterruptedException e) {};
31 System.out.println (”Program terminated correctly ! ”);
32 }
33
34 public static void main(String[] args ) {
35 new Travel();
36 }
37 }

Figura 7: Listing della classe Travel.java (parte 2).

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.

ˆ Incapsulamento. Un monitor incapsula una risorsa condivisa o sezione criti-


ca. L’accesso a questa entità da parte dei processi avviene solamente attraverso
l’invocazione di operazioni messe disposizione dal monitor. Il monitor dunque
centralizza la responsabilità per l’uso corretto dell’entità.

ˆ Mutua Esclusione. In particolare, il monitor si preoccupa di serializzare


richieste di accesso (invocazioni) contemporanee da parte di più processi. Al
più un processo alla volta sta eseguendo in ogni momento una operazione del
monitor, cioè è dentro il monitor.

ˆ Parallelismo. Operazioni su monitor diversi sono indipendenti, e possono


essere eseguite in parallelo.

1
fff

HH
H

monitor
f

Da quanto sopra esposto, l’analogia con la programmazione ad oggetti dovrebbe


risultare evidente. Un monitor è simile ad un oggetto, in particolare le operazioni
corrispondono ai metodi. Tuttavia vi sono delle differenze. In primo luogo, tutti
i campi di un monitor sono sempre, per cosı̀ dire, privati, perché accessibili solo
attraverso le operazioni, cosa che non è richiesta per gli oggetti. In secondo luogo,
non è richiesto che i metodi di un oggetto siano eseguiti in mutua esclusione (a
meno che essi non siano stati qualificati come synchronized, usando la terminologia
Java: torneremo su questo aspetto). Il monitor è anche simile al concetto di kernel o
supervisor di un sistema operativo: l’entità alla quale viene demandata l’esecuzione
di operazioni che accedono a risorse critiche del sistema. Nel caso del kernel, però,
la struttura è monolitica, perché c’è una sola entità che gestisce tutte le risorse. Nel
seguito, introdurremo il meccanismo dei monitor come descritto da Tony Hoare [1].
In una successiva lezione, tratteremo l’implementazione dei monitor in Java.

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

Osserviamo quanto segue.

1. Un monitor è un’entità statica e passiva, che attende le invocazioni delle sue


operazioni da parte dei processi.

2. Le operazioni sono eseguite in mutua esclusione. Implicitamente, nel monitor


è presente una variable lock, che dice se il monitor è libero oppure occupato
(un processo sta eseguendo una operazione del monitor).

3. Invocazioni simultanee delle operazioni del monitor, da parte di più processi,


vengono serializzate, nel senso che tra le tante pendenti, una richiesta di invoca-
zione alla volta è scelta e servita. La scelta è nondeterministica, in particolare
le richieste pendenti non vengono conservate in un coda FIFO. Dunque, non
c’è alcuna garanzia che una richiesta pendente da parte di un processo venga
prima o poi servita. In generale, la SF non è garantita.

Possiamo formalizzare questa semantica tramite i diagrammi stati-transizioni. Nel


caso dell’esempio precedente, avremo il seguente diagramma.

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

condition variable = coda FIFO di processi.

Le primitive definite sulle condition variables sono le seguenti. Le primitive vanno


usate all’interno di un monitor. In particolare, la dichiarazione va posta tra quelle
delle variabili del monitor. Le primitive 2,3 e 4, vanno chiamate all’interno di ope-
razioni del monitor. Nella descrizione che segue, chiamiamo P un generico processo
invocante.

1. Dichiarazione. Questa dichiarazione

4
condition cond

instanzia una coda vuota di processi di nome cond.


2. Sospensione.
def
waitC(cond) = h
append(P,cond)
P.phase ← blocked
i
Dunque waitC(cond) ha come effetto quello di inserire il processo P nella coda
cond e sospenderlo incondizionatamente.
3. Segnalazione.
def
signalC (cond) = h
if cond6=[]
Q ← takeFirst(cond)
Q.phase ← ready
i

Dunque, se la coda non è vuota, signalC(cond) ha come effetto quello di pre-


levare il primo processo in coda, Q, e fa riprendere la sua esecuzione dentro il
monitor (v. le precisazioni sotto).
4. Predicato empty.
def
empty(cond) = h
return ( cond = [] )
i

Restituisce il booleano true se la coda è vuota, false altrimenti.


Occorre fare delle precisazioni sulla semantica delle primitive waitC e signalC.
ˆ La waitC sospende sempre il processo che la esegue: l’operazione all’in-
terno della quale essa si trova non verrà perciò completata, e il control pointer
del processo invocante non avanzerà, rimanendo sull’operazione invocata.
ˆ La signalC ha effetto solo se la coda è non vuota. In tal caso, essa sblocca il
processo Q dentro il monitor e, atomicamente, fa proseguire e completa l’opera-
zione di Q che era rimasta in sospeso. Al completamento di tale operazione, il

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:

1. l’operazione invocata da P che contiene la signalC viene completata e dunque


P lascia il monitor;

2. Q viene sbloccato e rimesso subito in esecuzione all’interno del monitor;

3. la waitC di Q e il resto dell’operazione di Q vengono completate e anche Q esce


dal monitor.

Vediamo un esempio che chiarisce meglio questa semantica.

Esempio 1 (simulazione di un semaforo tramite un monitor). Implementiamo un


semaforo binario come un monitor che offre due operazioni, chiamate wait e signal.
Il monitor è usato per programmare una sezione critica con due processi, in forma
abbreviata (cioè NCS e CS viste come commenti).
1
In altri termini, le operazioni non prevedono istruzioni da eseguire successivamente ad una
signalC.

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.

' $ ' $ ' $


p1: Sem.wait, - p2: Sem.signal, p2: Sem.signal,
q1: Sem.wait,  q1: Sem.wait, - q1: blocked,
1, <> 0, <> 0, < q >
& % & %   & %
6 6 

 

' ? 9$
  ' $
p1: Sem.wait,  p1: blocked,
q2: Sem.signal, - q2: Sem.signal
0, <> 0, < p >
& % & %

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.

Osservazione Lo schema di impiego della waitC visto nell’esempio precedente


è abbastanza tipico. In generale, se cond è una condition variable corrispondente

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

Politiche di resumption. Produttori-consumatori


e filosofi a cena con monitor.
Docente: Prof. Michele Boreale (Disia)

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 sbloccati da una segnalazione e in attesa, indicati collettivamente con


W (waiting);

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

3 IRR vs. S&C: un esempio


Prima di introdurre il prossimo argomento, vediamo ancora un semplice esempio,
che serve a chiarire ulteriormente la differenza tra le politiche di resumption IRR e
S&C. Consideriamo il seguente programma, composto da due processi, che invocano
due operazioni di un monitor M. Si noti che in questo caso, la signalC è eseguita non
come istruzione finale delle operazioni.
Algorithm: IRR vs. S&C

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.

Esercizio 1. Modificare il programma precedente (operazione op2) secondo la di-


scussione della sezione precedente, in modo che l’invariante x ≥ 0 sia rispettato con
la politica S&C. Disegnarne il diagramma stati-transizioni.

4 Produttore-consumatore con monitor


La soluzione al problema produttore-consumatore con buffer finito diventa molto
semplice usando i monitor. Il buffer è incapsulato in un monitor, che offre le ope-
razioni corrispondenti ad append e take. Le condizioni di buffer non vuoto e buffer

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

bufferType buffer ← empty


condition notEmpty
condition notFull

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)

Esercizio 2. Disegnare il diagramma stati transizioni della versione abbreviata del


programma Producer-consumer con monitor (produce, consume sono dei commenti),
nel caso in cui il buffer abbia capacità 1.

5 Filosofi a cena con monitor


Terminiamo la lezione con un altro esempio di utilizzo dei monitor. Abbiamo visto
nei filosofi a cena che la difficoltà nel risolvere il problema del deadlock ha origine

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

integer array[0..4] fork ← [2, . . . , 2]


condition array[0..4] OKtoEat

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

Cascade unblocking. Lettori-scrittori.


Introduzione ai monitor Java.
Docente: Prof. Michele Boreale (Disia)

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

Il diagramma stati-transizioni è il seguente (NB: l’etichetta ’P, Q’ su un arco indica


la presenza di due archi distinti, uno etichettato ’P ’ e uno etichettato ’Q’). Si osservi
la transizione dallo stato più a destra della terza riga. Essa rappresenta una cascade
unblocking, in cui, atomicamente:

1. P chiama op2, che esegue x←2 e poi la signalC, che sveglia R;

2. R, ripresa l’esecuzione di op1 dentro il monitor, esegue x←x-1 e poi la signalC,


che sveglia Q;

3. Q, ripresa l’esecuzione di op1 dentro il monitor, esegue x←x-1 e poi la signalC,


che non ha effetto.

Considerazioni simili si applicano alla transizione dal secondo stato della terza riga.

2
• p1, q1, r1, x = 0, [ ]

P P R

P p1, q1, r1, x = 2, [ ] p1, q1 : B, r1, x = 0, [Q] p1, q1, r1 : B, x = 0, [R]

P
P
Q, R P
Q, R P Q
R

p1, q1, r1, x = 1, [ ] p1, q1 : B, r1; B, x = 0, [Q, R] p1, q1 : B, r1; B, x = 0, [R, Q]

2 Il problema dei lettori-scrittori


Si tratta di un classico problema di accesso a database (DB). In un sistema concorren-
te, un insieme di processi lettori (readers), R1 , R2 , ..., e un insieme di processi scrittori
(writers), W1 , W2 , ..., competono per l’accesso ad un DB. L’accesso concorrente al
DB è permesso nel rispetto delle seguenti regole di esclusione (Safety):
ˆ l’accesso al DB di un lettore esclude tutti gli scrittori, ma non altri lettori
(dunque letture parallele sono permesse);
ˆ l’accesso al DB di uno scrittore esclude sia tutti gli altri scrittori, che tutti i
lettori.
Per quanto riguarda la Liveness, richiediamo che
ˆ se un processo vuole accedere al DB, prima o poi quel processo dovrà accedervi
(SF).
Assumiamo inoltre che le fasi di lettura o scrittura di ogni processo terminano sem-
pre. Vediamo una soluzione al problema basata sull’impiego di monitor. Intanto,
si noti che non possiamo semplicemente incapsulare DB dentro un monitor. Infatti,
per via del vincolo di ME dell’esecuzione delle operazioni dei monitor, tale soluzione
impedirebbe le letture parallele da parte di diversi lettori, che invece vogliamo per-
mettere. L’idea allora è che i processi lettori e scrittori invocano le operazioni del

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.

Algorithm: Readers and writers with a monitor

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

Si noti quanto segue.

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.

2. In StartRead, la condizione not empty(OKtoWrite) serve a garantire la SF per


gli scrittori: in fase di lettura, nuovi processi lettori sono ammessi solo fino al
momento in cui arriva la prima richiesta di scrittura. Da quel momento in poi,
ulteriori richieste di lettura (e scrittura) vanno in coda. La EndRead dell’ultimo
lettore sveglierà il primo scrittore in attesa.

3. In StartRead, la signalC(OKtoRead) finale realizza una cascade unblocking dei


processi lettori in attesa su OKtoRead: dal momento che letture parallele sono
permesse, ed è in corso una fase di lettura, ha senso svegliare tutti i lettori in
attesa.

4. In EndWrite, le condizioni dell’ if ... else ... servono a garantire la SF per i


lettori: uno scrittore che termina il proprio accesso sveglia (se c’è) il primo
lettore in attesa. Solo se non ci sono letture pendenti, si può far passare un
altro scrittore.
Dunque, fasi lettura si alternano a fasi di scrittura. Inoltre, durante una fase
di lettura, nuovi lettori sono ammessi all’accesso al DB, ma solo fino al momento
in cui arriva la prima richiesta di scrittura. Da quel momento, ulteriori richieste di
accesso andranno in coda. I lettori attualmente presenti dovranno dunque prima o
poi terminare il loro accesso ed uscire: questo garantisce che ci sia un “ultimo lettore”
della fase di lettura corrente. Un esempio di esecuzione è il seguente (con le ovvie
abbreviazioni; le operazioni sospese sono racchiuse tra parentesi):
SR1, SR2, SR3, (SW1), (SR4), (SR5), R1, R2, R3, ER2, ER3, ER1, W1,
EW1, R4, R5, ER4, ER5,...
Nell’insieme, il programma rispetta sia la Safety che la SF. Vedremo una dimostra-
zione rigorosa di questo fatto in una successiva lezione.

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
}

Qualsiasi oggetto Java può fungere da lock. Il lock è automaticamente acquisito da un


thread in esecuzione prima di entrare nel blocco sincronizzato, ed è automaticamente
rilasciato non appena il thread in esecuzione lascia il blocco sincronizzato. L’unico
modo di acquisire il lock è quello di entrare in un blocco sincronizzato guardato da
quel lock. 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. Dunque

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

ˆ Quando un thread T cerca di eseguire un blocco synchronized e il lock è libero, il


thread T implicitamente acquisisce il lock, facendolo passare al valore occupato.
Altre invocazioni a blocchi guardati dal lock, che arrivino successivamente da
parti di altri threads, dovranno attendere il rilascio del lock da parte del thread
T.

ˆ Durante l’esecuzione del metodo, l’esecuzione di altri blocchi synchronized del-


l’oggetto da parte di T è consentita (sono consentite in particolare le chiamate
ricorsive di metodi).

ˆ Alla ritorno dall’ultimo blocco synchronized, il thread T rilascia il lock, renden-


dolo di nuovo libero.

ˆ Se ci sono più richieste pendenti su uno o più blocchi synchronized guardati


dallo stesso lock, da parte di più thread, quando il lock è libero, una e una sola
richiesta viene selezionata, in maniera nondeterministica.

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è

synchronized myType mymethod(...) {


// corpo del metodo
}

Questo può essere considerato come una abbreviazione di

public myType mymethod(...) {


synchronized (this ){
// corpo del metodo
}
}

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.

ˆ wait set. E’ una forma rudimentale di condition variable associata all’oggetto.


Non si assume alcuna disciplina di ordinamento particolare (a differenza delle
condition variables, che sono gestite in maniera FIFO).

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

ˆ notify(). Preleva un thread dal wait set e lo rende nuovamente eseguibile


(ready), ma fuori dal monitor. Il thread notificante continua dentro il moni-
tor, tenendo il lock. Il thread notificato dovrà ora competere con eventuali
altri thread fuori dal monitor per riguadagnare il lock del monitor (politica
E = W < S). Quando ciò avverrà, l’esecuzione del thread riprenderà den-
tro il monitor dall’istruzione successiva alla wait(). Anche questa primitiva va
chiamata dall’interno di un metodo synchronized.

ˆ notifyAll(). Simile a notify(), ma a differenza di quest’ultima preleva e rende


eseguibili tutti i threads in attesa nel wait set, ponendoli fuori dal monitor (si
osservi la figura sottostante). Anche questa primitiva va chiamata dall’interno
di un metodo synchronized.
fff
HH @
I
H @

@
 @ ff
f f


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

Per implementazioni che garantiscano SF ed efficienza, è possibile impiegare vere


condition variables, come quelle definite nel package java.util.concurrent, sviluppato
da Doug Lea e collaboratori [1], in particolare java.util.concurrent.locks.Condition.
Tale package tuttavia non è oggetto di questa lezione.

4 Produttori-consumatori con monitor in Java


Riportiamo in Figura 4 il codice della classe PCMonitor.java. Si notino, in accordo
con la discussione precedente, l’assenza di condition variables distinte per produttori
e consumatori, l’uso di notifyAll() e il wait() sempre inserito in cicli di controllo.

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 }

Figura 1: Listing della classe PCMonitor.java

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];

synchronized void Append(int V) {


while (Count == N)
try {
wait();
} catch (InterruptedException e) {}
Buffer[Newest] = V;
Newest = (Newest + 1) % N;
Count = Count + 1;
notifyAll();
}

synchronized int Take() {


int temp;
while (Count == 0)
try {
wait();
} catch (InterruptedException e) {}
temp = Buffer[Oldest];
Oldest = (Oldest + 1) % N;
Count = Count - 1;
notifyAll();
return temp;
}
}
Università di Firenze - CdS Triennale in Informatica

Programmazione Concorrente A.A. 2022-2023 Lezione 18 - 1/12/2022

Ancora monitors e condition variables in Java.


Docente: Prof. Michele Boreale (Disia)

1 Lettori-scrittori con monitor in Java


In Figura 1 viene presentata una classe monitor per il problema dei lettori-scrittori. Si noti che,
data l’assenza di condition variables, la gestione delle segnalazioni risulta notevolmente meno sofi-
sticata che nella soluzione con i monitor classici. In particolare, non risulta qui possibile segnalare
distintamente i lettori e gli scrittori. Come conseguenza, questo algoritmo di sicuro non garantisce
la SF per gli scrittori: infatti nuovi lettori possono continuamente arrivare durante una fase di
lettura, ed essere ammessi al DB, a prescindere dal fatto che ci siano richieste di scrittura pendenti.
Esiste dunque una computazione nella quale sempre almeno un lettore è in fase di lettura (readers
≥ 1 sempre): in una tale computazione, dunque, nessuna EndRead eseguirà mai la notifyAll().
Esercizio 1. Dire se il monitor proposto garantisce, sotto assunzione di fairness, almeno la SF per i
lettori. In caso contrario, delineare una computazione che porta alla starvation di un lettore.

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 }

Figura 1: Listing della classe RWMonitor.java

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.

ˆ mylock.newCondition(). Restituisce una nuova istanza dell’interfaccia Condition, cioè una


condition variable, che è associata al lock mylock (v. oltre).

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.

4 Fairness nei monitor Java


E’ possibile creare istanze di RentrantLock che prevedono un ordinamento FIFO dei thread in attesa
del lock. E’ sufficiente invocare la versione con un argomento booleano del costruttore
new ReentrantLock(true)
1 La gestione del lock dovrà anche tenere anche conto di possibili eccezioni che possono essere sollevate durante

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 }

Figura 2: Listing della classe TestRWMonitor.java


1 import java . util . Random;
2
3 class MyMonitorWrong{ /* Monitor class */
4
5 private Random rand = new Random();
6 private int x = 0; /* variabili incapsulate : x, y */
7 private int y = 0;
8 private boolean done = false; /* vale true se una tra x oppure y è stata già decrementata */
9
10
11 synchronized void inc () {
12 if (rand. nextInt (10)>5){
13 x++;
14 System.out.println (”x incremented.”);
15 }
16 else {
17 y++;
18 System.out.println (”y incremented.”);
19 }
20 notify ();
21 }
22
23
24 synchronized void decx() {
25 while (( x<=0) && !done)
26 try { System.out.println (”Waiting to decrement x... ”);
27 wait();
28 } catch (InterruptedException e) {}
29 if (! done){
30 x == ;
31 done=true;
32 System.out.println (”x decremented.”);
33 notify ();
34 }
35 }
36
37 synchronized void decy() {
38 while (( y<=0) && !done)
39 try { System.out.println (”Waiting to decrement y... ”);
40 wait();
41 } catch (InterruptedException e) {}
42 if (! done){
43 y == ;
44 done=true;
45 System.out.println (”y decremented.”);
46 notify ();
47 }
48 }
49
50
51 synchronized int getx() {
52 return x;
53 }
54
55
56 synchronized int gety() {
57 return y;
58 }
59 }

Figura 3: Listing della classe MyMonitorWrong.java.


8
1 import java . util . Random;
2
3 class TestMyMonitorWrong {
4
5 Random rand = new Random();
6 MyMonitorWrong M = new MyMonitorWrong();
7
8 class DecrementerX extends Thread {
9 public void run(){
10 M.decx();
11 }
12 }
13
14 class DecrementerY extends Thread {
15 public void run(){
16 M.decy();
17 }
18 }
19
20 class Incrementer extends Thread {
21 public void run(){
22 try {
23 Thread.sleep (rand. nextInt (2));
24 }
25 catch (InterruptedException e){};
26 M.inc();
27 }
28 }
29
30
31
32 TestMyMonitorWrong() { \\ costruttore classe principale
33 DecrementerX Dx = new DecrementerX();
34 DecrementerY Dy = new DecrementerY();
35 Incrementer I = new Incrementer();
36
37 System.out.println (”Starting processes ... ”);
38
39 Dx.start ();
40 Dy.start ();
41 I . start ();
42
43
44 try {
45 Dx.join (); Dy.join (); I . join ();
46 }
47 catch (InterruptedException e) {};
48 System.out.println (”Program terminated. Final values : x=” + M.getx() + ”, y=” + M.gety()+”.”);
49 }
50
51
52 public static void main(String[] args ) {
53 new TestMyMonitorWrong();
54 }
55 }

Figura 4: Listing della classe TestMyMonitorWrong.java.

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

Figura 5: Listing della classe MonitorWithConditionScheme.java (schema).

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 }

Figura 6: Listing della classe PCMonitorCond.java.

11
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 19 - 6/12/2022

Correttezza dei lettori-scrittori


Docente: Prof. Michele Boreale (Disia)

Algorithm: Readers and writers with a monitor

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.

Il seguente è un invariante, che mostra che le variabili readers e writers del


programma hanno il significato inteso. Tralasciamo la prova, che è molto semplice.

Lemma 1. I seguenti sono invarianti del programma: R = readers e W = writers.

Vediamo prima le proprietà di Safety, ovvero le regole di esclusione.

Teorema 2 (Safety). I seguenti sono invarianti del programma.

(a) (R > 0) ⇒ (W = 0).

(b) W ≤ 1.

(c) (W = 1) ⇒ (R = 0).

Prova È conveniente considerare la formula completa (a) ∧ (b) ∧ (c) e dimostrarla


per induzione computazionale. Il caso base è ovvio. Per il passo induttivo, suppo-
niamo che nello stato di partenza valga (a) ∧ (b) ∧ (c), e dimostriamo separatamente
che nello stato d’arrivo vale ciascuno di (a), (b), (c). Ci concentriamo su (a), essendo
la prova per (b), (c) molto simile. Trattandosi di una implicazione, come al solito è
sufficiente considerare due possibilità.

1. Comandi che possono modificare in vera la premessa R > 0 (incrementare


readers).

ˆ Esecuzione di una StartRead che, superando il controllo dell’if , incrementa


readers = R. Per il codice, questo è possibile solo se writers = W = 0. La
successiva signalC(OktoRead), e l’eventuale cascade unblocking di lettori
che ne risulta, non modificano W , che quindi rimane 0 anche nello stato
d’arrivo. Dunque anche la conclusione è vera nello stato d’arrivo.

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.

2. Comandi che possono modificare in falsa la conclusione W = 0 (incrementare


writers).

ˆ Esecuzione di una StartWrite che, superando il controllo dell’if , incremen-


ta writers = W . Per il codice, questo è possibile solo se readers = R = 0.
Dunque anche la premessa è falsa nello stato d’arrivo.
ˆ Esecuzione di una EndRead, che chiama signalC(OktoWrite), sbloccando
uno scrittore, che completa la sua StartWrite, incrementando W . Per il
codice, questo è possibile solo se, a seguito del decremento di readers,
readers = R = 0. Dunque anche la premessa è falsa nello stato d’arrivo.

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.

Lemma 3. I seguenti sono invarianti del programma.

(a) ¬empty(OktoRead) ⇒ (W 6= 0 ∨ ¬empty(OktoW rite)).

(b) ¬empty(OktoW rite) ⇒ (W 6= 0 ∨ R 6= 0).

Prova Si dimostrano per induzione computazionale. Vediamo in dettaglio solo il


caso (a), essendo il caso (b) molto simile. Il caso base è ovvio. Per il passo induttivo,
trattandosi di una implicazione, al solito è sufficiente considerare due possibilità.

1. Comandi che possono modificare in vera la premessa. L’unica possibilità è


l’invocazione di una StartRead, che sospenda l’invocante sulla waitC. Per il co-
dice, questo è possibile solo se writers = W 6= 0 oppure ¬empty(OktoW rite).
Dunque anche la conclusione è vera nello stato d’arrivo.

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.

Teorema 4 (Starvation Freedom). L’algoritmo Readers and writers with a monitor


soddisfa la SF.
Prova Questa è una conseguenza del lemma precedente. Verifichiamo solo il caso
della SF per i lettori, lasciando il caso degli scrittori per esercizio.
Per assurdo, supponiamo che la SF per i lettori non valga. L’unico modo per
cui un lettore possa rimanere starved in una computazione, è che esso rimanga per
sempre bloccato sulla condition variable OktoRead. Essendo questa una coda FIFO,
questo implica che da un certo stato in avanti della computazione, non verranno più
eseguite signalC su questa condition variable. Per cui, da un certo stato in avanti,
avremo una computazione che soddisfa questa formula LTL

¬empty(OktoRead) ∧ 2¬(signalC(OktoRead)) (1)

(dove con “¬(signalC(OktoRead))” intendiamo che nessuna signalC viene effet-


tuata su OktoRead). Applicando il Lemma precedente, parte (a), alla formula
¬empty(OktoRead), otteniamo che nel primo stato di tale computazione vale

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

Ora, se vale W 6= 0, si applica il ragionamento precedente, che come visto conduce


ad un assurdo. Se invece R 6= 0, vuol dire che è in corso una fase lettura. Inoltre,
essendo ¬(empty(OktoW rite)), nuove richieste di lettura da questo momento (stato)
andranno in coda. Per fairness, prima o poi l’ultimo lettore di questa fase eseguirà
una EndRead, che avrà come conseguenza quella di svegliare uno scrittore. Come
conseguenza, arriveremo in uno stato in cui W 6= 0: da questo stato, potremo
riapplicare il ragionamento precedente.

Esercizio 1 (problema Buy Milk). In un appartamento condiviso da due persone,


quando il latte in frigo è terminato, va ricomprato. Una sola persona alla volta può
accedere al frigo. La prima persona che, aprendo il frigo, si accorge che il latte è
terminato, è quella che lo va a comprare e poi lo rimette in frigo. Non è ammesso
lasciare la porta del frigo aperta mentre si va a comprare il latte. Scrivere una solu-
zione per risolvere il problema in modo tale che alla fine ci sia in frigo una e una sola
confezione di latte. Modellare le persone come processi e il frigo come una sezione
critica guardata da un semaforo. Scrivere una versione del programma prima nel
linguaggio teorico usato nel corso, poi in Java e infine in Promela. Formalizzare le
proprietà di interesse in LTL. Quindi procedere alla verifica della versione in Pro-
mela mediante il model checker SPIN-Erigone. Si veda anche il materiale messo a
disposizione sulla pagina Moodle.

Esercizio 2. Considerare una estensione del problema dell’esercizio precedente in


cui è presente anche una terza persona, che si limita a prelevare il latte dal frigo, se
c’è, per berlo, altrimenti aspetta. Inoltre, le operazioni sono inserite all’interno di un
ciclo anziché avvenire una sola volta. Risolvere il problema in Java e Promela tramite
semafori (è ammessa l’attesa attiva). Sottoporre la versione Promela a verifica.
Infine, considerare una soluzione con monitor che eviti ogni forma di attesa
attiva.

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

Esercizi sui monitors


Docente: Prof. Michele Boreale (Disia)

1 Un problema di gestione di risorse


Enunciamo un semplice problema di gestione delle risorse in un sistema operativo.
Ci prefiggiamo di risolvere il problema in tre contesti differenti: monitor classici
IRR, monitor Java (versione base) e monitor Java con condition variables. Ciascuna
proposta di soluzione dovrebbe rispettare almeno le proprietà di Safety e DF che
enunceremo. È auspicabile che anche la SF venga rispettata, ma non poniamo questa
richiesta come come vincolante.
Un server gestisce l’allocazione di N > 1 unità identiche di una certa risorsa
(es. partizioni della memoria), tra un certo insieme di processi. Ogni processo può
richiedere o 1 o 2 unità della risorsa al server. Una risorsa può risultare assegnata
in ogni momento ad al più un processo. Il server concede le unità richieste se di-
sponibili, altrimenti mette il processo richiedente in attesa finché le risorse richieste
non si rendono disponibili. Acquisite le risorse e terminato il proprio compito, il pro-
cesso richiedente rilascia le risorse, comunicandolo al server. Programmare il server
mediante un monitor che offre due operazioni, request(k) e release(k), dove k=1,2. Il
monitor dichiarerà una variabile intera

R = n. di risorse attualmente disponibili.

Ulteriori variabili di sincronizzazione (es. condition variables) sono naturalmente


ammesse. Processi in attesa di 1 unità hanno precedenza su quelli in attesa di 2
unità.
La soluzione proposta dovrà rispettare la Safety: 0 ≤ R ≤ N dovrà essere un
invariante; inoltre, indicando con A il numero di risorse complessivamente assegnate
ai vari processi, dovrà valere A = N − R. Questo vuol dire che non vengono mai
concesse più risorse di quelle disponibili né se ne creano di nuove, e che c’è un
bilanciamento tra le risorse concesse e quelle ancora disponibili. Inoltre, la soluzione
dovrà rispettare la la seguente forma di assenza di deadlock: non si arriva mai ad

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.

2 Soluzione con monitor classici


Useremo qui due condition variables, OktoTake[k] per k=1,2, su cui porre in attesa i
processi in attesa di 1 e 2 unità, rispettivamente. Assumeremo politica di resumption
IRR. Una soluzione possibile è la seguente.
Algorithm: Allocazione di risorse

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.

Esercizio 1. Provare gli invarianti (1) e (2) per induzione computazionale.

3 Soluzione con monitor Java in versione base


La soluzione in Java con i monitor built-in si ottiene abbastanza facilmente a partire
dalla soluzione con i monitor classici, semplificando al massimo le politiche di segna-
lazione (Figura 1). Infatti, non potendo disporre di due condition variables distinte
per i due tipi di processi da ritardare, in Java con i monitor built-in tali politiche
si riducono a questo: quando esiste almeno una risorsa disponibile, sbloccare tutti
i processi nel waiting set, che siano di tipo 1 o 2, fuori dal monitor, tramite noti-
fyAll(). Come al solito, la wait() è collocata all’interno di un ciclo di controllo, come
conseguenza della politica S&C.
Per garantire la precedenza ai processi di tipo 1 useremo una variabile contatore,
che ci dice quanti processi di tipo 1 hanno richiesto la risorsa senza ancora ottenerla:
se una richiesta di tipo 2 arriva quando ce n’è una di tipo 1 pendente, la richiesta
di tipo 2 dovrà essere ritardata. Si noti che i processi che hanno fatto richiesta
di risorse possono trovarsi, nel corso dell’esecuzione del programma, sia fuori dal
monitor in attesa di rientrarvi, che sospesi dentro il wait set del monitor. Inseriamo
nelle operazioni anche dei comandi di stampa, per rendere più animata l’esecuzione
del programma. Fissiamo N=5.
Usando la classe TestResourceAllMonitor.java (v. codice alla pagina Moodle), te-
stiamo il monitor: si tratta di un programma concorrente con due thread di tipo 1
(identificatori numerici 1,2) e tre di tipo 2 (identificatori numerici 3,4,5). Nel codice
(metodo run()) dei threads, i cicli loop forever sono stati rimpiazzati da cicli for; so-
no anche stati inseriti dei ritardi casuali. Una tipica esecuzione di tale programma
produce il seguente output.

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 }

Figura 1: Listing della classe ResourceAllocationMonitor.java.

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.

4 Soluzione con monitor Java e condition


variables
In questa versione (Figura 2) si fa uso di due condition variable distinte, rispettiva-
mente per i processi di tipo 1 e 2 in attesa. La politica di segnalazione ora ricalca
esattamente quella della soluzione con monitor classici. Si noti tuttavia che la se-
mantica di resumption dei monitor Java rimane S&C. Si noti anche che dichiariamo
OktoTake come un array di due Condition. Tale array viene inizializzato tramite
due istanze distinte di Condition, ottenute invocando due volte lock.newCondition().
La variabile intera WaitingOktoTake1 funge da contatore per i processi sospesi su
OktoTake[0]: tale variabile viene impiegata, al termine dell’esecuzione di releaseRes,
solamente per capire se la OktoTake[0] è vuota o meno.
Per la SF, valgono le stesse considerazioni del caso precedente. Si ricordi che
possiamo garantire la SF almeno dei processi di tipo 1 impiegando dei lock fair, che
prevedono una disciplina FIFO sia dei processi in attesa di entrare nel monitor, che
di quelli sospesi sulle condition variables associate al lock. Allo scopo, è sufficiente
rimpiazzare la dichiariazione di lock nel codice con la seguente

ReentrantLock lock = new ReentrantLock(true)

Questo potrebbe avere un costo in termini di efficienza.

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 }

Figura 2: Listing della classe ResourceMonitorWithConditions.java.

8
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 21 - 14/12/2022

Modelli a scambio di messaggi.


Canali e pipeline
Docente: Prof. Michele Boreale (Disia)

1 Modelli message passing


Le primitive di sincronizzazione che abbiamo analizzato fino ad ora si basano sul-
l’assunto che i processi possano fare affidamento su una memoria comune: si parla
anche di modello shared memory. In alcuni contesti, tuttavia, questo assunto non
corrisponde alla realtà. Nel caso di multicomputer o sistemi distribuiti, per esempio,
è più naturale adottare un modello per la programmazione concorrente basato su
primitive di scambio di messaggi: si parla di modelli message passing.
Il modello di scambio di messaggi che descriveremo è basato sulle seguenti astra-
zioni.
ˆ Non ci sono fallimenti. Questo in pratica vuol dire assumere che, ad un livel-
lo non visibile al programmatore, un qualche meccanismo di recovery interviene
per gestire e risolvere le eventuali fallite consegne dei messaggi.
ˆ Non c’è tempo assoluto. Si astrae dal tempo di consegna dei messaggi,
ovvero, si assume la consegna istantanea dei messaggi.
ˆ Conta solo l’ordinamento dei messaggi. I messaggi vengono ricevuti nello
stesso ordine in cui sono stati inviati. Questo ordinamento è l’unico aspetto
temporale su cui possiamo ragionare nel modello.
Il mittente del messaggio viene detto sender, il destinatario, receiver. Le pri-
mitive di comunicazione che possono essere introdotte, a partire da queste assun-
zioni, sono molteplici. Conviene introdurre una classificazione basata sulle seguenti
dimensioni.
ˆ Grado di cooperazione tra sender e receiver. La cooperazione può essere
sincrona o asincrona. Nel caso sincrono, l’invio e la ricezione del messaggio

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.

ˆ Indirizzamento. Il sender è sempre tenuto a conoscere l’indirizzo (o il nome)


del destinatario; se anche il receiver conosce l’indirizzo (o nome) del sender,
allora siamo nel caso simmetrico. Questa modalità permette di fissare statica-
mente i partecipanti ad una comunicazione e il loro ruolo, e dunque di fissare
staticamente le risorse necessarie per la comunicazione. Può risultare quindi in
un codice compilato più efficiente. Linguisticamente, una primitiva che segue
l’indirizzamento simmetrico è quella dei canali, dichiarati come entità condivise
tra due o più processi, sui cui è possibile chiamare le primitive send e receive.
Non sempre, però, il receiver può conoscere staticamente l’indirizzo o il nome
del/dei possibili sender: siamo allora nel caso asimmetrico. Si pensi ad una
architettura di tipo client-server, in cui il server non è tenuto a conoscere i
riferimenti dei (potenzialmente, tanti) client che possono richiedere il suo ser-
vizio. Una primitiva linguistica che segue lo schema asimmetrico è quella di
rendez-vous.

ˆ 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

ch ⇐ v \\ send v to ch ch ⇒ x \\ receive a value from ch


dove v è un’espressione che valuta ad un valore di tipo type, e x è una variabile locale
del receiver di tipo type. La semantica di questi comandi è la seguente: in qualsiasi
stato in cui un processo è pronto ad eseguire una receive su ch e un altro processo
è pronto ad eseguire una corrispondente send su ch, i due comandi possono essere
eseguiti simultaneamente, portando ad uno stato in cui il valore di v è stato copiato
nella variabile locale x del receiver. Una send o una receive senza corrispondenza
bloccano il processo che cerchi di eseguirle, fino al momento in cui tale corrispondenza
si realizza. Questo tipo di canali sono a volte noti come canali alla CSP, o alla Ada,
dal nome di due dei linguaggi che li supportano [1].
Il prossimo esempio illustra una soluzione al problema produttore-consumatore
tramite canali sincroni. Si ricordi che, in pratica, questa soluzione è accettabile in
termini di efficienza solo se il produttore e il consumatore procedono quasi sempre
alla stessa velocità.

Algorithm: Producer-consumer (channels)


channel of integer ch
producer consumer
integer x=0 integer y=0
loop forever loop forever
p1: x ← produce q1: ch ⇒ y
p2: ch ⇐ x q2: consume(y)

Il diagramma stati-transizioni di questo programma è il seguente. Qui supponia-


mo, per contenere al minimo la dimensione del diagramma, che il comando produce
produca sempre lo stesso dato d, la cui concreta natura è irrilevante.

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.

3 Pipelines tramite canali sincroni


I canali possono essere usati per connettere in cascata un insieme di processi, ciascuno
dei quali assolve ad un compito specifico in una certa elaborazione, formando cosı̀ una
sorta di catena di montaggio, in Inglese pipeline. Per fare una analogia, si consideri

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.

inC - compress pipe - outC -


output

Il programma che segue rappresenta una soluzione al problema. Viene mostrato


solo il codice dei processi compress e output. Si noti che il processo compress mantiene,
a regime, due caratteri in due variabili: l’ultimo carattere letto è in c, il precedente
in previous. Confrontando previous e c, si è in grado di capire se incrementare il n. di
caratteri consecutivi uguali, n, oppure se si è arrivati alla fine di un run:

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

active proctype A() {


byte buymilkA = 0;
do
:: true ->
atomic {fridge > 0; fridge--}; /*wait(fridge) */
if
:: (milk==0) & (note==0) ->
note=1;
buymilkA = 1;
:: else -> skip
fi;
fridge++;
/*signal(fridge) */
if
:: buymilkA>0 ->
/* go & buy milk */
atomic {fridge > 0; fridge--}; /*wait(fridge) */
milk++;
note =0;
buymilkA = 0;
if
:: (nw > 0) -> waitToDrink++; /* wakes up drinker and passes the baton */
:: else -> fridge++; /*signal(fridge) */
fi;
:: else -> skip;
fi;
od;
}

active proctype B() {


byte buymilkB = 0;
do
:: true ->
atomic {fridge > 0; fridge--}; /*wait(fridge) */
if
:: (milk==0) & (note==0) ->
note=1;
buymilkB = 1;
:: else -> skip
fi;
fridge++;
if
:: buymilkB>0 ->
/* go & buy milk */
atomic {fridge > 0; fridge--}; /*wait(fridge) */
milk++;
note =0;
buymilkB = 0;
if
:: (nw > 0) -> waitToDrink++; /* wakes up drinker and passes the baton */
:: else -> fridge++; /*signal(fridge) */
fi;
:: else -> skip;
fi;
od;
}

active proctype C() {


do
:: true ->
atomic {fridge > 0; fridge--}; /*wait(fridge) */
nw++;
wC=1;
if
:: (milk==0) -> fridge++;/*signal(fridge)*/
atomic {waitToDrink > 0; waitToDrink--};
/*wait(waitToDrink)*/
:: else -> skip
fi;
nw--;
wC=0;
milk--;
fridge++; /*signal(fridge) */
od;
}

active proctype D() {


do
:: true ->
atomic {fridge > 0; fridge--}; /*wait(fridge) */
nw++;
if
:: (milk==0) -> fridge++; /*signal(fridge)*/
atomic {waitToDrink > 0; waitToDrink--};
/*wait(waitToDrink)*/
:: else -> skip
fi;
nw--;
milk--;
fridge++; /*signal(fridge) */
od;
}
Università di Firenze - CdS Triennale in Informatica
Programmazione Concorrente A.A. 2022-2023 Lezione 22 - 15/12/2022

Moltiplicazione tra matrici. Input selettivi.


Canali in Java.
Docente: Prof. Michele Boreale (Disia)

1 Moltiplicazione tra matrici


Mediante i canali è possibile anche costruire strutture più complesse della semplice
pipeline lineare. Consideriamo il caso della moltiplicazione tra due matrici quadrate
di lato N : C = A × B. L’algoritmo classico comporta O(N 3 ) operazioni aritmetiche
su scalari, tra moltiplicazioni e somme. Una programmazione strettamente sequen-
ziale di tale algoritmo richiede quindi un tempo di esecuzione O(N 3 ) per calcolare la
matrice risultato C = A × B. Possiamo far scendere il tempo d’esecuzione a O(N )
se disponiamo di N 2 processori, ciascuno dei quali è responsabile dell’utilizzo di un
elemento della matrice A. Per capire come, consideriamo il caso di matrici di lato
N = 3. Ad esempio,
     
1 2 3 1 0 2 4 2 6
4 5 6 × 0 1 2 = 10 5 18 .
7 8 9 1 0 0 16 8 30

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

Ciascun elemento di C, per esempio c23 , è ottenuto dunque calcolando successiva-


mente le somme parziali (scorrendo la riga di A da destra a sinistra): 6 · 0, 6 · 0 + 5 · 2
e (6 · 0 + 5 · 2) + 4 · 2. Concentrandoci su come vengono impiegati i singoli elementi di
A, vediamo che è possibile organizzare il calcolo complessivo come un array bidimen-
sionale di processi, uno per ciascun elemento di A, in cui, per esempio, il processo
Multiplier responsabile dell’uso di a22 = 5 deve, per j = 1, 2, 3:

1. ricevere un elemento b2j della riga 2 di B dal processo 2

2. ricevere una somma parziale dal processo 6

3. calcolare la nuova somma parziale come 5 · b2j + somma parziale

4. propagare il valore b2j verso il processo 8, in modo che esso lo possa utilizzare

5. inviare la nuova somma parziale al processo 4.

L’array completo, per l’esempio considerato, è raffigurato di seguito.

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

2 Input (receive) selettivi


Abbiamo detto che il comando receive sincrono ch ⇒ x è potenzialmente bloccante (in
modo temporaneo). I linguaggi che supportano i canali, come CSP, Ada e Promela,
offrono anche una forma più flessibile di receive, detta input selettivo. Si consideri il
programma del processo Multiplier. Vediamo che il completamento della receive su
Nord precede sempre il completamento della receive su East. Essendo questi canali
sincroni, ne consegue che se il processo a Nord ritarda a fare la send (per motivi
legati alla sua velocità di esecuzione o altro) mentre quello a East è già pronto, il
processo a East verrà inutilmente ritardato. Sarebbe dunque preferibile completare
la prima receive che diventa pronta e poi l’altra. E’ possibile fare questo con gli input
selettivi. Il comando ha la seguente sintassi, che presenta due alternative, delimitate
dalle keyword either ... or...:

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

3 Filosofi a cena con canali sincroni


Un vantaggio della modalità sincrona rispetto a quella asincrona è che, al comple-
tamento delle primitive di send/receive, ciascun partner della comunicazione sa con
precisione in che stato si trova l’altro. Per continuare l’analogia dei lavapiatti, quan-
do il primo passa un piatto nelle mani del secondo, il primo sa esattamente cosa
si appresta a fare il secondo. Diverso sarebbe se tra i due ci fosse un tavolo su
cui il primo deposita i piatti, mentre il secondo si sta occupando di altre faccende.
Questo aspetto può essere sfruttato per risolvere con relativa semplicità problemi di
sincronizzazione.
Discutiamo ora una soluzione dei filosofi a cena in cui sia i filosofi che le forchette
sono processi, che comunicano mediante canali sincroni. In questa soluzione, la
forchetta i-ma è disponibile se e solo se essa offre una send sul canale forks[i]. I
filosofi a sinistra e a destra della forchetta si contendono la risorsa: il primo dei due
che completa la propria receive su forks[i] avrà acquisito la risorsa e potrà usarla.
Dopo aver completato la propria send, la forchetta attende il rilascio da parte del
filosofo: come si vede dal codice, ciò equivale ad attendere di completare una receive,
sempre sul canale forks[i], col filosofo che ha acquisito la forchetta.
Questo è il caso di un canale su cui comunicano (ciclicamente) tre processi, uno
cercando di fare una send, due cercando di fare una receive. Se ambedue le receive
sono pronte, una sola viene scelta in maniera nondeterministica. Viene riportato qui

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.

Algorithm: Dining philosophers with channels


channel of boolean forks[5]
philosopher(i) fork(i)
boolean dummy boolean dummy
loop forever loop forever
p1: think q1: forks[i] ⇐ true
p2: forks[i] ⇒ dummy q2: forks[i] ⇒ dummy
p3: forks[i+1] ⇒ dummy q3:
p4: eat q4:
p5: forks[i] ⇐ true q5:
p6: forks[i+1] ⇐ true q6:

Esercizio 1. Rispondere alla seguente domanda: è possibile utilizzare canali asincroni


nella soluzione vista sopra, e se no, perché.

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

ˆ SynchronousQueue<T>: canali sincroni alla CSP che trasportano messaggi di


tipo T;

ˆ ArrayBlockingQueue<T>: canali asincroni che trasportano messaggi di tipo T.


La capacità viene passata come argomento del costruttore.

Per ambedue le classi, la send corrisponde a put(), la receive a take(). Come al


solito, dal momento che questi metodi sono potenzialmente bloccanti per il thread
invocante, la loro invocazione va collocata all’interno di un blocco try-catch. A titolo
esemplificativo, presentiamo di seguito (Figure 1 e 2) una soluzione Java al problema
di Conway. Il codice ricalca fedelmente la soluzione vista in precedenza. L’unica
differenza è che qui si assume che la sequenza in ingresso sia finita e specificata nel

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 }

Figura 1: Listing della classe Conway.java (prima parte).


1 class Generator extends Thread {
2 public void run() {
3 try {
4 for (int I = 0; I < myinputsequence.length; I ++)
5 inC. put(myinputsequence[I]);
6 } catch (InterruptedException e) {}
7 }
8 }
9
10 class Sink extends Thread {
11 public void run() {
12 System.out.println (’ \n’); // stampa newline solo per motivi tipografici
13 try {
14 for (;;)
15 System.out.println (outC.take());
16 } catch (InterruptedException e) {}
17 }
18 }
19
20
21 Conway() {
22 new Generator().start ();
23 new Compress().start();
24 new Output().start();
25 new Sink().start ();
26
27 }
28
29 public static void main(String[] args ) {
30 new Conway();
31 }
32 }

Figura 2: Listing della classe Conway.java (seconda parte).

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.

Esercizio 2. Programmare in Java la soluzione al problema dei filosofi a cena con


canali sincroni.

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 }

Figura 3: Listing della classe ProducerConsumerAsyncChannel.java.


10
Università di Firenze - CdS Triennale in Informatica

Programmazione Concorrente A.A. 2022-2023 Lezione 23 - 20/12/2022

Rendezvous. Confronto tra modelli.


Docente: Prof. Michele Boreale (Disia)

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 ;

ˆ la entry è il nome di un servizio (service) offerto dal server.


Sintatticamente, la sincronizzazione rendezvous segue il seguente schema.
Algorithm: Rendezvous (schema)

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:

spawn P(v1, v2,...)

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

Algorithm: Client-server squaring, multithreaded


process squaring(integer n, integer & ret)
ret← n*n
client1 client2 server
integer x, result integer x, result integer p, r
loop forever loop forever loop forever
p1: x ← produce q1: x ← produce r1: do something
p2: server.square(x, result) q2: server.square(x, result) r2: accept square(p, r)
p3: write(result) q3: write(result) r3: spawn squaring(p,r)

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.

Algorithm: Client-server with squaring and cubing


client1 client2 server
integer x, result integer x, result integer p, r
loop forever loop forever loop forever
either
p1: x ← produce q1: x ← produce r1: accept square(p, r)
p2: server.square(x, result) q2: server.cube(x, result) r2: r ← p*p
p3: write(result) q3: write(result) r3:
or
p4: q4: r4: accept cube(p, r)
p5: q5: r5: r ← p*p*p

2 Rendezvous tramite canali sincroni


Essendo il rendezvous scomponibile in due comunicazioni sincrone consecutive, è abba-
stanza intuitivo che esso possa essere compilato in un modello con soli canali sincroni. Il
meccanismo di ritorno della risposta dal server al client, che nel rendezvous è trasparente
al programmatore, nel modello a canali deve essere però adeguatamente dettagliato. La
soluzione più immediata è che la risposta venga fornita al client attraverso un canale, che
chiameremo canale di ritorno (return channel ). Tuttavia, il canale di ritorno non può
essere stato dichiarato staticamente tra il server e il client: infatti il server non conosce
staticamente tutti i suoi potenziali client. La cosa più semplice, allora, è che il canale di
ritorno venga passato dal client al server al momento dell’invocazione del servizio, insieme
ai parametri attuali. Questo presuppone l’esistenza di un canale accept.service, dichiarato
staticamente, da cui solo il server riceve, e su cui tutti tutti i client interessati possono in-
viare. Tale canale trasporta coppie (p, retCh ), dove p rappresenta l’argomento del servizio
e retCh è il canale di ritorno. Dunque accept.service avrà tipo

channel of (integer , channel of integer )

4
L’architettura client-server con rendezvous viene quindi implementata tramite il se-
guente schema.

Algorithm: Rendezvous with synchronous channels (schema)


channel of (integer, channel of integer) accept.service
client server
integer parm, result integer p
channel of integer retCh channel of integer r
loop forever loop forever
p1: parm ← . . . q1: do something
p2: accept.service ⇐ (parm, retCh) q2: accept.service ⇒ (p, r)
p3: retCh ⇒ result q3: r ⇐ do the service(p)
p4: use(result) q4:

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.

ˆ Scenario distribuito. Il client e il server risiedono fisicamente su macchine diverse,


che sono nodi di una rete TCP/IP. In tal caso il client e il server possono comunicare
attraverso oggetti chiamati socket, mediante i quali si può stabilire una connessione
affidabile tra i due. Questo scenario può essere programmato affidandosi alle classi
del package java.net. Tuttavia, come detto in precedenza, noi non ci occuperemo di
sistemi distribuiti, quindi d’ora in avanti ignoreremo questo scenario.

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;

Param(Integer myarg, SynchronousQueue<Integer> myretCh) {


this . arg = myarg;
this . retCh = myretCh;
}
}
Il codice che segue (Figura 1) ricalca per il resto fedelmente lo schema visto in prece-
denza, Rendezvous with synchronous channels, con l’unica differenza che qui prevediamo due
client invece che uno. Viene offerto un servizio di squaring. Si veda anche il codice messo
a disposizione sulla pagina Moodle del corso.
Possiamo programmare con facilità anche una versione con server multithreaded. Si
noti che spawn P(v) corrisponde in Java essenzialmente a
new P(v).start ();
dove P è il costruttore di una classe che estende Thread. Mostriamo solo le modifiche relative
alla classe server del programma, che sono del tutto trasparenti ai client, che rimangono
invariati. La classe SpawnedThread è quella dei thread che vengono dinamicamente generati
dal server per servire nuove richieste.

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 }

Figura 1: Listing della classe ClientServerRV.java.


class SpawnedThread extends Thread { // thread generati dinamicamente dal server
Param received;

SpawnedThread(Param par) {
this . received = par;
}

public void run() {


try {
System.out.println (”Server : elaborating answer for request ” + received. arg + ” ...”);
Thread.sleep (rand. nextInt (500));
(received . retCh). put( received . arg * received . arg);
} catch (InterruptedException e) {}
}
}

class Server extends Thread {


public void run() {
try {
Param received;
for (;;) {
received = accept.take();
new SpawnedThread(received).start();
// genera thread per gestire richiesta
}
}
catch (InterruptedException e) {}
}
}

Esercizio 1. Riflettere su una possibile implementazione in Java del comando di input


(receive) selettivo either... or....

4 Confronto tra modelli: shared memory vs. message passing


Con l’ultimo argomento del corso chiudiamo il cerchio, nel percorso fin qui compiuto.
Ciascuno dei due modelli, shared memory e message passing, che abbiamo studiato ha
ovviamente i suoi pregi e i suoi difetti, quando calato in scenari applicativi concreti.
Tuttavia, dovrebbe essere abbastanza chiaro che i due modelli hanno la stessa espressi-
vità, nel senso che la classe di processi o funzioni che si possono programmare in ciascuno
di essi, è la stessa. Questo fatto si può enunciare e dimostrare in maniera rigorosa. In
sostanza, si tratta di far vedere che ogni programma scritto in un modello può essere tra-
dotto nell’altro, ottenendo un programma semanticamente equivalente. Qui ci limiteremo
ad esaminare due casi specifici, semplici ma istruttivi.

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.

4.2 Dai semafori ai canali sincroni


E’ possibile definire una traduzione dai monitor ai canali sincroni. E’ tuttavia più istruttivo
trattare un caso più semplice, quello dei semafori. A partire dai semafori possono poi essere
implementati anche i monitor.

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.

Algorithm: Weak semaphore with synchronous channels

channel of boolean wait, send, go


process sem
boolean dummy integer S←K, waiting ←0
boolean dummy
loop forever loop forever
either
p1: wait ⇐ true q1: wait ⇒ dummy
p2: go ⇒ dummy q2: if S>0
p3: CS q3: S←S-1
p4: signal ⇐ true q4: go ⇐ true
p5: q5: else waiting ← waiting +1
or
p6: q6: signal ⇒ dummy
p7: q7: if waiting>0
p8: q8: waiting←waiting-1
p9: q9: go ⇐ true
p10: q10: else S ← S +1

Esercizio 3. Programmare in Java i due algoritmi visti sopra.

10
Università di Firenze - CdS Triennale in Informatica

Programmazione Concorrente A.A. 2022-2023 Lezione 24 - 20/12/2022

Esercizi sul message-passing


Docente: Prof. Michele Boreale (Disia)

1 Esercizi sui canali


Esercizio 1. Un processo genera una sequenza di interi x1 , x2 , ..., un altro genera la sequenza
y1 , y2 , .... Programmare un sistema che calcola la sequenza di risultati 2x1 + 3y1 , 2x2 +
3y2 , ...., 2xi + 3yi , .... Il sistema deve essere composto, oltre che dai due generatori, da un
processo 2Times che moltiplica ciascun intero ricevuto per 2, da un processo 3Times che
moltiplica ciascun intero per 3, e da un processo Sum che riceve ripetutamente due interi
e ne calcola la somma. I processi devono essere opportunamente connessi tramite canali
sincroni. La sequenza dei risultati va inviata, un intero alla volta, su un canale out.

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

2Times 3Times Sum


integer x integer y integer w, z
loop forever loop forever loop forever
either
p1: in2 ⇒ x q1: in3 ⇒ y r1: out2 ⇒ w
p2: out2 ⇐ 2∗x q2: out3 ⇐ 3∗y r2: out3 ⇒ z
or
p3: q3: r3: out3 ⇒ z
p4: q4: r4: out2 ⇒ w
p5: q5: r5: out ⇐ w+z

Esercizio 2 (Crivello di Eratostene). Il crivello di Eratostene è un algoritmo per la genera-


zione dei numeri primi. Possiamo programmare un algoritmo concorrente che implementa
Eratostene, secondo uno schema in cui una pipeline viene creata dinamicamente. Il sistema
consiste di una insieme di processi G, F2 , F3 , ..., Fp , .... connessi a cascata, dove G genera la
sequenza degli interi 2,3,4,... mentre il generico Fp , per p primo, filtra via (elimina) dalla
sequenza tutti i multipli di p (si osservi la figura sottostante).

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

process F(integer p, channel of integer in)


integer n, boolean last ←true
channel of integer next

write(p)
loop forever
in ⇒ n
if (n mod p) ̸= 0
if ¬ last
next ⇐ n
else last←false
spawn F(n,next)

channel of integer in2

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 }

Figura 1: Listing della classe Eratosthenes.java


.
2 Esercizi sul rendezvous
Esercizio 3. Implementare in Java un sistema client-server secondo lo schema rendezvous,
dove il server offre sia un servizio squaring che un servizio cubing. Si prenda come punto di
partenza il programma Client-server with squaring and cubing visto nella lezione precedente.
Il server deve accettare le richieste dai canali sincroni acceptSquare e acceptCube. L’im-
plementazione dovrà prevedere che i due servizi, o alternative di either, corrispondano a
due sotto-thread del server, diciamo alt1, alt2, eseguiti in parallelo. Essi sono continua-
mente in ascolto sui canali acceptSqare e acceptCube, rispettivamente, in attesa di un client
pronto a sincronizzarsi. Allo scopo, si potrà impiegare il metodo poll() della classe Syn-
chronousQueue, che è una versione non bloccante di take(). Precisamente, l’invocazione

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;

ˆ prima di accettare la richiesta di un nuovo servizio, eventuali sotto-thread del server


devono aver terminato.

Soluzione. La parte interessante dell’esercizio consiste naturalmente nell’implementazio-


ne in Java del costrutto either ... or ..... Lo schema che possiamo adottare è rappresentato
nella figura seguente.

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

ˆ l’accesso alla variabile condivisa done avviene all’interno di un blocco synchronized


guardato da una variabile condivisa lock.

5
class Listener extends Thread {

SynchronousQueue<Param> channel;
Thread service ;

Listener (SynchronousQueue<Param> mychannel, Thread myservice) {


this . channel = mychannel;
this . service = myservice;
};
public void run() {
while (true ) {
synchronized(lock ) {
if (done)
{done = false; break;}
else { received = channel.poll ();
if (received != null ) {
done = true;
service . start ();
try {service . join ();}
catch (InterruptedException e) {};
break;
}
}
}
}
}
}

6
Università di Firenze - CdS Triennale in Informatica

Programmazione Concorrente A.A. 2022-2023 Lezione 24 - 20/12/2022

Esercitazione: simulazione di prova scritta


Docente: Prof. Michele Boreale (Disia)

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)

Algorithm: Produttore-consumatore con buffer di capacità 1


semaphore notFull ← (1,∅), semaphore notEmpty ← (0,∅)
P C
loop forever loop forever
p1: wait(notFull) q1: wait(notEmpty)
p2: signal(notEmpty) q2: signal(notFull)

(b) Il diagramma degli stati è il seguente.

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

p1 : B, q1, (0, {P }), (1, ∅) p1, q2, (0, ∅), (0, ∅)

C P

C p1 : B, q2, (0, {P }), (0, ∅)

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 }

Potrebbero piacerti anche