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