Sei sulla pagina 1di 20

183

7 Multithreading in Java
Java è un linguaggio concorrente nel quale cioè sono presenti come costrutti
linguistici molti dei concetti esaminati nei precedenti capitoli del testo, in
particolare i concetti di thread, di applicazioni multithreading, di interazioni
e di condivisione di dati fra threads.
Lo scopo di questo capitolo è duplice: da un lato esemplificare i precedenti
concetti utilizzando uno strumento linguistico adatto a questo scopo, dall'altro
fornire un approfondimento degli argomenti dei sistemi operativi e, più in
particolare, di quelli relativi alle problematiche inerenti la programmazione di
sistema, tipiche della programmazione concorrente.
Chiaramente questo capitolo non vuole essere un'introduzione a Java, argomento
questo che esula gli scopi del testo. Viene quindi data per scontata una generica
conoscenza del linguaggio oltre che dei principi della programmazione orientata
agli oggetti (classi, oggetti, incapsulamento, meccanismi di astrazione,
ereditarietà, polimorfismo, gestione delle eccezioni, garbage collection), e di
come tali concetti sono stati calati nel linguaggio Java. Per approfondire i
principi del paradigma orientato agli oggetti si può fare riferimento a testi
specifici. Per quanto riguarda un buon tutorial su Java si può fare riferimento
alla documentazione disponibile in rete sul sito della Oracle
[https://docs.oracle.com/javase/tutorial/java/index.html].

7.1 Ambiente di sviluppo e ambiente di esecuzione


Java non è soltanto un linguaggio di programmazione, ma una tecnologia che
consiste di un ambiente per lo sviluppo di nuove applicazioni (Java Development
Environment) e di uno per la loro esecuzione (Java Run-Time Environment), spesso
indicato anche come Java Platform.
L'ambiente di sviluppo consiste nel linguaggio Java e nel relativo compilatore
che traduce il programma in un linguaggio intermedio (bytecode) indipendente
dalla particolare architettura della macchina su cui deve girare il programma e
dal particolar sistema operativo presente sulla stessa macchina. L'ambiente di
esecuzione consiste in una libreria di packages Java, contenenti alcune classi
di utilità offerte al programmatore già tradotte in linguaggio intermedio (Java
API), nel linker-loader, utilizzato per collegare il codice prodotto dal
compilatore e quello delle classi di libreria usate e caricare il risultato in
memoria, e nell'interprete del linguaggio intermedio, necessario per eseguire il
programma sulla specifica macchina. Tale interprete implementa la Java Virtual
Machine (JVM) che, come dice il nome, rappresenta una macchina astratta
indipendente dalla specifica macchina ospite su cui il programma deve girare.
Un programma Java è costituito da un insieme di classi. Una di queste deve
contenere il metodo main() (un metodo corrisponde a una funzione in C o in C++).
Il metodo main() deve essere definito come:

public static void main(String args[])

Il file con il codice sorgente contenente la classe in cui compare il metodo


main() deve avere lo stesso nome di questa classe e avere l'estensione .java (per
esempio: esempio.java). Il compilatore traduce il programma sorgente nel
184

linguaggio intermedio producendo un file con lo stesso nome e la stessa estensione


.class (esempio.class).

La libreria Java API, come già detto, consiste in un insieme di packages


contenenti classi già tradotte in linguaggio intermedio (il package standard del
linguaggio: java.lang, il package con le classi di supporto per la grafica:
java.awt , il package con le classi per la gestione dell'I/O: java.io, e altri
ancora). Un elenco completo di tutti i packages si può trovare sul sito della
Oracle [https://docs.oracle.com/en/java/javase/19/docs/api/allpackages-
index.html].

La Java Virtual Machine contiene il linker-loader per caricare le classi da


eseguire e l' interprete che implementa sulla macchina ospite la macchina astratta
il cui linguaggio macchina coincide con il bytecode (vedi Figura 7.1).

Figura 7.1 - Java Virtual Machine

Il concetto di macchina virtuale Java rappresenta un'astrazione che può essere


implementata in vari modi. Il più ovvio e più spesso utilizzato è proprio quello
di realizzare un interprete del bytecode sopra la macchina ospite sfruttando le
funzionalità del sistema operativo che su essa opera, ma in modo tale da garantire
la completa indipendenza dei programmi Java dalla specifica architettura e dallo
specifico sistema operativo (vedi Figura 7.2).

Si può anche ipotizzare una successiva fase di compilazione che traduca il


bytecode direttamente sulla macchina ospite o, addirittura, di realizzare in
hardware la macchina che interpreti direttamente il bytecode.
185

Figura 7.2 - Indipendenza di java dalla macchina ospite.

Come già detto, Java è un linguaggio concorrente che consente la definizione di


più threads all'interno dello stesso programma. Ciò ovviamente implica che la
Java Virtual Machine è una macchina multiprogrammata, nel senso che fornisce il
supporto all'esecuzione concorrente di più threads. Nel secondo capitolo
(Paragrafo 2.9) è stato messo in evidenza che non tutti i sistemi operativi
multiprogrammati forniscono il supporto ai threads. Ci sono casi in cui la
gestione dei threads può avvenire a livello utente, in altri avviene a livello
di nucleo. La realizzazione della Java Virtual Machine astrae da questi dettagli
implementando i threads Java a livello utente o a livello di nucleo in base alle
caratteristiche del sistema operativo ospite, ma rende i programmi del tutto
indipendenti da questi dettagli.

5.2 I thread in Java


Ogni programma Java contiene almeno un singolo thread, corrispondente
a1l'esecuzione del metodo main() sulla JVM. È possibile però creare dinamicamente
ulteriori threads attivando le loro esecuzioni concorrentemente all'interno del
programma. I threads Java sono oggetti che derivano dalla classe Thread fornita
dal package java.lang.

Esistono due diversi modi in Java per creare nuovi threads:

• il primo consiste nel derivare nuove classi dalla classe Thread mediante la
normale tecnica di estensione tipica dell'ereditarietà,

• il secondo nel definire una nuova classe che implementa l'interfaccia Runnable
(anch'essa offerta dal package java.lang).

Esaminiamo adesso il primo dei due metodi e, successivamente l'altro.


186

7.2.1 Creazione di thread mediante estensione della classe Thread


La classe di libreria Thread definisce e implementa i thread Java fornendo un
insieme di metodi necessari per il controllo delle loro esecuzioni. Uno di questi
metodi, il metodo run(), definisce ciò che ogni oggetto della classe eseguirà come
thread separato (una qualunque sequenza di statements Java), concorrentemente con
gli altri threads del programma. La classe Thread implementa un thread generico
che, per default, non fa rigorosamente niente, cioè l'implementazione del suo
metodo run() è vuota. Per creare quindi qualcosa di utile si può definire una
sottoclasse di Thread ridefinendone (override) il metodo run() in modo tale da
fargli eseguire ciò che è richiesto dal programma. Nella Figura 7.3 viene
illustrato questo approccio.

class AltriThreads extends Thread {


@override
public void run(){
<corpo del programma eseguito>
<da ogni thread di questa classe>;
}
}

public class PrimoEsempioConDueThreads {


public static void main (String[] args){
AltriThreads tl = new AltriThreads();
tl.start() ;
<resto del programma eseguito dal thread main>
}
}
Figura 7.3 - Creazione di un thread mediante estensione della classe Thread

La classe AltriThreads (estensione di Thread) implementa i nuovi threads


ridefinendo il metodo run(). La successiva classe è quella che fornisce il main
nel quale viene creato il thread t1 come oggetto derivato dalla classe Thread.

Da notare che la creazione dell'oggetto t1 nel primo statement del metodo main
si limita a creare un thread vuoto, cioè senza nessuna risorsa allocata al thread
(senza un processore virtuale in grado di eseguirlo). Come vedremo in un
successivo paragrafo esaminando il grafo di stato di un thread Java, per attivare
il thread assegnandogli un processore virtuale, e quindi per farlo transitare in
stato pronto per l'esecuzione, è necessario eseguire il metodo start() come indicato
nel secondo statement del main. È il metodo start() che invoca il metodo run()
attivando l'esecuzione del nuovo thread. Il metodo run() non può essere chiamato
direttamente ma solo tramite lo start().

Nell'esempio di Figura 7.3 la JVM gestisce due thread concorrenti: il thread


principale associato al main e il thread t1, creato dinamicamente dal precedente
con l'esecuzione dello statement t1.start() che lancia, in concorrenza, l'esecuzione
del metodo run() del nuovo thread.
187

7.2.2 Creazione di thread mediante implementazione dell'interfaccia


Runnable
Un diverso modo di fornire il metodo run() a un thread, rispetto a quello visto
precedentemente, è quello di definire una nuova classe che implementa l'
interfaccia Runnable che è così definita:

public interface Runnable{


public abstract void run();
}

da cui ovviamente risulta che ogni classe che implementa questa interfaccia deve
definire il metodo run(). Nella successiva Figura 7.4 viene illustrato questo
approccio.

class AltriThreads implements Runnable {


public void run() {
<corpo del programma eseguito>
<da ogni thread di questa classe>;
}
}

public class SecondoEsempioConDueThreads{


public static void main(String[] args){
Runnable esecutore= new AltriThreads();
Thread t1 = new Thread(esecutore);
t1.start();
<resto del programma eseguito dal thread main>;
}
}
Figura 7.4 - Creazione di un thread mediante implementazione di Runnable.

La nuova classe che implementa Runnable non estende Thread e quindi, in questo
caso, non ha accesso al metodo start() necessario per attivare un nuovo thread.

È quindi obbligatorio anche ora creare un oggetto della classe Thread (t1
nell'esempio di figura, nel secondo statement del metodo main). Il criterio con
cui viene specificato il metodo run() che il nuovo thread dovrà eseguire è quello
di creare un oggetto Runnable, come nel primo statement del main, nel quale
l'oggetto esecutore è creato come istanza della classe AltriThreads che implementa
il nuovo metodo run(). Tale oggetto è poi passato come parametro al costruttore
del thread t1 nel secondo statement del main. In questo modo, quando t1 è attivato
mediante il metodo start () (terzo statement), inizia l'esecuzione del metodo run()
dell'oggetto Runnable.

La necessità di avere in Java anche questo secondo criterio di creazione di un


thread è dovuto all'impossibilità di derivazione multipla di una classe. Per cui,
per esempio, se una classe è già derivata da un'altra non è possibile estendere
contemporaneamente anche la classe Thread. In questo caso si può allora consentire
188

alla classe derivata di implementare contemporaneamente l'interfaccia Runnable.


Negli esempi che seguono, per semplicità, faremo riferimento esclusivamente al
primo modo (illustrato in Figura 7.3).

7.2.3 Grafo di stato dei thread Java


Gli stati possibili nei quali può trovarsi un thread sono quattro come indicato
nella Figura 7.5. Lo stato nuovo (new) è quello in cui si trova un thread dopo
che è stato creato un oggetto per il thread mediante lo statement new. Quando il
thread si trova in questo stato non può eseguire poiché non è stato ancora
attivato. L'attivazione avviene invocando il metodo start() che, a sua volta,
chiama il metodo run() per iniziare l'esecuzione del thread. Lo stato in cui si
trova il thread dopo la sua attivazione viene indicato come runnable. Quando
termina l'esecuzione del metodo run() il thread commuta il proprio stato in
terminato (dead).

Figura 7.5 - Grafo di stato di un thread.

Durante la sua esecuzione il thread può sospendersi per varie cause entrando in
stato bloccato (blocked):

• una prima causa di blocco è dovuta a un'operazione di I/O che implica la


sospensione del thread fino al termine dell'operazione.
• Un'altra causa di sospensione corrisponde all'invocazione del metodo sleep, a
cui viene passato un parametro che corrisponde al numero di millisecondi che
devono trascorrere prima che il thread diventi di nuovo runnable. In pratica
tale metodo consente a un thread di ritardare la propria esecuzione per un
periodo di tempo programmato.
• Un'ulteriore causa di blocco corrisponde all'esecuzione del metodo wait, per
attendere che una condizione necessaria per la prosecuzione del thread sia
verificata. Vedremo in dettaglio nei successivi paragrafi l'uso di questo
metodo.
189

Per ciascuna delle cause di sospensione di un thread esiste un evento la cui


occorrenza rende di nuovo attivo il thread commutandolo in stato eseguibile
(runnable).
• Per esempio se il thread si era sospeso per aver attivato un'operazione di
I/O, tale evento corrisponde alla terminazione dell'operazione.
• Analogamente, se il thread si era spontaneamente sospeso invocando sleep,
l'evento corrisponde al completamento del periodo di attesa.
• Se, infine, il thread si era sospeso eseguendo una wait , l'evento corrisponde
all'esecuzione del metodo notify (o notifyAll) da parte di un altro thread, come
vedremo nel seguito.

Lo stato eseguibile (runnable) identifica che il thread è attivo e quindi che la


JVM lo può eseguire. Però, come indicato nel Capitolo 2, a livello di sistema
operativo, lo stato attivo (vedi figure 2.2 e 2.3) viene suddiviso nei due stati
di pronto (ready) e di esecuzione (running) per tenere conto che la macchina
reale ha un numero di processori fisici inferiore a quello dei processori
virtuali. Quindi, tenendo conto di questa osservazione, il grafo di stato di un
thread corrisponde esattamente al grafo a 5 stati illustrato nel Capitolo 2 (vedi
Figura 2.4).

Il sistema operativo nasconde i due stati di pronto (ready) e di esecuzione


(running) alla JVM, che vede solo lo stato eseguibile (runnable) (Figura 7.6).

Figura 7.6 – Lo stato runnable di JAVA visto dal sistema operativo.

Quando un thread passa per la prima volta allo stato eseguibile dallo stato nuovo,
si trova nello stato pronto (ready). Un thread pronto entra nello stato di
esecuzione (cioè inizia a funzionare) quando il sistema operativo lo assegna a
un processore, il che è noto come dispacciamento del thread (dispatching the
thread).

Nella maggior parte dei sistemi operativi, a ogni thread viene assegnata una
piccola quantità di tempo del processore - chiamata quantum o timeslice - in cui
eseguire il proprio compito. Decidere quanto grande debba essere il quantum è un
argomento chiave nei corsi di sistemi operativi. Quando il suo quantum scade, il
thread torna allo stato di pronto e il sistema operativo assegna un altro thread
al processore.
190

Le transizioni tra lo stato di pronto e quello di esecuzione sono gestite


esclusivamente dal sistema operativo. La JVM non "vede" le transizioni: si limita
a considerare il thread come eseguibile e lascia al sistema operativo il compito
di passare il thread da pronto a in esecuzione. Il processo che il sistema
operativo utilizza per determinare quale thread dispacciare si chiama thread
scheduling e dipende dalle priorità dei thread.

È possibile verificare lo stato di un thread, almeno in parte, mediante il metodo


isAlive che restituisce un valore booleano. In particolare restituisce il valore
false se il thread è terminato e true in caso contrario.

7.2.4 Priorità e algoritmi di scheduling dei thread Java


All'interno della JVM i threads sono schedulati mediante un algoritmo che opera
su base prioritaria con diritto di prelazione (preemptive priority scheduling)
con priorità fisse.

Ogni thread ha una propria priorità rappresentata mediante un valore intero


compreso fra due valori limite: MIN_PRIORITY e MAX_PRIORITY (due costanti definite
all'interno della classe Thread). A intero maggiore corrisponde una maggiore
priorità. Ogni thread, all'atto della creazione, eredita la priorità dal thread
che lo ha creato. È però possibile modificare tale valore mediante il metodo
setPriority.

L'algoritmo di scheduling sceglie fra tutti i threads pronti per l'esecuzione il


thread con la priorità più alta e, nel caso di threads con la stessa priorità,
in subordine effettua la scelta in ordine FIFO.

La JVM esegue l'algoritmo di scheduling in uno dei seguenti due casi:

1. quando il thread correntemente in esecuzione esce dallo stato runnable (perché


si sospende o termina);

2. nel caso in cui diventi runnable un thread a priorità più alta (preemption)
rispetto a quella del thread in esecuzione.

La JVM non fornisce nessuna indicazione rispetto a un'eventuale assegnazione


della CPU a quanti di tempo (time slice) tipica dei sistemi operativi che adottano
algoritmi di scheduling round robin. Se la JVM è implementata su sistemi che
adottano questo criterio (per esempio Windows), i threads di uguale priorità
vengono gestiti con tecnica Round Robin e non semplicemente FIFO.

Quindi, in questi casi, il thread in esecuzione mantiene il controllo della CPU


fino a quando si verifica uno dei due eventi indicati precedentemente o, al
massimo, fino alla scadenza del quanto di tempo assegnato.

Se la JVM viene implementata su un sistema che non adotta la tecnica a partizione


191

di tempo è comunque possibile simulare a programma tale comportamento mediante


il metodo yield() che il thread in esecuzione può invocare per cedere
volontariamente la CPU e richiamare lo schedulatore al fine di assegnarla a un
altro thread della stessa priorità (vedi Figura 7.7).

public void run(){


while (true) {
< sequenza di statements eseguiti dal thread .. . > ;
< .. . fra quando entra in esecuzione e ... > ;
< .. . quando cede il controllo della CPU> ;
< (approssimazione di un quanto di tempo)> ;
Thread.yield();
}
}
Figura 7.7 - Uso del metodo yield().

L'uso del metodo yield() visto precedentemente viene spesso indicato con il termine
di cooperative multithreading.

La trasparenza della JVM rispetto alla gestione dei quanti di tempo ha costituito
un potenziale problema per quanto riguarda la portabilità di alcune applicazioni
su sistemi operativi che adottano diversi criteri di schedulazione.

7.2.5 Attesa di un thread: il metodo join()


Nel caso in cui un thread compie delle operazioni che utilizzano i risultati
dell’elaborazione di un altro thread è necessario attendere che questo thread
termini la sua esecuzione. Affinché ciò sia possibile la classe Thread mette a
disposizione il metodo join() che resta semplicemente in attesa finché il thread
per il quale è stato chiamato non termina la sua esecuzione.

In Figura 7.8 è riportato un breve esempio nel quale è mostrato un main() che
manda in esecuzione due thread per poi sospendersi in attesa della terminazione
di entrambi.

Public static void main(String[] args){


Thread t1 = new Thread();
Thread t2 = new Thread();
. . .
t1.start();
t2.start();
Try{
t1.join();
t2.join();
}catch(InterruptedException e){
System.out.println(e);
}
}
Figura 7.8 - Uso del metodo join().
192

Si osservi che il metodo join() può generare una InterruptedException e


quindi deve essere eseguito in un segmento try-catch (vedi Paragrafo
7.3.2).

I metodi sleep() e join() sono metodi che hanno in comune la caratteristica di


mettere un thread in stato di attesa (blocked). Ma mentre con sleep() l’attesa ha
una durata prefissata, con join() l’attesa potrebbe protrarsi indefinitamente, o
non avere addirittura termine. Alcune volte il protrarsi dell’attesa oltre un
certo limite potrebbe indicare un malfunzionamento o comunque una condizione da
gestire in maniera diversa che stando semplicemente ad aspettare.
In questi casi si può usare il metodo join(int millisecondi) che permette di assegnare
un limite massimo di attesa, dopo in quale il metodo ritornerà comunque,
consentendo al metodo chiamante di riprendere l’esecuzione.

7.3 Sincronizzazione in Java


Come è stato mostrato nel Capitolo 2 (Paragrafo 2.9), i threads di un'applicazione
condividono lo spazio di indirizzamento. È quindi naturale che le soluzioni ai
problemi di interazione tra threads siano strutturate seguendo il modello ad
ambiente globale (vedi Capitolo 3 - Paragrafo 3.1). Tale modello, in particolare,
prevede che ogni tipo di interazione tra threads avvenga tramite la memoria
comune: quindi, nel caso di applicazioni Java realizzate seguendo il paradigma
object-oriented, tramite oggetti comuni.

Sempre nel Paragrafo 3.1 è stato mostrato che le interazioni possono essere di
due tipi diversi:

• di tipo competitivo, per l'accesso a oggetti comuni, e


• di tipo cooperativo, per lo scambio di informazioni.

Per risolvere i problemi inerenti i due tipi di interazione sono necessari due
diversi tipi di meccanismi di sincronizzazione, indicati con i nomi di
sincronizzazione indiretta (o di mutua esclusione) e di sincronizzazione diretta
rispettivamente.

In Java tali meccanismi sono stati introdotti a livello di linguaggio e sono


supportati dalla JVM. Essi corrispondono, rispettivamente, al meccanismo degli
"object-locks" e al meccanismo "wait-notify".

7.3.1 Accessi esclusivi a un oggetto e sezioni critiche


Nel Capitolo 5 (Paragrafo 3.2) è stato messo in evidenza che quando due o più
processi (o threads nel caso di Java) operano concorrentemente su variabili
comuni, possono nascere problemi di corse critiche, e cioè si può verificare la
possibilità che, per certi rapporti di velocità fra le esecuzioni di due threads,
le operazioni eseguite sulle variabili comuni da uno di essi siano eseguite in
concorrenza con operazioni sulle stesse variabili da parte dell'altro, generando
interferenze e quindi errori.
193

Per questo motivo è necessario imporre la mutua esclusione fra le esecuzioni di


queste operazioni che, come è stato indicato nel Capitolo 5, vengono spesso
riferite col nome di sezioni critiche.

Per risolvere questo problema in Java a ogni oggetto, a qualunque classe


appartenga, viene associato dalla JVM un meccanismo di mutua esclusione (lock)
analogo a un semaforo binario (come, per esempio, il semaforo mutex visto nel
Paragrafo 5.2.1). Tale meccanismo è nascosto all'interno del supporto fornito
dalla JVM e non è quindi direttamente disponibile al programmatore. E però
possibile denotare alcune sezioni di codice che operano su un oggetto come sezioni
critiche, identificandole con la parola chiave synchronized. È poi compito del
compilatore garantire che tali sezioni critiche siano eseguite in mutua esclusione
inserendo in testa alla sezione critica un prologo, il cui scopo è garantire
l'acquisizione del lock associato all'oggetto (se libero, altrimenti il thread
che lo esegue viene sospeso) e, alla fine della sezione critica un epilogo per
rilasciare il lock (vedi Paragrafo 5.2.1 per una dettagliata illustrazione del
prologo e dell'epilogo).

Per esempio, con riferimento a un oggetto x , è possibile definire un blocco di


statements come una sezione critica nel seguente modo, noto col termine di blocco
sincronizzato (synchronized block):

synchronized(oggetto x){
<sequenza di statements>;
}

Nella Figura 7.9 viene riportato l'esempio del metodo M che più threads possono
invocare ma che, al proprio interno, contiene un blocco sincronizzato (<sezione
di codice critica>) che viene quindi eseguito in mutua esclusione.

Object mutexLock = new Object();


...
...
public void M(){
<sezione di codice non critica> ;
synchronized(mutexLock){
<sezione di codice critica> ;
}
<sezione di codice non critica>;
}
Figura 7.9 – Esempio di blocco sincronizzato

In questo esempio l'oggetto mutexLock viene usato esclusivamente (come indicato


dal suo nome) per sfruttare il suo lock al fine di garantire che la sezione
critica di codice all'interno del metodo M sia eseguita in mutua esclusione.
In particolare, quando un thread invoca M può eseguire la prima parte del metodo
(<sezione di codice non critica>) senza nessun vincolo, anche in concorrenza con
altri threads che a loro volta abbiano invocato M. Quando, però, un thread tenta
di eseguire il blocco sincronizzato può proseguire soltanto se il lock associato
194

a mutexLock è libero, altrimenti il thread viene sospeso dalla JVM in attesa che
il lock si liberi.

Se il lock è libero il thread prosegue e occupa (atomicamente) il lock


disabilitando altri threads a entrare a loro volta nella sezione critica. Quando
il thread termina l'esecuzione del blocco sincronizzato, se non ci sono altri
threads in attesa, il lock viene reso libero altrimenti la JVM sceglie
arbitrariamente uno dei threads in attesa, abilitandolo a occupare nuovamente il
lock e a eseguire, a sua volta, la sezione critica.

Il fatto che a ogni oggetto sia implicitamente associato un lock implica che a
esso è anche associato un insieme (eventualmente vuoto) di threads, i quali avendo
tentato di eseguire un blocco sincronizzato controllato dal lock di tale oggetto
e avendolo trovato occupato, sono stati sospesi in attesa che il lock venga
liberato. Questo insieme di threads viene anche indicato come entry set (vedi
Figura 7.10).

Figura 7.10 – Entry set di un oggetto.

Nella parte a) della figura viene rappresentato il thread t1 che tenta di eseguire
un blocco sincronizzato controllato dal lock dell'oggetto ob e, avendolo trovato
libero, può iniziare la sua esecuzione occupando il lock che viene chiuso.
Successivamente (parte b) della figura) altri threads (t2 e t3) tentano di
eseguire lo stesso blocco sincronizzato, ma trovando il lock chiuso si sospendono,
entrando quindi a far parte dell'entry set di ob.
195

Il blocco sincronizzato non costituisce l'unica modalità per definire sezioni


critiche in Java. Esiste anche la possibilità di definire la mutua esclusione fra
metodi di una classe. In questo caso è sufficiente che tali metodi siano
caratterizzati dalla parola chiave synchronized.

Quando uno di tali metodi viene invocato per operare su un oggetto della classe,
l'esecuzione del metodo viene garantita in mutua esclusione sfruttando il lock
dell'oggetto. Per esempio in Figura 7.11 viene fornito il codice di una classe
che permette di definire variabili intere sulle quali più threads possano eseguire
le operazioni di incrementa e decrementa in maniera mutuamente esclusiva.

public class IntVar{


private int i = 0;

public synchronized void incrementa(){


i++;
}

public synchronized void decrementa(){


i--;
}
}
Figura 7.11 - Metodi synchronized.

I due modi di definire sezioni critiche, il blocco sincronizzato e il metodo


sincronizzato, non sono fra loro indipendenti. Un metodo dichiarato synchronized
corrisponde a un metodo il cui corpo coincide con un blocco sincronizzato
controllato dall'oggetto su cui il metodo viene eseguito (vedi Figura 7.12, nella
quale i due metodi della Figura 7.11 sono riscritti come blocchi sincronizzati).

public class IntVar{


private int i = 0;

public void incrementa(){


synchronized (this){
i++;
}
}

public void decrementa(){


synchronized(this){
i--;
}
}
}
Figura 7.12 - Equivalenza tra blocchi sincronizzati e metodi synchronized.

Possiamo adesso ricapitolare i meccanismi offerti da Java per garantire la mutua


esclusione di sezioni critiche:
196

• la sincronizzazione viene implementata mediante il meccanismo interno dei lock


(semplici semafori di mutua esclusione), offerto dalla JVM (e non visibile
direttamente al programmatore), che associa un lock a ogni oggetto;

• il meccanismo linguistico principale è quello indicato precedentemente col


termine di synchronized block, mediante il quale un qualunque blocco di codice
può essere sincronizzato in modo tale da garantirne l' esecuzione in maniera
mutuamente esclusiva rispetto ad altre esecuzioni dello stesso blocco o di
altri blocchi sincronizzati sullo stesso oggetto. L'altro meccanismo, quello
dei metodi synchronized, corrisponde allo stesso metodo non dichiarato
synchronized ma il cui corpo è un unico blocco sincronizzato mediante il lock
dell'oggetto su cui il metodo viene eseguito;

• un metodo sincronizzato può invocare un altro metodo sincronizzato sullo stesso


oggetto senza bloccarsi al fine di evitare condizioni di blocco critico;

• due diversi metodi, uno sincronizzato e uno non sincronizzato, possono essere
eseguiti concorrentemente sullo stesso oggetto.

7.3.2 Sincronizzazione diretta: metodi wait e notify


Il secondo meccanismo linguistico offerto da Java per consentire il coordinamento
delle esecuzioni dei threads è relativo alla sincronizzazione diretta. A questo
fine, la JVM associa implicitamente a ogni oggetto, oltre al meccanismo dei locks
visto precedentemente, anche una coda di threads, inizialmente vuota, nota col
nome di wait set. I threads entrano ed escono da questa coda utilizzando i due
metodi wait() e notify() offerti dalla classe Object, da cui tutte le classi di
Java sono derivate.

Questi due metodi vengono utilizzati con le seguenti regole:

• i due metodi wait() e notify() possono essere invocati da un thread esclusivamente


all'interno di un blocco sincronizzato o di un metodo sincronizzato e cioè
soltanto quando il thread detiene il lock relativo a un oggetto;

• l'esecuzione del metodo wait() risulta nelle seguenti azioni:


- il lock sull'oggetto viene rilasciato,
- l'esecuzione del thread viene sospesa,
- il thread viene inserito nel wait set relativo all'oggetto;

• l'esecuzione del metodo notify() risulta nelle seguenti azioni:


- se il wait set relativo all'oggetto è vuoto, non viene eseguita nessuna
azione, altrimenti la JVM sceglie arbitrariamente un thread (indichiamolo
con t) estraendolo dal wait set e lo inserisce nell'entry set in modo tale
che, riattivando la sua esecuzione, questo possa riacquisire il lock e
riprendere l'esecuzione dall'istruzione successiva alla wait con cui si era
sospeso,
197

- t, dovendo riacquisire il lock per riprendere l'esecuzione, deve comunque


attendere che il thread che ha invocato la notify() rilasci a sua volta il
lock che detiene (azione che avviene alla fine del blocco o del metodo
sincronizzato in cui si trova la notify). Inoltre, poiché la notify inserisce
t nell'entry set, per motivi di competizione può accadere che all'atto del
rilascio del lock questo venga acquisito da un thread t' prima che t riesca
a sua volta ad acquisirlo,
- una volta che t ha riacquisito il lock, la sua esecuzione riprende
dall'istruzione successiva alla wait con cui si era sospeso;

• oltre al metodo notify() viene offerto anche il metodo notifyAll() che risulta
nelle stesse azioni del metodo notify(), ma con la differenza che vengono
coinvolti tutti i threads contenuti nel wait set;

• anche il metodo wait() ha delle alternative, in particolare quella che prevede


la specifica di un tempo massimo da passare nel wait set prima di essere
risvegliato automaticamente (time-out);

• i due metodi notify() e notifyAll() non provocano il rilascio del lock da parte
di chi li invoca per cui, come indicato precedentemente, i threads risvegliati
devono attendere che il thread in esecuzione rilasci il lock terminando
l'esecuzione del blocco, o del metodo, sincronizzato al cui interno si trova
la notify, per poter riacquisire il lock e ripartire dall'istruzione successiva
alla wait.

Per illustrare l'uso di questo meccanismo vedremo adesso come può essere risolto
il problema della comunicazione tra threads (problema del produttore/consumatore)
introdotto nel quinto capitolo (Paragrafo 5.3.1). Supponiamo, per prima cosa, di
risolvere il problema nel caso più semplice (vedi Figura 5.1) di un solo thread
produttore e un solo thread consumatore che si scambiano messaggi tramite un'area
condivisa (buffer) in grado di contenere un solo messaggio e che il tipo dei
messaggi, per semplicità, sia il tipo intero.

Nella successiva Figura 7.13 vene presentata la classe Mail box a cui dovrà
appartenere l'oggetto buffer da utilizzare come area condivisa tra i due threads.

public class Mailbox{


private int contenuto;
private boolean pieno = false;

public synchronized int preleva(){


while(pieno == false){
wait();
}
pieno = false;
notify();
return contenuto;
}
198

public synchronized void deposita(int valore){


while(pieno == true){
wait();
}
contenuto = valore;
pieno = true;
notify();
}
}
Figura 7.13 - Mailbox unitaria.

La variabile intera contenuto rappresenta l'area di memoria condivisa mentre


l'indicatore booleano pieno serve per registrare lo stato di tale area contenente
un messaggio già depositato dal produttore e non ancora prelevato dal consumatore
(valore true), oppure (valore false) quando ancora nessun messaggio è stato
depositato o, se già depositato, è stato anche prelevato.

I metodi deposita() e preleva() sono sincronizzati in quanto operano su un oggetto


condiviso. Se il produttore invoca deposita quando il contenuto è pieno deve
sospendersi eseguendo wait in attesa che il consumatore prelevi il messaggio già
presente in contenuto. Altrimenti il nuovo valore depositato andrebbe a
sovrascrivere quello ancora presente in contenuto.

Viceversa, quando il contenuto non è pieno, il messaggio viene inserito nel


contenuto ponendo l'indicatore pieno a true per evitare ulteriori depositi e
viene eseguita la notify per risvegliare il consumatore qualora questo sia in
attesa che il contenuto sia pieno. L'altro metodo (preleva) è praticamente il
duale del precedente.

Da notare che quando un thread si sospende sulla wait (per esempio il produttore
all'interno del metodo deposita) deve attendere che l'altro thread lo risvegli
con la notify eseguita alla fine dell'altro metodo (nell'esempio eseguita dal
consumatore alla fine del metodo preleva e viceversa).

Quando un thread viene risvegliato, in base alle regole viste precedentemente,


deve attendere che il thread in esecuzione rilasci il lock, alla fine del metodo
in cui è presente la notify, quindi riacquisisce il lock e riprende l'esecuzione
dopo la wait. Poiché questa è all'interno di un ciclo while, il thread risvegliato
valuta di nuovo la condizione del while che adesso sarà sicuramente falsa.

In questo caso particolare al posto del while avremmo potuto usare, più
semplicemente, uno statement if. Se però generalizziamo il problema, per esempio
ipotizzando che siano presenti più threads produttori e/o più threads consumatori,
allora l'uso del while diventa necessario.
199

Per capire ciò, e contestualmente anche la necessità del metodo notifyAll in


alternativa al semplice notify, ipotizziamo che un oggetto buffer della precedente
classe sia condiviso tra molti produttori e molti consumatori.

In questo caso (quando per esempio, il buffer è vuoto) può accadere che siano
contemporaneamente bloccati alcuni thread consumatori che - avendo invocato
preleva e non essendoci niente da prelevare - si sono sospesi entrando nel wait
set di buffer.

Supponiamo anche che alcuni produttori siano presenti nell'entry set in attesa
di acquisire il lock ed eseguire il metodo deposita. Appena il primo fra questi
acquisisce il lock, esegue deposita riempiendo il buffer e con la notify sveglia
uno dei consumatori prelevandolo dal wait set e inserendolo nell'entry set. Quando
tale produttore termina l'esecuzione di deposita rilascia il lock e quindi uno
dei threads presenti nell'entry set viene abilitato a proseguire.

Se per caso viene scelto ancora un produttore, quest'ultimo troverà il buffer


pieno e quindi si sospenderà. Da questo momento in poi abbiamo nel wait set sia
threads consumatori sospesi perché hanno trovato il buffer vuoto sia produttori
che hanno trovato il buffer pieno.

Quando un consumatore riuscirà ad acquisire il lock, potrà prelevare il messaggio


contenuto nel buffer ed eseguire la notify per svegliare un produttore. Però, per
le regole viste precedentemente, la notify sceglie in maniera casuale uno dei
threads presenti nel wait set da risvegliare e allora può accadere che invece di
un produttore venga scelto un consumatore che, evidentemente non può essere in
grado di proseguire essendo vuoto il buffer.

In questo caso, per garantire la correttezza della soluzione, è quindi necessario


sostituire alla notify la notifyAll che risvegli tutti i threads presenti nel wait
set. Questi, uno alla volta, riacquisiranno il lock, ma necessariamente dovranno
rivalutare la condizione per verificare se possono proseguire o se debbano
sospendersi di nuovo. È questo il motivo per cui è necessario inserire la wait
all'interno di un ciclo nel quale il thread rimane fino a quando non trova la
condizione falsa.

In Figura 7.14 l'esempio viene generalizzato supponendo che l'area condivisa sia
in grado di contenere non uno soltanto, ma fino a N messaggi contemporaneamente.
In questo caso la variabile contenuto diventa un array di N elementi i quali
vengono gestiti con tecnica FIFO mediante gli indici testa e coda (come è stato
mostrato nel Capitolo 3). Inoltre viene aggiunta la variabile contatore che è
destinata a contenere il numero di elementi pieni dell'array. La condizione buffer
vuoto coincide, in questo caso, con (contatore = = O) e, analogamente, la
condizione buffer pieno con (contatore = = N).
200

public class Mailbox{


private int[]contenuto; //buffer
private int contatore, testa, coda; //contatore e indici buffer
private final int N; // capacità del buffer

public mailbox(int n){


N = n;
contenuto = new int[N];
contatore = 0;
testa = 0;
coda = 0;
}

public synchronized int preleva(){


int elemento;
while(contatore == 0){
wait();
}
elemento = contenuto[testa];
testa = (testa + 1) % N;
contatore--;
notifyAll();
return element;
}

public synchronized void deposita(int valore){


while(contatore == N){
wait();
}
contenuto[coda] = valore;
coda = (coda + 1) % N;
contatore++;
notifyAll();
}
}
Figura 7.14 - Buffer circolare.

Come ultimo esempio, in Figura 7.15 viene riportato il codice della classe
semaforo che implementa oggetti il cui comportamento corrisponde esattamente a
quello di un semaforo introdotto nel quinto capitolo (Paragrafo 5.2).

Nell'esempio i metodi di accesso a oggetti di tipo semaforo sono stati indicati


con gli identificatori P e V e non con wait e signal come indicato nel Paragrafo
5.2. Ciò per evitare la confusione che potrebbe nascere col metodo wait di Java.

Del resto, i nomi P al posto di wait e V al posto di signal, sono quelli


originariamente previsti da Dijkstra quando ha introdotto il meccanismo
semaforico per la prima volta.
201

public class Semaforo{


private int valore;

public Semaforo(int v){


valore = v;
}

public synchronized void P(){


while(valore == 0){
wait();
}
valore--;
}

public synchronized void V() {


valore++;
notify;
}
}
Figura 7.15 - Semaforo Java

Come ultima considerazione è necessario evidenziare che, per semplicità, in tutti


i precedenti esempi il metodo wait è stato utilizzato senza fare riferimento a
eventuali eccezioni che tale metodo può sollevare. Il suo corretto uso prevede,
viceversa, che sia sempre utilizzato all'interno di un blocco:

try{
...
}catch (Interrupted Exception e) {
. . .
}

poiché la definizione di tale metodo è la seguente:

public final void wait() throws InterruptedException;

Ciò significa che il codice che invoca il metodo wait può ricevere, come
conseguenza di questa invocazione, l'eccezione InterruptedException che, quindi,
deve essere gestita all'interno da un ramo catch appartenente a un blocco

try{
...
} catch ( . . . ) {
...
}

al cui interno compare la chiamata di wait.


202

Infatti la specifica di Java prevede che, se un thread ta è sospeso, avendo


invocato la wait, e prima di essere riattivato viene interrotto da un altro thread
tb mediante il metodo interrupt, ta viene risvegliato e riceve l'eccezione
InterruptedException che deve quindi gestire.

Per concludere l'argomento della sincronizzazione diretta è opportuno mettere in


evidenza che nelle versioni più recenti di Java (Java2 Platform Standard Ed.5.0)
è prevista anche la possibilità di definire e usare le variabili condition, già
introdotte nel Paragrafo 6.4.2 in relazione ai thread di Linux.

L'uso di tali variabili semplifica la soluzione ai problemi di sincronizzazione


più complessi, consentendo al programmatore di separare i processi sospesi in
funzione del tipo di condizione logica attesa e facilitando così la realizzazione
di politiche di risveglio dei processi.

Riassunto
Lo scopo di questo capitolo è stato quello di esemplificare alcuni dei concetti
di sistema visti nei precedenti capitoli mediante gli strumenti linguistici che
Java mette a disposizione del programmatore. In particolare, data per scontata
una generica conoscenza del paradigma di programmazione a oggetti e di Java in
modo specifico, sono stati presentati il meccanismo multithreading di Java e i
meccanismi di sincronizzazione previsti nel linguaggio, per garantire la
soluzione sia a problemi di competizione (sincronizzazione indiretta) sia a
problemi di cooperazione (sincronizzazione indiretta) tra threads.

Potrebbero piacerti anche