L7-Multithreading in Java-AS.2022-23
L7-Multithreading in Java-AS.2022-23
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].
• 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).
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().
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.
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.
Durante la sua esecuzione il thread può sospendersi per varie cause entrando in
stato bloccato (blocked):
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
2. nel caso in cui diventi runnable un thread a priorità più alta (preemption)
rispetto a quella del thread in esecuzione.
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.
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.
Sempre nel Paragrafo 3.1 è stato mostrato che le interazioni possono essere di
due tipi diversi:
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.
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.
a mutexLock è libero, altrimenti il thread viene sospeso dalla JVM in attesa che
il lock si liberi.
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).
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
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.
• due diversi metodi, uno sincronizzato e uno non sincronizzato, possono essere
eseguiti concorrentemente sullo stesso oggetto.
• 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;
• 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.
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).
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
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.
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
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).
try{
...
}catch (Interrupted Exception e) {
. . .
}
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 ( . . . ) {
...
}
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.