Java mette a disposizione la classe Thread e l`interfaccia Runnable

L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
INTRODUZIONE AL MECCANISMO DEI THREAD DI JAVA
Java mette a disposizione la classe Thread e l’interfaccia Runnable per consentire
l'implementazione di tipi thread definiti dall'utente. In entrambi i casi, un thread esegue
un algoritmo specificato da un metodo run() e va in esecuzione concorrente con gli altri
thread esistenti. Sia Thread che Runnable fanno parte del package java.lang e quindi
sono importati automaticamente.
public class TipoThread extends Thread {
...
public void run() {
//algoritmo thread generico della classe
}
}
TipoThread t = new TipoThread(...);
In alternativa:
public class AltroTipoThread extends Superclasse implements Runnable {
...
public void run() {
//algoritmo del thread generico
}
}
AltroTipoThread att = new AltroTipoThread(...);
Thread t = new Thread( att );
Questa seconda possibilità è utile allorquando una classe deve necessariamente
estendere un'altra classe diversa da Thread (es. Applet di java.applet). Non essendo
possibile l'ereditarietà multipla, si implementa nel contempo l'interfaccia Runnable.
La classe Thread ammette più costruttori. Di seguito si indicano varie alternative:
Thread()
Thread( istanza-di-classe-che-implementa-Runnable )
Thread( stringa-nome-thread )
Thread( istanza-di-classe-che-implementa-Runnable , stringa-nome-thread )
Per ragioni di debugging è utile agganciare un nome al thread. In fase di trace
dell'esecuzione è poi possibile chiedere il nome di un thread col metodo:
25
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
t.getName()
L'ambiente runtime di Java realizza normalmente, anche se non necessariamente, un
contesto multithread con time-slicing e scheduling preemptivo in presenza di priorità.
Tale schema è disponibile sia su Win che su Solaris 2.x.
All'atto pratico, oltre ai thread introdotti dall'utente, esistono dei thread in background
chiamati deamon: es. il thread per la garbage-collection, ... Per qualificare un thread
come deamon occorre utilizzare il metodo (prima dello start)
t.setDeamon( boolean );
in cui se l’argomento è true.
Comunque sia stato creato, un thread può essere posto in esecuzione (ready-to-run)
con il metodo start():
t.start()
Le seguenti operazioni di base consentono di controllare l'esecuzione di thread:
t.suspend();
t.resume();
t.stop();
t.join();
yield();
//sospende l'esecuzione di t - deprecated
//fa riprendere l'esecuzione di t - deprecated
//forza la terminazione di t - deprecated
//consente di attendere sino alla terminazione di t
//il thread corrente cede il controllo ad altri thread
t.setPriority( thread_priority); //fissa la priorità di t
t.getPriority(); //ritorna la priorità di t
Le priorità possono variare in un intervallo tipo 1-10, identificato dalle costanti
Thread.MIN_PRIORITY e Thread.MAX_PRIORITY. Come default, i thread prendono
tutti la stessa priorità Thread.NORM_PRIORITY (esempio, 5). Lo scheduler della
virtual machine di Java dispaccia sempre sulla CPU il (o uno dei) thread ready-to-run
con la massima priorità.
Thread.currentThread();
//ritorna l'dentità del thread corrente
Thread.sleep( delay );
//arresta l'esecuzione del thread corrente per delay (millisec)
26
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
Verifica dell'ambiente multithread utilizzato
Si consideri la seguente classe che definisce un semplice tipo thread “corridore”:
class Runner extends Thread {
public Runner( String nome ) { super( nome ); }
public void run() {
for(int tick=0; tick<400000; tick++)
if(tick%15000==0)
System.out.println("Runner: "+getName()+" tick: "+tick );
}
}
ed il seguente programma di test che crea e attiva due corridori:
public class TestYourJavaRunTimeSystem {
public static void main( String args[] ){
Runner r1=new Runner( "r1" );
Runner r2=new Runner( "r2" );
r1.start();
r2.start();
}
}
Se l'ambiente multithread disponibile è non-preemptivo, un possibile output consiste
delle seguenti linee:
Runner: r1 tick: 0
Runner: r1 tick: 15000
Runner: r1 tick: 30000
Runner: r1 tick: 45000
Runner: r1 tick: 60000
...
Runner: r1 tick: 390000
Runner: r2 tick: 0
Runner: r2 tick: 15000
Runner: r2 tick: 30000
Runner: r2 tick: 45000
...
Runner: r2 tick: 390000
ossia prima appare l'intero output del corridore r1 quindi quello di r2. Se invece il
sistema è preemptive con time-slicing, ogni thread esegue per un quanto di tempo,
27
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
quindi è prelazionato dallo scheduler che lancia l'esecuzione dell'altro thread e cosi
via. L'output in tal caso consiste in scritte interfogliate generate dai due thread.
Terminazione di un'applicazione multithread
Le prime versioni del JDK (Java Development Kit) avevano il problema che anche in
seguito alla terminazione dei vari thread utente, l'applicazione non terminava. Per
trattare questi casi sono possibili i provvedimenti:
• forzare dall'esterno la terminazione con CTRL-C
• inserire nell'ultimo thread una chiamata alla funzione System.exit( int exitCode )
• monitorare l'esecuzione dell'applicazione multithread mediante l'utilizzo del metodo
join() la cui invocazione deve essere preparata a gestire l'eccezione
InterruptedException.
Con riferimento alla terza possibilità, si mostra di seguito una versione modificata della
classe di test per i thread corridori:
public class TestYourJavaRunTimeSystem {
public static void main( String args[]) {
Runner r1=new Runner( "r1" );
Runner r2=new Runner( "r2" );
r1.start();
r2.start();
try {
r1.join(); r2.join();
}catch(InterruptedException ignored) {}
}
}
Un’applicazione Java termina se essa consiste di soli thread deamon.
28
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
Thread cooperanti e meccanismi di sincronizzazione
Normalmente i thread di un'applicazione non sono indipendenti come i corridori
dell'esempio precedente. Piuttosto essi collaborano al raggiungimento di un obiettivo
globale e necessitano di comunicazione e cooperazione.
Fondamentali sono a questo proposito le operazioni di sincronizzazione nell'accesso a
risorse condivise. Una classe è detta thread-safe se può essere acceduta
contemporaneamente da un insieme di thread. Gran parte della libreria di Java è
thread-safe.
In Java la sincronizzazione è essenzialmente assicurata dall’adozione di un
meccanismo di monitor alla Hoare. In altre parole, ciascuna istanza di una classe
thread-safe ha associato un monitor e i metodi (tutti o alcuni) della classe possono
essere eseguiti in mutua esclusione (entry procedure). La sintassi è:
public class ThreadSafe ... {
...
public synchronized void method-1() {
//sezione critica
}
...
public synchronized return-type method-j() {
//sezione critica
}
...
}
Resta garantito che i dati interni dell'oggetto-istanza saranno acceduti da un solo
thread per volta. Naturalmente, quanto più un metodo è lungo, tanto più altri thread
possono essere costretti ad attendere l'ingresso nel monitor. Java offre anche il blocco
synchronized per restringere l’estensione della sezione critica ad una parte propria di
un metodo:
synchronized( oggetto ) {
//sezione critica
}
dove oggetto può essere this. L’oggetto denota l'istanza della classe thread-safe su cui
un lucchetto è posto allorquando un thread riesce ad ottenere l'accesso al monitor.
29
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
Di seguito si esemplifica l'utilizzo del costrutto monitor primitivo di Java. L'esempio si
riferisce al classico problema Produttore-Consumatore che comunicano con un buffer
limitato.
Buffer limitato per il caso 1 produttore/1 consumatore
class BufferLimitato {
private int n;
private int[] buffer;
private int in, out, count;
public BufferLimitato( int n ){ this.n=n; buffer=new int[n]; in=0; out=0; count=0; }
public synchronized int get() {
if(count==0)
try { wait(); }catch (InterruptedException ignored) { }
int ans=buffer[out]; out=(out+1)%n; count--;
notify(); return ans;
}
public synchronized void put( int value ) {
if(count==n) try { wait(); }catch (InterruptedException ignored) { }
buffer[in]=value; in=(in+1)%n; count++;
notify();
}
}//BufferLimitato
class Producer extends Thread {
private BufferLimitato b;
private int number;
String nome;
public Producer( BufferLimitato b, int number, String nome ) {
super(nome); this.nome=nome; this.number=number; this.b=b;
}
public void run() {
for(;;){
b.put(number);
System.out.println("Producer"+nome+" produces item# "+number);
number++;
try{ sleep( (int)(Math.random()*500) ); }catch(InterruptedException e){}
}
}
}//Producer
30
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
class Consumer extends Thread {
private BufferLimitato b;
private String nome;
public Consumer( BufferLimitato b, String nome ) {
super(nome); this.b=b; this.nome=nome;
}
public void run() {
for(;;) {
int value=b.get();
System.out.println("Consumer "+nome+” receives “+value);
try{ sleep( (int)(Math.random()*900) ); }catch(InterruptedException e){}
}
}
}//Consumer
public class TBL {
public static void main(String args[]) {
BufferLimitato b=new BufferLimitato(5);
Producer p1=new Producer( b, 10, “Pippo” );
Consumer c1=new Consumer( b, “Pluto” );
p1.start(); c1.start();
}
}
L'esempio fa uso delle operazioni wait() e notify() definite da Object. L'operazione wait
pone un thread in attesa del verificarsi di una condizione. Dunque il thread si blocca sul
monitor considerato e contemporaneamente rilascia la mutua esclusione. L'operazione
notify() segnala che una condizione possibilmente è cambiata e che dunque qualche
thread in attesa può riprendere l'esecuzione riguadagnando il monitor.
Osservazione 1: il costrutto monitor di Java si ispira al monitor di Hoare ma non utilizza
varibili condition esplicite. Piuttosto è il programmatore che definisce ed interroga le
condizioni logiche che consentono l’effettuazione delle operazioni sulla struttura dati
protetta dal monitor.
Osservazione 2: un thread svegliato da notify() viene a trovarsi nella stessa situazione
di un qualunque thread che voglia entrare nel monitor. Il thread svegliato, in sostanza,
non ha alcun privilegio rispetto agli altri. Il suo punto di ripresa del controllo è
l’istruzione immediatamente seguente la wait.
Osservazione 3: poiché non è noto l’ordine dei risvegli, può essere opportuno talvolta
svegliare tutti i thread in attesa (che hanno cioè eseguito wait) con l’operazione
31
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
notifyAll(). Di questi uno potrà proseguire perché la sua condizione è soddisfatta, gli
altri torneranno in wait.
Dalle osservazioni 1-3 segue lo schema canonico di interrogazione di una condizione
in un metodo synchronized:
while( !condition ) {
...
try{ ... wait(); ... }catch(InterruptedException e){}
...
}
Nel caso della classe BufferLimitato si è potuto seguire una implementazione
semplificata proprio per l’esistenza di un solo produttore ed un solo consumatore.
Un thread in attesa (wait) si risveglia o per effetto di un’operazione notify[All] o per
un’eccezione InterruptedException. Una tale eccezione si solleva, ad es., se il metodo
wait è interrotto nel mezzo delle sue operazioni: es. il lucchetto è stato sbloccato ma il
thread che sta eseguendo wait perde il controllo per esaurimento del time-slice. In
queste situazioni critiche il thread non è andato ancora in wait e può ritestate la
condizione e comportarsi di conseguenza. Un’altra possibilità per la generazione
dell’eccezione si ha quando un thread in wait riceve un segnale interrupt mediante il
metodo
t.interrupt();
Per altre informazioni su questo ed altri metodi affini si rimanda alla documentazione di
Java.
Buffer Limitato per il caso generale N produttori/M consumatori
public class BufferLimitato {
private int n;
private int[] buffer;
private int in, out, count;
public BufferLimitato( int n ){ this.n=n; buffer=new int[n]; in=0; out=0; count=0; }
public synchronized int get() {
while(count==0) try { wait(); }catch (InterruptedException ignored) {}
int ans=buffer[out]; out=(out+1)%n; count--; notifyAll(); return ans;
}
public synchronized void put( int value ) {
while(count==n) try { wait(); }catch (InterruptedException ignored) { }
buffer[in]=value; in=(in+1)%n; count++;
notifyAll();
32
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
}
}//BufferLimitato
Normalmente, un’operazione di wait si accoppia con una corrispondente operazione di
notify. In particolare, allorquando, all’interno di un metodo synchronized, si raggiunge
la consapevolezza che lo stato dell’oggetto è cambiato e che qualche thread in wait
può essere risvegliato, si invoca l’operazione notify. L’operazione notifyAll è giustificata
nei casi complessi nei quali più thread possono essere in wait e per una varietà di
condizioni per cui, in assenza di condition separate, svegliare tutti i thread in attesa
rappresenta un modo per far riprendere il thread con la condizione soddisfatta. La
versione del BufferLimitato per N produttori/M consumatori utilizza notifyAll. L’uso di
notifyAll, a parte ogni problema di efficienza, è giustificato in quanto assicura generalità
all’implementazione.
L’operazione wait esiste anche nelle due varianti che seguono:
wait( long timeout )
wait( long timeout, int nanos )
che specificano che l'attesa di un thread cessa o con una notify[All] o allorquando
scade un certo intervallo di tempo (timeout), in millisecondi, con in più una precisione
espressa in nanosecondi.
Osservazione
Java associa un wait-set ad un oggetto guardato da un monitor. Tale wait-set è
utilizzato per sospendervi i thread che per qualunque ragione trovano il monitor
indisponibile e dunque devono attendere. Quindi anche i thread esterni che fanno il
tentativo di ingresso e trovano il monitor occupato vanno sul wait-set. Tuttavia tali
thread non sono in stato di wait. Il loro risveglio è garantito indipendentemente dalle
operazioni notify[All].
La specifica del linguaggio non stabilisce per il wait-set alcuna politica particolare dei
risvegli. Un’implementazione potrebbe basarsi sulla strategia FIFO, su una strategia
casuale etc.
33
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
Una classe semaforo contatore
E’ noto che i meccanismi per la programmazione concorrente sono equivalenti. Di
seguito si propone un utilizzo del costrutto monitor di Java per una simulazione naif del
semaforo generalizzato.
//file SemaforoContatore.java -- provvisorio
package sde.semaforo;
public class SemaforoContatore {
private int contatore;
public SemaforoContatore( int contatore ){ this.contatore=contatore; }
public synchronized void P(){
if( --contatore <0 )
try{ wait(); }catch( InterruptedException ignored ){}
}//P
public synchronized void V(){
if( ++contatore <=0 ) notify();
}//V
}//SemaforoContatore
Per semplicità sono stati ignorati i problemi derivanti dall’insorgenza di una
InterruptedException a seguito di una wait interrotta a metà. Un’implementazione più
robusta verrà mostrata più avanti.
Una classe semaforo binario
//file BadInitializationException.java
package sde.semaforo;
public class BadInitializationException extends RuntimeException { }
//file SemaforoBinario.java -- provvisorio
package semaforo;
public class SemaforoBinario {
private int contatore, inAttesa=0;
public SemaforoBinario( int contatore ) throws BadInitializationException {
if( contatore<0 || contatore>1 ) throw new BadInitializationException();
this.contatore=contatore;
}
34
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
public synchronized void P(){
if( contatore==0 ) //semaforo rosso
try{ inAttesa++; wait(); inAttesa--; } catch(InterruptedException ignored){}
contatore=0;
}//P
public synchronized void V(){
if( contatore==0 ){
if( inAttesa==0 ) contatore=1;
else notify();
}
}//V
};//SemaforoBinario
Un errore di inizializzazione di un semaforo binario è segnalato mediante un’eccezione
unchecked in modo da non costringere l’utente a dover esplicitamente trattarla.
L'applicazione Produttore-Consumatore con i semafori
//file BoundedBuffer.java
package sde.boundedbuffer;
import sde.semaforo.*;
class BoundedBuffer {
private int in, out, len;
private int n;
private int[] buffer;
private SemaforoContatore mutex, full, empty;
public BoundedBuffer( int n ) {
this.n=n; buffer=new int[n]; in=0; out=0; len=0;
mutex=new SemaforoContatore( 1 );
full=new SemaforoContatore( 0 );
empty=new SemaforoContatore( n );
}
public void put( int item ){
empty.P();
mutex.P();
buffer[in]=item; in=(in+1)%n; len++;
System.out.println(“Producer “+Thread.currentThread().getName()+” put item: “+item );
mutex.V();
full.V();
}//put
public int get(){
full.P();
mutex.P();
int item=buffer[out]; out=(out+1)%n; len--;
System.out.println("Consumer "+Thread.currentThread.getName()+ " got item: " + item);
mutex.V();
35
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
empty.V();
return item;
}//get
}//BoundedBuffer
Una classe Produttore
class Producer extends Thread {
private BoundedBuffer b;
public Producer( BoundedBuffer b, String name ) { super(name); this.b=b; }
public void run(){
for(int i=0; i<20; i++)
b.put( i );
}
}//Producer
Una classe Consumatore
class Consumer extends Thread {
private BoundedBuffer b;
public Consumer( BoundedBuffer b, String name ) { super(name); this.b=b; }
public void run() {
for(int i=0; i<20; i++){
int value=b.get(); consume value;
}
}
}//Consumer
Una classe di test
public class TestProdCons {
public static void main(String args[]) {
BoundedBuffer b=new BoundedBuffer();
Producer p=new Producer( b, "Producer #1" );
Consumer c=new Consumer( b, "Consumer #1" );
p.start();
c.start();
}
}//TestProdCons
Si osserva che l’implementazione dei thread producer/consumer è stata resa
indipendente dall’implementazione del buffer utilizzando i semafori direttamente
all’interno delle operazioni get/put.
36
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
Ancora sull’implementazione dei semafori
Si presenta ora una differente implementazione del semaforo binario. La nuova
realizzazione è più complessa ma realistica e si caratterizza per una gestione esplicita
di tipo FIFO dell’ordine dei risvegli. Tale formulazione, disponibile nel software di
supporto al corso, verrà assunta come riferimento nel seguito. In modo perfettamente
analogo si implementa il semaforo contatore.
//file SemaforoBinario.java
package sde.semaforo;
import java.util.*;
class Coppia{
Thread id;
boolean sveglia;
Coppia( Thread id, boolean sveglia ){
this.id=id; this.sveglia=sveglia;
}
}
public class SemaforoBinario {
private int contatore;
private List listaDiAttesa=new LinkedList();
public SemaforoBinario( int contatore ) throws BadInitializationException {
if( contatore<0 || contatore>1 ) throw new BadInitializationException();
this.contatore=contatore;
}
public synchronized void P(){
if( contatore==0 ) {
listaDiAttesa.add( new Coppia(Thread.currentThread(), false) );
while( true ){
try{
wait();
}catch(InterruptedException ignored){}
Coppia c=(Coppia)listaDiAttesa.get(0);
if( c.id==Thread.currentThread() && c.sveglia ){
listaDiAttesa.remove(0);
if( listaDiAttesa.size()>0 &&
((Coppia)listaDiAttesa.get(0)).sveglia ) notifyAll();
break;
}
}
}
37
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
contatore=0;
}//P
public synchronized void V(){
if( listaDiAttesa.size()==0 ) contatore=1;
else{
int i;
for( i=0; i<listaDiAttesa.size(); i++ )
if( !((Coppia)listaDiAttesa.get(i)).sveglia ) break;
if( i<listaDiAttesa.size() )
((Coppia)listaDiAttesa.get(i)).sveglia=true;
notifyAll();
}
}//V
}//SemaforoBinario
La classe SemaforoBinario utilizza un Vector gestito in modo FIFO come lista di attesa
dei thread. Ovviamente, a seguito di una V, solo il thread in testa alla lista deve
effettivamente svegliarsi. Tutto ciò è controllato da una booleana sveglia che vale true
per il thread da svegliare false per gli altri. Si capisce quindi la necessità di
inserire/estrarre dal vector coppie di informazioni: il riferimento al thread ed il valore
della boolean sveglia.
Considerato che mentre un thread P è in fase di sveglia, un altro Q potrebbe essere
svegliato da una ulteriore “ravvicinata” operazione di V, e potrebbe Q avere nel
contempo già verificato il suo tentativo di risveglio ed essere tornato in wait, si
responsabilizza il thread P ad accertare questa situazione (esiste cioè valore true per
la sveglia del prossimo thread) e ri-eseguire la notifyAll.
38
L. Nigro – Ingegneria del Software per Sistemi in Tempo Reale
Sui metodi deprecati della classe Thread
Metodi come stop() e suspend() sono deprecati (cioè se ne sconsiglia l’uso) nelle
versioni recenti di Java in quanto possono accompagnarsi a rischi di deadlock. Ad es.
eseguendo dall’esterno una stop su un thread che detiene un lucchetto su una
struttura dati, si farà terminare il thread ma a nessun altro thread sarà più possibile
accedere alla struttura dati. Simili argomentazioni si possono ripetere per suspend. Il
metodo resume è deprecato in quanto lo è suspend a cui logicamente si accompagna.
Esercizi
1. Si consideri la versione della classe BufferLimitato allorquando sussistono N
produttori, M consumatori, la dimensione del buffer è unitaria e i risvegli sono basati su
notify. Si analizzi il seguente scenario. Un produttore (veloce) entra, riempie il buffer.
Successivamente va in wait. Gli altri N-1 produttori tentano di depositare e vanno in
wait. Un consumatore entra, preleva l’elemento e notifica. Tuttavia, prima che il
produttore risvegliato riprenda il controllo del monitor, gli altri M-1 consumatori tentano
di prelevare e vanno pure essi in wait. Il produttore svegliato produce un nuovo
elemento, riempie il buffer e notifica. Quindi va in wait. Se il nuovo thread notificato è
un produttore cosa si può dire dell’evoluzione futura del sistema ? Che interventi si
possono intraprendere ? Cosa cambia se l’implementazione di Java assicura una
gestione FCFS del wait set del buffer limitato ?
2. Utilizzando esclusivamente semafori (l’uso di contatori, booleani, etc. e di istruzioni if
while etc. è vietato) disciplinare un gruppo di quattro thread, di cui tre istanze di una
classe A ed uno istanza di classe B, in modo che sia garantita la seguente sequenza di
terminazione: A B A A. Più in particolare, progettare il metodo run delle classi A e B in
modo da basarsi unicamente su un certo numero di semafori opportunamente
inizializzati e sulle operazioni P e V per ottenere il risultato voluto.
3. Come 2. ma utilizzando tre istanze di A e due di B. La sequenza desiderata di
terminazione deve essere: A B A A B.
39