MokaByteNumero
16 - Febbraio 1998
|
|||
|
dei 5 filosofi |
||
Massimo Carli |
Java è un linguaggio orientato al multithreading dotato di costrutti come semafori e monitor che consentono di risolvere gran parte dei problemi di gestione di risorse condivise | ||
La conoscenza delle tecniche relative alla programmazione concorrente non è più caratteristica dei soli programmatori di sistemi operativi, ma è divenuto bagaglio indispensabile di ogni professionista del settore. La concorrenza spinge alla creazione di programmi sempre più efficienti che sfruttano appieno le potenzialità del linguaggio utilizzato. Se poi il linguaggio è multithreaded come Java, è quasi impossibile non essere a conoscenza delle tecniche di creazione di thread e di interazione tra essi. In questo articolo utilizzeremo il tradizionale problema dei cinque filosofi pensanti come pretesto per la descrizione dei concetti fondamentali di programmazione multithreading applicati a Java.
Prima di passare
alla descrizione del problema specifico, vediamo alcuni concetti alla base
della programmazione concorrente. La prima osservazione è relativa
alla distinzione tra i concetti di algoritmo e programma.
Un algoritmo
consiste in un procedimento logico atto alla risoluzione di un problema
attraverso un determinato sistema d'elaborazione, mentre un programma si
riferisce alla descrizione dell'algoritmo attraverso un opportuno formalismo:
il linguaggio di programmazione [1]. Uno stesso algoritmo può
portare alla realizzazione di programmi diversi. Il processo, invece, rappresenta
il procedimento di esecuzione di un programma. In un ambiente multitasking
possiamo avere più processi per uno stesso programma: ad esempio
più finestre dello stesso text editor nel desktop. Ogni processo
dispone della propria memoria e delle proprie risorse che vengono gestite
dal sistema operativo.
Il concetto di thread è molto simile e deriva dalla considerazione secondo cui se in un sistema operativo possono coesistere più processi, non si vede perché uno stesso processo non possa essere scomposto a sua volta in sottoprocessi che, questa volta, condividono lo stesso spazio di memoria e le stesse risorse.
Questi sottoprocessi prendono il nome di thread. L'esigenza di un insieme di strumenti e di tecniche per la programmazione multithreading nasce proprio dal fatto che più sottoprocessi possono disporre delle stesse risorse per cui è necessario poterne regolare l'interazione. Ecco che nascono problemi di sincronizzazione (le azioni devono essere eseguite in un particolare ordine), e di condivisione delle risorse che non portino a situazioni di deadlock (blocco irreversibile del processo). Il problema dei cinque filosofi si presenta come un buon esempio per esporre le principali tecniche utilizzate nel contesto descritto e per proporre le soluzioni più adatte.
Il
problema dei cinque filosofi
Si tratta di
un classico problema di condivisione delle risorse tipico di un sistema
operativo in cui esistono più processi che in continuazione, acquisiscono
usano e rilasciano un insieme di risorse condivise, quali possono essere
una stampante, una particolare locazione di memoria o il disco.
Ovviamente si vogliono evitare situazioni di blocco critico o individuale. Per blocco critico individuale si intende la situazione per cui un particolare processo non riesce mai ad ottenere le risorse di cui ha bisogno e quindi a proseguire.
Nell'antica Cina esistevano cinque filosofi che non facevano altro che pensare e mangiare. I cinque filosofi sono sempre seduti attorno a un tavolo rotondo nel cui centro vi è un piatto di riso, sempre colmo e caldo (risorsa illimitata). Ogni filosofo passa il suo tempo a pensare e, quando per lo sforzo profuso gli viene fame, inizia a mangiare. Per mangiare ha bisogno di due bastoncini. Sul tavolo esistono cinque bastoncini che si alternano ai filosofi in modo che ogni filosofo abbia un bastoncino alla sua destra e uno alla sua sinistra. Per poter mangiare un filosofo deve impossessarsi di entrambi i bastoncini. Mai più di due filosofi mangeranno contemporaneamente.
Ogni filosofo rappresenta un thread che esegue all'infinito le operazioni relative a mangiare e pensare. Una possibile rappresentazione di ciò è una classe astratta con un metodo da specializzare ad ogni istanziazione come nel codice riportato qui di seguito
protected String nome;
public Filosofo() {
this("");
}
public Filosofo( String nome ){
super();
this.nome=nome;
}
public void run() {
while (true) { gestione_risorse(); }
}
public abstract void gestione_risorse();
}
Listato
1 - Thread che rappresenta ciascun filosofo
Per creare un thread è sufficiente estendere la classe java.lang.Thread e definire il metodo run() come corpo del thread stesso. Java permette solamente l'ereditarietà singola, per cui nel caso in cui una classe sia già l'estensione di un'altra, sarà sufficiente implementare l'interfaccia java.lang.Runnable, che richiede la definizione del solo metodo run() e utilizzare il costruttore della classe Thread appropriato. Si è detto che ogni filosofo deve impossessarsi di entrambi i bastoncini per mangiare e ovviamente un bastoncino non può essere contemporaneamente di due filosofi (risorsa condivisa). Dovremo trovare un meccanismo per cui se il bastoncino non è libero, il filosofo che lo reclama dovrà aspettare. Questo particolare strumento si chiama semaforo binario.
Sincronizzazione
in Java
Nello sviluppo
di applicazioni concorrenti capita spesso che un thread, che dispone di
una certa risorsa, si blocchi in attesa di particolari condizioni, per
esempio che siano disponibili dati su uno stream. Per il raggiungimento
di tali condizioni, è possibile che divenga indispensabile il rilascio
della risorsa utilizzata fino a quel momento. Sarà poi un altro
thread a risvegliare il thread bloccato. Quando un thread si impossessa
di una particolare risorsa in modo esclusivo ed agisce su di essa, si dice
che il thread opera in una regione critica.
La regione critica è costituita, quindi, dall'insieme delle istruzioni che sono eseguite in modo esclusivo su una particolare risorsa: un solo thread le potrà eseguire in un particolare momento. È chiaro allora che la possibilità di fermarsi in attesa di particolari condizioni debba avvenire all'interno di una regione critica. Analogamente il thread che verifica la condizione dovrà essere in una regione critica relativa alla stessa risorsa del thread che dovrà eventualmente "svegliare". Lo strumento che si utilizza in questi casi si dice semaforo binario. Supponiamo che un thread si trovi in una regione critica e che per proseguire debba attendere il verificarsi di una particolare condizione.
A tale condizione assoceremo un semaforo binario. Per indicare che il thread è in attesa su quel semaforo chiamiamo un particolare metodo che, per motivi storici, indichiamo con P() (noto anche come wait). A questo punto il thread si fermerà in corrispondenza del metodo e libererà la regione critica, ovvero la risorsa associata. Un altro thread potrà verificare la condizione attesa dal primo e, nel caso si verificasse, lo potrà risvegliare richiamando il metodo V() (noto anche come signal).
L'esecuzione
di questi metodi deve essere indivisibile. Java permette di ottenere un
funzionamento analogo attraverso il costrutto synchronized ed i
metodi wait() e notify(). La parola chiave synchronized
permette, infatti, di individuare una regione critica relativamente ad
una particolare risorsa. Per esempio, nel caso di una risorsa object
si ha:
public synchronized void metodo(){in quanto il metodo deve essere eseguito in modo esclusivo relativamente all'oggetto considerato (this).
// REGIONE CRITICA
}è equivalente a
public void metodo(){
syncronized(this){
// REGIONE CRITICA
}
}
Un
semaforo binario in Java
Caratteristica
fondamentale delle operazioni V e P è l'atomicità o indivisibilità.
Esse devono essere eseguite come se fossero primitive dell'ambiente. In
Java ciò significa che devono essere eseguite in modo esclusivo
e quindi utilizzeremo la parola chiave synchronized
Per rappresentare lo stato del semaforo, utilizziamo una variabile intera. Se essa è a 0 significa che il semaforo è rosso e quindi se un thread esegue una P() su di esso, si bloccherà. Se il valore di stato è 1 allora il semaforo è verde per cui uno dei thread bloccati (che aveva eseguito una P), potrà proseguire. Ovviamente, più thread si possono bloccare sullo stesso semaforo. A tale scopo utilizzeremo una variabile intera, num_attesa, che memorizza il numero di processi in attesa sul semaforo rosso (vedi listato 2). Inizialmente il valore dello stato è 1 ed il numero di thread in attesa è 0. Se facciamo una P sul semaforo, esso è verde (stato=1) per cui possiamo proseguire. Da questo momento in poi il semaforo, però, diventa rosso (stato=0). Se un altro thread esegue la P sullo stesso semaforo rimarrà bloccato. Affinché uno dei thread in attesa possa proseguire è necessario che venga chiamata la funzione V sul semaforo. Al suo interno avviene l'esecuzione del metodo notify() che permette il risveglio di un thread in attesa (se ne esistono).
Una
prima soluzione al problema
Nel paragrafo
precedente abbiamo definito e costruito un semaforo binario che vogliamo
ora utilizzare per risolvere il problema iniziale. Proviamo ad associare
a ciascuna risorsa, cioè a ciascun bastoncino, un semaforo. In questo
modo per acquisire il bastoncino basterà eseguire una P sul semaforo
associato. Se è disponibile acquisiremo il bastoncino, altrimenti
attenderemo che si renda disponibile, ovvero che il filosofo che se ne
è impossessato, lo rilasci. Le azioni che il filosofo dovrà
fare, che definiremo nel metodo gestione_risorse(), saranno quindi
le seguenti:
protected SemaforoBin[] bastoncini;
protected int who;
public FilosofoSemaforo(int who,SemaforoBin[] bastoncini){
super(""+who);
this.bastoncini=bastoncini;
this.who=who;
}
public void gestione_risorse(){
System.out.println("Il filosofo "+nome+" Pensa");
long attesa= (long)(Math.random()*10000);
try{ Thread.sleep(attesa);} catch(InterruptedException ie){}
System.out.println("Il filosofo "+nome+" Vuole il bastoncino destro");
bastoncini[(who+1)%5].P();
System.out.println("Il filosofo "+nome+" Ottiene il bastoncino destro");
System.out.println("Il filosofo "+nome+" Vuole il bastoncino sinistro");
bastoncini[who].P();
System.out.println("Il filosofo "+nome+" Ottiene il bastoncino sinistro");
System.out.println("Il filosofo "+nome+" Mangia");
attesa= (long)(Math.random()*10000);
try{ Thread.sleep(attesa);} catch(InterruptedException ie){}
System.out.println("Il filosofo "+nome+" Rilascia il bastoncino destro");
bastoncini[(who+1)%5].V();
System.out.println("Il filosofo "+nome+" Rilascia il bastoncino sinistro");
bastoncini[who].V();
}
}
Listato 3 - Utilizzazione del semaforo binario per l'acquisizione delle risorse
public static void main(String str[] ){
SemaforoBin[] bastoncini=new SemaforoBin[5];
for (int i=0;i<5;i++){
bastoncini[i]= new SemaforoBin();
}
FilosofoSemaforo[] filosofi= new FilosofoSemaforo[5];
for (int i=0;i<5;i++){
filosofi[i]= new FilosofoSemaforo(i,bastoncini);
filosofi[i].start();
}
}
}
Eseguendo l'applicazione si nota un buon funzionamento ma cosa succederebbe se tutti e cinque i filosofi prendessero il bastoncino alla loro destra e cercassero di impossessarsi di quello alla sinistra? La politica di gestione utilizzata non permette, infatti, il rilascio di un bastoncino da parte di un filosofo per cui si verificherebbe una situazione di stallo.
La soluzione
suggerita, quindi, non è la migliore. Bisognerebbe, infatti, fare
in modo che ciascun filosofo disponesse di una unica azione di acquisizione
bastoncini, nella quale verificare la disponibilità di entrambi
i bastoncini e solo allora se ne impossessasse. Possiamo allora decidere
di delegare l'assegnazione dei bastoncini ad un gestore che metterà
a disposizione di ciascun filosofo, due metodi per l'acquisizione e il
rilascio delle risorse durante l'esecuzione dei quali, l'accesso alle risorse
stesse è esclusivo. Bisogna evitare, infatti, che la disponibilità
dei bastoncini vari nel tempo che passa dall'istante della verifica a quello
dell'acquisizione: un altro processo potrebbe, infatti, "rubarle".
Per ottenere
lo scopo, possiamo utilizzare quello che si chiama Monitor.
Esso è
un costrutto sintattico che associa un insieme di procedure con una struttura
dati comune (i bastoncini) a più processi (i filosofi), consentendo
al compilatore di verificare che esse siano le sole operazioni permesse
su quella struttura ed assicurando la loro mutua esclusione (solo un processo
alla volta può accedere ad una azione del monitor). Nel contesto
Java, un monitor è semplicemente una classe in cui tutti i metodi
sono synchronized.
Il fatto che
le azioni descritte dai metodi di tale classe siano le uniche azioni possibili
sulla struttura dati dipende, ovviamente, dalla implementazione della classe.
Nel caso del
problema dei cinque filosofi il gestore sarà quindi quello della
classe GestoreBastoncini (listato 5).
protected byte num_bast[]={2,2,2,2,2};
public synchronized void acquisizione_bastoncini( int who ){
while( num_bast[who] <2 ){
try { wait(); } catch(InterruptedException ie) {}
}
num_bast[(who+1)%5]-; // bastoncino a destra
num_bast[(who+4)%5]-; // bastoncino a sinistra
}
public synchronized void rilascio_bastoncini( int who ){
num_bast[(who+1)%5]-; // bastoncino a destra
num_bast[(who+4)%5]-; // bastoncino a sinistra
notifyAll();
}
}
Implementare un costrutto monitor
Il primo passo
consiste nella creazione di un array che contenga la situazione
relativa a ciascun filosofo, ovvero il numero di bastoncini di cui potrebbe
disporre. La mutua esclusione nell'esecuzione di ciascun metodo è
assicurata dall'utilizzo del modificatore synchronized.
Il metodo acquisizione_bastoncini()
consisterà nella verifica della disponibilità di entrambe
le risorse. Nel caso in cui i bastoncini non siano entrambi disponibili,
il filosofo si metterà in attesa. Nella fase di rilascio, invece,
non si farà altro che modificare il numero di bastoncini disponibili
e chiamare la notifyAll() (listato 6).
protected GestoreBastoncini gestore;
protected int who;
public FilosofoGestore(int who,GestoreBastoncini gestore) {
super(""+who);
this.gestore=gestore;
this.who=who;
}
public void gestione_risorse(){
System.out.println("Il filosofo "+nome+" Pensa");
long attesa= (long)(Math.random()*10000);
try { Thread.sleep(attesa); } catch( InterruptedException ie ) {}
System.out.println("Il filosofo "+nome+" vuole i bastoncini");
gestore.acquisizione_bastoncini(who);
System.out.println("Il filosofo "+nome+" ha ottenuto i bastoncini");
System.out.println("Il filosofo "+nome+" Mangia");
attesa= (long)(Math.random()*10000);
try{ Thread.sleep(attesa);} catch( InterruptedException ie ){}
gestore.rilascio_bastoncini(who);
System.out.println("Il filosofo "+nome+" ha rilasciato i bastoncini");
}
}
public static void main(String str[]){
GestoreBastoncini gestore= new GestoreBastoncini();
FilosofoGestore[] filosofi= new FilosofoGestore[5];
for (int i=0;i<5;i++){
filosofi[i]= new FilosofoGestore(i,gestore);
filosofi[i].start();
}
}
}
Quella utilizzata è una tecnica di allocazione globale delle risorse che evita il blocco critico ma non quello individuale. Potrebbe, infatti, capitare che a mangiare siano sempre gli stessi filosofi mentre gli altri non riescono mai ad impossessarsi delle risorse necessarie. Per ovviare a questo nuovo problema si renderebbero necessarie particolari tecniche legate alla natura delle risorse. Si potrebbe, per esempio, fare in modo che un filosofo che ha mangiato, sia escluso per un particolare lasso di tempo. Un'altra soluzione potrebbe essere un'accurata gestione delle priorità da stabilire in un oggetto simile al nostro gestore.
Il classico problema
dei cinque filosofi ha permesso di introdurre quelli che sono gli strumenti
(semafori e monitor) più importanti nella gestione dei thread e
di applicarli ad un particolare linguaggio: Java.
È stato
anche evidenziato il fatto di come una particolare tecnica, giudicata buona
sotto certi aspetti (evita il blocco critico), si possa rivelare carente
sotto altri (blocco individuale).
Purtroppo la
determinazione della possibilità di blocco critico ed individuale
è cosa molto più complicata e necessita di algoritmi molto
complessi, la cui trattazione esula dagli scopi di questo articolo.
[1] Paolo Ancillotti,
Maurelio Boari, "Principi e tecniche di programmazione concorrente",
UTET.
[2] Salvatore
Antonio Bello, "Introduzione al multiprocessing ed al multithreading",
DEV,
Dicembre 96.
[3] Doug Lea,
"Cuncurrent
Programming in Java-Design and Pattern", Addison Wesley.
[4] David Flanagan,
"Java
in a Nutshell", II edizione, O'Reilly.
[5] F.Tisato,
L.Nigro, "Java Restaurant", Apogeo.
|
||
MokaByte ricerca
nuovi collaboratori.
|
||
|