Nella seconda parte di questa serie di tutorial abbiamo visto come due o più thread in esecuzione parallela possono accedere in lettura e scrittura ad una variabile condivisa, in maniera atomica. In questa terza parte vedremo uno dei più classici scenari di uso della concorrenza, il modello produttore – consumatore.
Modifichiamo l’esempio del web server in questo modo. Supponiamo che esista un thread (o più di uno, ma non è il caso di un web server reale), che riceva le richieste dall’esterno. Per fare in modo che il nostro server sia subito pronto a ricevere altre richieste, decidiamo che questo thread non le gestisce direttamente: le deposita in un buffer. Dopodichè avremo altri thread, o anche uno solo, che si occuperanno di prelevare le richieste dal buffer e gestirle. In questo esempio il produttore è rappresentato dal thread che inserisce le nuove richieste nel buffer, mentre il consumatore è il thread che estrae le richieste dal buffer per gestirle. Ovviamente possono esistere più thread produttori e più thread consumatori.
Chi gestisce il buffer?
Poichè il buffer è unico, deve esistere un soggetto che lo gestisca, e controlli di non perdere richieste e di non dare la stessa richiesta a più thread consumatori. Per questo si introduce il concetto di monitor. Il monitor è un semplice oggetto che incapsula una qualche struttura dati che funge da buffer. In questo esempio useremo un Vector contenente oggetti String, le nostre richieste. I problemi che is pongono sono essenzialmente due. Se il buffer è vuoto, come informo i consumatori che non c’è nessuna richiesta da gestire? E viceversa, se il buffer non è vuoto, come informo i consumatori che esiste una richiesta da gestire? La risposta più semplice è: non li informo, gestisco la loro esecuzione. Più precisamente andrò ad "addormentare" i consumatori se il buffer è vuoto, mentre li risveglierò se ricevo una nuova richiesta.
I metodi wait() e notify()
Il monitor, poichè gestisce il buffer, fornirà dei metodi per il prelevamento di una richiesta dal buffer, e per l’inserimento di una richiesta nel buffer, ovvero prelevaRichiesta() e accodaRichiesta(). Poichè il buffer è una risorsa condivisa questi metodi dovranno essere synchronized.
Supponiamo che un cosumatore tenti di prelevare una richiesta. Il monitor controllerà se ci sono richieste nel buffer, e se non ci sono dovrà addormentare il consumatore. A questo scopo si usa il metodo wait(), che addormenta il thread che ha chiamato il metodo. In pratica si addormenta il thread in attesa che nel buffer arrivi qualcosa.
Supponiamo ora che il produttore tenti di accodare un richiesta. Il monitor inserirà la richiesta nel buffer e dovrà risvegliare eventuali thread dormienti in modo da gestire la richiesta. Esiste quindi il metodo notify(). Esso risveglia il primo thread che era stato addormentato. Usando invece notifyAll() si svegliano tutti i thread in attesa. In questo caso bisogna ricordarsi di riaddormentare tutti quelli per cui non si hanno richieste da gestire.
Il codice
Passiamo al codice. Il monitor sarà di questo tipo:
import java.util.Vector;
public class Monitor {
// Coda delle richieste
private Vector<String> codaRichieste = new Vector<String>();
// Preleva la prima richiesta in coda
public synchronized String prelevaRichiesta(){
while (codaRichieste.size() == 0){
try {
wait();
}
catch (InterruptedException e){
e.printStackTrace();
}
}
return codaRichieste.remove(0);
}
// Accoda una nuova richiesta
public synchronized void accodaRichiesta(String richiesta){
codaRichieste.addElement(richiesta);
notifyAll();
}
}
Notare che nel metodo prelevaRichiesta() non si usa un if ma un while. Ciò permette di riaddormentare eventuali thread svegliati con il notifyAll() del metodo accodaRichiesta() per cui non ci sono richieste. Infatti questi thread verrebbero svegliati, il ciclo while riprenderebbe e ricontrollerebbe la dimensione del buffer. Se questo è in realtà ancora vuoto i thread verrebbero nuovamente addormentati.
E ora i due thread produttore e consumatore, molto simili anche se svolgono azioni nettamente differenti:
public class MyThreadProduttore extends Thread{
private Monitor monitor;
private int id;
public void run(){
int richiesta = 1;
while (true){
try {
Thread.sleep(300);
}
catch (InterruptedException e){};
// accodo una richiesta
monitor.accodaRichiesta("Produttore " + id + ", richiesta " + richiesta);
richiesta++;
}
}
public MyThreadProduttore(Monitor monitor, int id){
this.monitor = monitor;
this.id = id;
}
}
public class MyThreadConsumatore extends Thread {
private Monitor monitor;
private int id;
public void run(){
while (true){
// prelevo la prima richiesta in coda
String richiesta = monitor.prelevaRichiesta();
try {
Thread.sleep(300);
}
catch (InterruptedException e){};
System.out.println(richiesta);
// ... azioni di gestione della richiesta ...
}
}
public MyThreadConsumatore(Monitor monitor, int id){
this.monitor = monitor;
this.id = id;
}
}
Il metodo main è simile a quello della lezione scorsa, eccetto che crea anche i thread produttori:
public class MyMultiThreadedProgram {
public static void main(String args[]){
// creo il monitor
Monitor monitor = new Monitor();
try {
// creo due thread di creazione delle richieste
MyThreadProduttore tp1 = new MyThreadProduttore(monitor, 1);
MyThreadProduttore tp2 = new MyThreadProduttore(monitor, 2);
// Avvio i thread
Thread.sleep(300);
tp1.start();
Thread.sleep(300);
tp2.start();
// creo due thread di gestione delle richieste
MyThreadConsumatore tc1 = new MyThreadConsumatore(monitor, 1);
MyThreadConsumatore tc2 = new MyThreadConsumatore(monitor, 2);
// Avvio i thread
Thread.sleep(300);
tc1.start();
Thread.sleep(300);
tc2.start();
}
catch (InterruptedException e){}
}
}
E’ stato assegnato ad ogni thread un id solamente per facilitare il riconoscimento al momento della stampa dei messaggi. Notare l’uso del metodo statico sleep() per auto-addormentare il thread in modo da poter vedere l’effetto reale a video, che altrimenti sarebbe troppo veloce.
Eseguendo il codice vedrete che le richieste arrivano in modo non sequenziale, in quanto i due produttori sono eseguiti in parallelo e quindi con tempi diversi. I consumatori tuttavia prelevano le richieste nell’ordine in cui sono state inserite nel buffer, e vengono addormentati e svegliati in base alla disponibilità di richieste.