4 - Multithread
4 - Multithread
I sistemi operativi meno recenti sono sistemi a multitasking cooperativo, mentre i più recenti sono
sistemi a multitasking preemptive.
I programmi multithread estendono il concetto del multitasking a un livello più basso: sembra che
i singoli programmi possano eseguire più attività contemporaneamente. Ogni attività prende di
solito il nome di thread. I programmi che sono in grado di eseguire più di un thread alla volta sono
detti programmi multithread. In sostanza, la differenza principale tra processi multipli e thread
multipli riguarda il fatto che, mentre ogni processo prevede un set completo di variabili proprie, i
thread condividono gli stessi dati.
I programmi GUI (Graphical User Interface) prevedono un thread separato per raccogliere gli
eventi dell’interfaccia grafica che provengono dal sistema operativo ospite.
Esempio thread
Conviene iniziare lo studio esaminando un programma che non utilizza thread multipli. Questo
programma anima una pallina che rimbalza spostandola continuamente.
Il metodo statico sleep della classe Thread definisce una pausa per un determinato numero di
millisecondi (parametro DELAY), cioè interrompe temporaneamente l’attività del thread corrente.
Inoltre può generare un’eccezione InterruptedException, che verrà discussa più avanti.
Utilizzare thread multipli
Mostreremo adesso come è facile realizzare lo stesso programma in modo che venga eseguito in
più thread, in particolare mandando in esecuzione il codice che sposta la pallina in un thread
separato. In effetti, sarà possibile lanciare più palline, ciascuna delle quali sarà mossa da un
proprio thread. Il thread principale è in grado di verificare se un utente fa click sul pulsante ‘close’
mentre le palline stanno rimbalzando e può così elaborare l’azione “di chiusura”.
Di seguito è riportata una semplice procedura che permette di eseguire un’attività in un thread
separato:
1. Si inserisce il codice dell’attività nel metodo run di una classe che implementa l’interfaccia
Runnable. Esempio:
Quando si chiama il metodo interrupt su un thread, viene impostato lo stato di interruzione del
thread. Si tratta di un flag booleano che è presente in ogni thread e ogni thread deve verificare
periodicamente se è stato interrotto.
Per scoprire se è stato impostato lo stato di interruzione, si deve prima chiamare il metodo
statico Thread.currentThread() per acquisire il thread corrente e poi chiamare il metodo
isInterrupted come segue:
Tuttavia se un thread è bloccato, la chiamata che blocca, per esempio sleep o wait, viene
terminata da un’eccezione InterruptedException poiché non si può verificare lo stato di
interruzione.
Il linguaggio non richiede che un thread interrotto debba essere terminato. Infatti il thread
interrotto può decidere come reagire all’interruzione stessa. Tuttavia è abbastanza comune che un
thread voglia semplicemente interpretare un’interruzione come una richiesta di conclusione.
Il metodo interrupted è un altro metodo statico che verifica se il thread corrente è stato
interrotto. Inoltre pulisce lo stato di interruzione ripristinandolo a false.
Stati dei thread
I thread possono si possono trovare in uno degli stati indicati di seguito:
Nuovo: quando si crea un thread con l’operatore new, il thread non è ancora in esecuzione
quindi si trova nello stato di nuovo.
Runnable: dopo aver richiamato il metodo start, il thread si trova nello stato runnable.
Bloccato: si trova in questo stato quando si verificano una delle seguenti azioni:
Ogni thread ha una priorità. L’ impostazione predefinita prevede che un thread erediti la priorità
del suo thread genitore, cioè il thread che lo ha avviato.
Tuttavia le priorità dei thread dipendono fortemente dal sistema. Ad esempio Windows XP
prevede 7 livelli di priorità mentre nella macchina virtuale SUN per Linux le priorità
vengono ignorate e tutti i thread hanno la stessa priorità.
NB: se si utilizzano le priorità, si deve tenere conto di un errore comune iniziale. Se ci sono diversi
thread che hanno alta priorità e si bloccano raramente, i thread che hanno bassa priorità non
vengono mai eseguiti.
Thread demoni
Un thread demone è semplicemente un thread che ha il solo scopo di servirne altri. Quando
rimangono solo thread demoni, si esce dalla macchina virtuale poiché non c’è motivo di
mantenere in esecuzione il programma essendo questi thread “di supporto”.
t.setDaemon(true);
Alcuni programmi contengono un buon numero di thread e può essere utile classificarli in
categorie (gruppi) in base alle loro funzionalità. Si consideri per esempio un browser Internet. Se
molti thread tentano di scaricare immagini da un server e l’utente fa click su un pulsante ‘stop’ per
interrompere il caricamento della pagina corrente, è comodo avere un modo per interrompere
tutti questi thread simultaneamente.
Per scoprire se tutti i thread di un determinate gruppo sono ancora nello stato runnable:
Per evitare il danneggiamento dei dati condivisi da più thread, è necessario apprendere come
sincronizzare gli accessi. Vedremo prima cosa succede se non si imposta un’adeguata
sincronizzazione e successivamente come sincronizzare l’accesso ai dati. Analizzeremo quindi le
due versioni (unsynch e synch) di un programma che simula una banca che gestisce un certo
numero di conti di deposito e si generano transazioni casuali di spostamento di denaro tra questi
conti.
Nella prima versione, quella senza sincronizzazione (unshynch) si può scoprire che si verificano
errori quasi subito oppure dopo un pò di tempo: alcune somme di denaro spariscono o vengono
create spontaneamente.
accounts[to] += amount;
Il problema è legato al fatto che questo sono operazioni atomiche. L’istruzione può essere
elaborata come segue:
A questo punto si supponga che il primo thread esegua i passi 1 e 2 e poi si interrompa. Si
supponga ora che si attivi il secondo thread, che aggiorna la stessa voce nell’array accounts. A
questo, si riattiva il primo thread che porta a termine il passo 3. Quest’ultima azione distrugge le
modifiche dell’altro thread. In questo modo si ottiene un risultato che non è più corretto.
Il vero problema riguarda il fatto che il compito del metodo transfer può essere interrotto in un
qualsiasi momento. Se si potesse garantire che il metodo venga eseguito fino alla fine prima che il
thread possa perdere il controllo, lo stato dei conti in banca non risulterebbe mai danneggiato.
Lock e Condition: bloccare gli oggetti
A partire da JDK 5.0, ci sono due meccanismi che permettono di proteggere un blocco di codice
rispetto ad accessi concorrenti. Le precedenti versioni di Java utilizzavano la parola chiave
synchronized mentre JDK 5.0 ha introdotto la classe ReentrantLock. La parola chiave synchronized
mette a disposizione automaticamente un lock associato ad una condizione. Sarà più facile
comprendere la parola chiave synchronized dopo aver visto i lock e le condizioni singolarmente.
Oggetti lock
Questa condizione garantisce che solo un thread alla volta possa entrare nella sezione
critica. Non appena un thread acquisisce il lock sull’oggetto myLock, nessun altro thread
può superare l’istruzione lock fino a quando il primo thread non sblocca l’oggetto myLock.
Si può utilizzare un blocco per proteggere il metodo transfer della classe Bank:
...
private Lock bankLock = new ReentrantLock(); // ReentrantLock implementa Lock
public void transfer(int from, int to, double amount) {
bankLock.lock();
try {
// uguale al blocco del metodo transfer
}
finally {
bankLock().unlock();
}
}
Si ipotizzi che un thread chiami transfer e venga sospeso prima di completare l’esecuzione. Si
consideri che anche un secondo thread chiami transfer. Il secondo thread non può acquisire il lock
e rimane bloccato sulla chiamata del metodo lock (invocato su bankLock).
Osservare che ogni oggetto Bank ha il proprio ReentrantLock: solo se due thread tentano di
accedere allo stesso oggetto Bank, il lock ha lo scopo di serializzare il processo.
Il lock si dice rientrante perché un thread può acquisire ripetutamente un lock che già possiede.
Oggetti condizione
Può succedere che un thread entri in una sezione critica solo per scoprire che non può proseguire
fino a quando non si verifica una determinata condizione. Si utilizza un oggetto condizione per
gestire i thread che hanno acquisito un lock ma non possono svolgere un’attività concreta. Per
motivi storici gli oggetti condizioni vengono anche chiamati variabili condizione.
Nel nostro programma, ad esempio, non si vuole trasferire denaro da un conto che non ha i fondi
sufficienti per coprire il trasferimento.
E’ molto probabile che il controllo passi a un altro thread mentre si è tra l’uscita del test e
la chiamata del metodo transfer.
Per essere sicuri che nessun altro thread possa modificare il saldo tra il test e l’inserimento
si può utilizzare un lock:
bankLock.lock();
try {
while (accounts[from] < amount) {
// pausa
}
// trasferisce i fondi
}
finally {
bankLock.unlock();
}
Se non c’è abbastanza denaro in un conto si aspetta fino a quando altri thread hanno
aggiunto fondi sufficienti. Tuttavia, questo thread ha appena acquisito l’accesso esclusivo a
bankLock, per cui nessun thread ha la possibilità di effettuare un deposito. Questa è la
situazione nella quale vengono utili gli oggetti condizione.
Un oggetto lock può essere associato a uno a più oggetti condizione. Si ottiene un oggetto
Condition con il metodo d’istanza newCondition della classe Lock:
...
Private Condition sufficientFunds;
public Bank(. . .) {
...
sufficientFunds = bankLock.newCondition();
}
Se il metodo transfer scopre che non sono disponibili fondi sufficienti, chiama
sufficientFunds.await();
A questo punto si blocca il thread corrente e si attiva il lock in modo che altri thread hanno
la possibilità di aumentare il saldo del conto.
Dopo che il metodo ha chiamato il metodo await, entra in await set relativo a questa condizione.
Il thread non viene sbloccato quando è disponibile il lock, ma rimane bloccato fino a quando un
altro thread non chiama il metodo signalAll sulla stessa condizione .
sufficientFunds.signalAll();
In generale, una chiamata di await si deve trovare sempre all’interno di un ciclo simile a
quello indicato di seguito
NB: è molto importante che qualche altro thread chiami il metodo signalAll. Quando un thread
chiama await non ha più modo di sbloccare se stesso e si affida agli altri thread. Se nessuno di
questi si occupa di sbloccare il thread in attesa, questo non viene più eseguito e ciò può portare a
spiacevoli deadlock.
Quando si chiama signalAll? La regola generale afferma che: si deve chiamare ogni volta che lo
stato di un oggetto cambia in un modo che può risultare vantaggioso per i thread in attesa, nel
nostro caso ogni volta che cambia il saldo di un conto.
Ritornando al blocco del metodo transfer quindi:
bankLock.lock();
try {
while (accounts[from] < amount)
sufficientFunds.await();
// trasferisce i fondi
finally {
bankLock.unlock();
}
}
Per completare diciamo che un thread può chiamare await, signalAll oppure signal (sblocca un
solo thread a caso dall’await set) solo su un oggetto condizione di cui possiede il lock.
Ogni oggetto condizione gestisce i thread che sono entrati in una sezione di codice protetta
ma non sono in grado di proseguire.
Prima che venissero aggiunte le interfacce Lock e Condition in JDK 5.0, il linguaggio Java utilizzava
un meccanismo di concorrenza diverso (synchronized) che verrà illustrato nel prossimo paragrafo.
La parola chiave synchronized
Ogni oggetto in Java ha un lock implicito. Se un metodo è dichiarato con la parola chiave
synchronized, il lock dell’oggetto protegge l’intero metodo.
In altre parole, per chiamare il metodo un thread deve acquisire il lock dell’oggetto. Quindi:
è equivale a:
Per esempio, invece di utilizzare un lock esplicito (bankLock) si può dichiarare semplicemente il
metodo transfer della classe SynchBank come synchronized.
condizioneImplicita.await();
condizioneImplicita.signalAll();
Esempio metodo transfer synchronized
Si può osservare che l’utilizzo della parola chiave synchronized produce codice che risulta molto
più conciso. Ovviamente, per comprendere questo codice si deve sapere che ogni oggetto ha un
lock implicito e che il lock ha un condizione implicita:
Tuttavia, i blocchi e le condizioni implicite hanno certi limiti, tra cui i seguenti:
Le primitive di lock della macchina virtuale non si accoppiano fedelmente con i meccanismi
di lock disponibili nell’hardware.
Cosa si deve utilizzare nel proprio codice, oggetti Lock e Condition oppure metodi synchronized?
2. Se la parola chiave synchronized può andare bene in una situazione, conviene utilizzarla. Si
scrive meno codice e ci sono meno possibilità di errore.
E’ ammesso dichiarare metodi statici come sincronizzati. Se si chiama un metodo di questo tipo, si
acquisisce il lock dell’oggetto class associato. Per esempio, se la classe Bank ha un metodo statico
sincronizzato, quando lo si chiama si ottiene anche il lock dell’oggetto Bank.class.
Campi volatili
A volte può sembrare eccessivo pagare il prezzo della sincronizzazione solo per leggere o scrivere
uno o due campi istanza. La parola chiave volatile propone un meccanismo indipendente dai lock
che permette di accedere in modo sincronizzato a un campo istanza.
Si consideri per esempio un oggetto che abbia un flag done di tipo boolean impostato da
un thread ed esaminato da un altro thread. Ci sono due possibilità:
3. Il campo è volatile
if (myLock.tryLock()) {
//ora il thread possiede il lock
try { . . . }
finally {
myLock.unlock();
}
}
else
// esegue altre istruzioni
if (myLock.tryLock(100, TimeUnit.MILLISECONDS)) . . .
myCondition.await(100, TimeUnit.MILLISECONDS)) . . .
Il metodo await ritorna se un altro thread ha attivato questo thread chiamando signalAll
oppure signal, oppure se è trascorso il timeout o se è stato interrotto il thread.
Thread e Swing
Uno dei motivi per cui si utilizzano i thread nei programmi è legato al fatto che questi diventano
reattivi. Quando un programma deve svolgere del lavoro che richiede tempo, si può attivare un
altro thread così da non bloccare l’interfaccia utente.
Tuttavia, si deve fare attenzione a ciò che fa un thread, perché Swing non è thread-safe. Se si
cerca di elaborare gli elementi dell’interfaccia utente con thread multipli, si rischia di danneggiare
l’interfaccia utente. I progettisti Swing hanno deciso di non compiere lo sforzo di rendere Swing
thread-safe per un motivo in particolare:
Ogni applicazione Java inizia con un metodo main che viene eseguito nel thread principale. In un
programma Swing il metodo main esegue di solito le operazioni indicate di seguito:
Quando viene mostrata la prima finestra, si crea un secondo thread, il thread di distribuzione
degli eventi (EventDispatcher) che esegue tutte le notifiche di eventi, per esempio le chiamate di
actionPerformed o paintComponent. Questo thread termina la sua esecuzione quando si esce dal
metodo main.
Regole da osservare
1. Se un’azione impiega molto tempo, si attiva un nuovo thread per svolgere l’attività.
2. Se un’azione può bloccare un input o un output, si attiva un nuovo thread: non si vuole
congelare l’interfaccia utente per un tempo potenzialmente infinito in attesa, ad esempio,
di una connessione in rete che non risponde.
3. I thread generati non devono modificare l’interfaccia utente. Conviene avviarli, completarli
e poi aggiornare l’interfaccia utente dal thread di distribuzione degli eventi.
Coda di eventi: EventQueue
Si consideri a questo punto di attivare un thread separato per svolgere un’attività che richiede
tempo. Si può, ad esempio, voler aggiornare l’interfaccia utente per indicare lo stato di
avanzamento dei lavori svolti nel thread. Ma il thread corrente non può toccare i componenti
dell’interfaccia grafica.
Per risolvere questo problema si possono utilizzare due metodi di supporto che consentono di
aggiungere azioni arbitrarie nella coda di eventi (EventQueue).
o invokeLater: dopo che l’evento viene immesso nella coda di eventi ritorna subito il
controllo al thread principale: il metodo run viene eseguito in modo asincrono.
Nel caso in cui si voglia aggiornare un’etichetta che indichi l’avanzamento dei lavori conviene
utilizzare il metodo invokeLater: gli utenti preferiscono far avanzare il thread di lavoro piuttosto
che avere un indicatore di avanzamento dei lavori più accurato.
EventQueue.invokeLater(new
Runnable() {
public void run() {
label.setText(percentage + “% complete”);
}
});
Un esempio: SwingWorkerTest
Il programma di esempio SwingWorkerTest prevede comandi per caricare un file di testo e per
cancellare il processo di caricamento del file. Conviene provare il programma con un file grande,
per esempio il testo completo di un libro. Le attività svolte sono le seguenti:
2. Mentre si legge il file, la voce ‘Open’ del menu è disattivata, mentre è attiva la voce
‘Cancel’;
3. Dopo aver letto ogni riga, viene aggiornato un contatore di riga nella barra di stato;
2. Dopo ogni unità di lavoro, aggiorna l’interfaccia grafica per mostrare l’avanzamento dei
lavori (update),
3. Dopo avere completato il lavoro, effettua la modifica finale dell’interfaccia grafica (finish).
La classe SwingWorkerTask semplifica l’implementazione di queste attività.
Si estende la classe, si imposta l’override dei metodi init, update e finish e si implementa la logica
per gli aggiornamenti dell’interfaccia grafica.
La superclasse contiene i metodi comodi doInit e doUpdate e doFinish che mettono a disposizione
il codice che permette di eseguire questi metodi nel thread di distribuzione di evento.
A questo punto si mette a disposizione un metodo work che contiene il lavoro da svolgere
nell’attività. Nel metodo work è necessario chiamare doUpdate, non update, dopo ogni unità di
lavoro. Per esempio, l’attività di lettura del file prevede questo metodo work: