I processi

La prima cosa da fare è capire come si creano e si gestiscono i processi all'interno dei sorgenti Java. A dire il vero parleremo di "thread" anziché di processi: si tratta di un abuso linguistico, ma per gli scopi che ci proponiamo è un compromesso accettabile.

Il punto di partenza è dato dallo studio della classe java.lang.Thread, che dispone di alcuni metodi che permettono la gestione dei thread; i principali sono:

  • - start(): permette l'esecuzione di un Thread;

stop(): permette l'interruzione di un Thread;

yield(): sospende il thread lasciando libera l'esecuzione di quelli con la stessa priorità;

suspend(): sospende il thread fino alla esecuzione di resume();

resume(): riabilita il thread sospeso con suspend();

join(): attende la conclusione di un particolare thread;

sleep(): attende un certo tempo espresso in millisecondi.

Per definire un thread è sufficiente estendere la classe java.lang.Thread e specificare le azioni che dovranno essere compiute, all'interno del metodo run().

public class MioThread extends Thread{
    public MioThread(){
        super();
    }
    public void run(){
                .....
    }
}// fine classe MioThread

Per utilizzare il thread, esso dovrà essere istanziato mediante una linea del tipo:

MioThread thread= new MioThread();

A questo punto esso dovrà essere mandato in esecuzione mediante

thread.start();

Così facendo verranno eseguite le azioni specificate all'interno del metodo run().

Una volta eseguita l'ultima azione, il thread termina e, se l'oggetto non è più referenziato, viene addirittura eliminato dal Garbage Collector.

Ricordate quindi che il metodo run() rappresenta il corpo del thread, ovvero l'insieme di istruzioni che verranno eseguite non appena verrà chiamato il metodo start(). Molto spesso il metodo run() contiene un ciclo infinito del tipo while(true){} per cui, per la sua terminazione, dobbiamo obbligatoriamente usare stop() (terminazione forzata). Dopo l'esecuzione di stop() il thread è soppresso e non potremo eseguire nuovamente start().

Abbiamo visto che per la sospensione del thread esistono due diversi metodi. Il metodo yield() si può applicare ad un thread in esecuzione, il quale dà la precedenza ad altri thread di uguale priorità (il funzionamento reale di questo metodo dipende moltissimo dalla implementazione della Java Virtual Machine e dal sistema operativo). Il metodo suspend(), invece, blocca il thread che non tornerà in esecuzione fino a quando non verrà invocato il metodo resume().

Il metodo join() permette ad un thread di bloccarsi in attesa che un altro termini la sua esecuzione.

Prima di passare ad utilizzare questi metodi, vediamo un'ultima cosa.

Nel caso in cui la nostra classe sia già l'estensione di un'altra e non essendoci ereditarietà multipla in Java, si utilizza l'interfaccia java.lang.Runnable che prevede la definizione del solo metodo run().

Il procedimento da seguire è il seguente:

- public class MyThreadFrame extends Frame implements Runnable{
  private Thread runner;
  public MyThreadFrame(){
  super();
  . . .
}
    public void run(){

> . . .

> . . .

}
    public void start(){
if (runner==null){
            runner= new Thread(this);
            runner.start();
        }
}// fine start()
    public void stop(){
if (runner!=null){
            runner.stop();
            runner=null;
        }
}// fine stop()
  }// fine classe

Notiamo come, in questo caso, debba essere definita una variabile di tipo Thread che si dovrà poi gestire all'interno dei metodi start() e stop() (che, si badi bene, non sono ereditati dalla classe Thread).

Dalla teoria alla pratica

Proviamo, quindi, ad applicare quanto visto al problema della realizzazione di un piatto di spaghetti al sugo. Il mese scorso abbiamo scomposto l'algoritmo in tre parti principali:

1. Preparare il sugo

Cuocere gli spaghetti

Mettere il tutto nel piatto

Possiamo rappresentare la preparazione della ricetta attraverso una classe astratta che chiamiamo SpaghettiAlSugo che lascia indefinito il corpo del metodo prepara_spaghetti()

Listato1: classe astratta Filosofo

> /\\

> \* Classe astratta che rappresenta il Thread relativo a ciascun

> \* filosofo. Per scegliere la politica di gestione delle risorse

> \* basterà estendere questa classe e ridefinire il metodo

> \* gestione_risorse().

\*/
public abstract class Filosofo extends Thread{
protected  String nome;
        public Filosofo(){
                this("");
        }// fine costruttore vuoto
       public Filosofo(String nome){
                super();
                this.nome=nome;
        }// fine costruttore
       public void run(){
                while (true){
                gestione_risorse();
        }// fine while
}// fine run
    public abstract void gestione_risorse();
}// fine classe filosofo

In questo modo, per specificare un certo procedimento ci basterà estendere la classe SpaghettiAlSugo e definire il metodo prepara_spaghetti().

La classe utilizza un certo numero di risorse che abbiamo memorizzato in un array (risorse\[\])

Listato2: filosofo semaforo

/\\

\* Utilizzo dei semafori binari per l'allocazione

\* delle risorse

\*/

public class FilosofoSemaforo extends Filosofo {

protected Semaforo \[\] bastoncini;

protected int who;

public FilosofoSemaforo(int who,Semaforo\[\] bastoncini){

super(""+who);

this.bastoncini=bastoncini;

this.who=who;

}// fine costruttorevuoto

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();

}// fine gestione risorse

}// fine classe

La classe Risorsa prevede solamente l'assegnamento di un nome .

Possiamo infatti associare a ciascuna delle tre azioni una risorsa fondamentale per portarla a compimento (in realtà le risorse sarebbero in numero maggiore ma il nostro interesse è la descrizione dei concetti fondamentali e non la risoluzione completa del problema), ovvero:

  • Per preparare il sugo serve un tegamino.
  • Per cuocere gli spaghetti serve una pentola.
  • Serve un piatto.

Le risorse che dovremo creare saranno dunque "tegamino", "pentola" e "piatto".

Se osserviamo la classe SpaghettiAlSugo notiamo anche che si vuole cucinare un piatto di spaghetti dietro l'altro, per cui abbiamo utilizzato un ciclo while infinito. Rappresenteremo, poi, ciascuna azione con un'istanza della classe Azione, il cui metodo esegui()non fa altro che fornire una descrizione ed aspettare un tempo casuale per il completamento

Listato 3

> > /\\

> \* Verifica dell'utilizzo dei semafori per la gestione

> \* delle risorse

\*/
           public class ProvaFilosofoSemaforo {
public static void main(String str\[\]){
                           // Creiamo il vettore di semafori
                           Semaforo\[\] bastoncini=new Semaforo\[5\];
                           for (int i=0;i\<5;i++){
                                   bastoncini\[i\]= new Semaforo();
                           }// fine for
                           // Creiamo vettore filosofi e li avviamo
                           FilosofoSemaforo\[\]      filosofi= new FilosofoSemaforo\[5\];
                           for (int i=0;i\<5;i++){
                                   filosofi\[i\]= new FilosofoSemaforo(i,bastoncini);
                                   filosofi\[i\].start();
                           }
                }// fine main
          }// fine classe

A quest'ultimo passiamo in input anche la risorsa utilizzata per eseguire l'azione; la risorsa sarà presa e, al termine, rilasciata.

Proviamo adesso a dare una prima definizione del metodo prepara_spaghetti()

> Listato 4:

> /\\

> \* Creazione di un Monitor per l'allocazione globale

> \* delle risorse

\*/
  public class GestoreBastoncini {
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){}
                  }// fine while
                  num_bast\[(who+1)%5\]--;      // Prendiamo il bastoncino a destra
                  num_bast\[(who+4)%5\]--;     // Prendiamo il bastoncino a sinistra
}// fine acquisizione_bastoncini
          public synchronized void rilascio_bastoncini(int who){
num_bast\[(who+1)%5\]--;    // Rilasciamo il bastoncino a destra
                  num_bast\[(who+4)%5\]--     // Rilasciamo il bastoncino a sinistra
                  notifyAll();
          }// fine acquisizione_bastoncini
}// fine classe
public void prepara_spaghetti(){
Azione.esegui(cuoco," Preparara il sugo",  risorse\[0\]);
    Azione.esegui(cuoco," Cuoce gli spaghetti",  risorse\[1\]);
    Azione.esegui(cuoco," Mette il tutto nel  piatto", risorse\[2\]);
}

Se eseguiamo l'applicazionePreparaSpaghetti1, notiamo che le azioni si susseguono in maniera corretta ed il nostro problema sembrerebbe risolto in quanto viene cucinato un piatto di spaghetti al pomodoro alla volta.

Come accennato il mese scorso, i problemi sorgono quando si vogliono cucinare più piatti di spaghetti contemporaneamente. Proviamo la soluzione più immediata, ovvero creiamo l'applicazione PreparaSpaghetti2 modificando il metodo main()in modo da creare due istanze da avviare una dopo l'altra. In questo caso utilizziamo un costruttore che ci permetta di dare un nome a ciascun thread-"cuoco".

L'output diventa il seguente:

  • Cuoco 1 Acquisisco la risorsa Tegamino
  • Cuoco 2 Acquisisco la risorsa Tegamino
  • Cuoco 2 Prepara il sugo
  • Cuoco 1 Prepara il sugo
  • Cuoco 1 Rilascio la risorsa Tegamino
  • Cuoco 1 Acquisisco la risorsa Pentola
  • . . .

I lettori più attenti noteranno che qualcosa non funziona in quanto, disponendo di un solo tegamino, non è possibile che entrambi i cuochi se ne impossessino. Se un primo cuoco si impossessasse della risorsa tegamino, il secondo cuoco dovrebbe attendere che si renda disponibile ovvero che il primo lo rilasci. Sono quindi necessarie delle tecniche opportune di sincronizzazione.

Tecniche di sincronizzazione: i semafori binari

Nel paragrafo precedente abbiamo visto come un programma, all'apparenza corretto, possa presentare dei grossi problemi se guardato in un contesto multithreading. In un contesto, cioè, in cui più processi dello stesso programma devono condividere le stesse risorse. La condivisione di una risorsa è un problema tipico di programmazione concorrente: un processo vuole impossessarsi di una particolare risorsa e si blocca in attesa che questa si renda disponibile. La soluzione classica a questo problema si chiama semaforo binario.

Supponiamo di associare il semaforo ad una particolare risorsa condivisa (il tegamino). Se un processo vuole acquisire il tegamino eseguirà una particolare funzione del semaforo che, per motivi storici, si indica con P() (oppure wait()). Se la risorsa è disponibile il processo la utilizzerà ed il semaforo ne memorizzerà lo stato (semaforo rosso). Se un altro processo, a questo punto, prova ad impossessarsi della stessa risorsa, eseguirà nuovamente la P() sullo stesso semaforo, ma questa volta rimarrà bloccato. Questo succederà quindi per tutti gli eventuali processi che facciano la stessa richiesta.

Quando il processo utilizzatore rilascia la risorsa, esegue un'altra operazione sul semaforo che si indica con V() (oppure signal()).A questo punto, se sono presenti dei processi in coda, uno di essi acquisisce la risorsa facendo rimanere rosso il semaforo. Se non sono presenti processi in coda il semaforo diventerà verde ovvero, la risorsa sarà disponibile.

In Java possiamo affermare che un semaforo binario è un oggetto che dispone di due metodi, P() e V() appunto, che regolano l'acquisizione di una particolare risorsa. La caratteristica fondamentale di questi metodi è l'atomicità. Dire che un metodo o una funzione è atomica, significa affermare che la sua esecuzione non può essere interrotta: è come una operazione primitiva del sistema operativo.

Creazione del semaforo in Java

Vediamo allora di realizzare il semaforo in Java introducendo altri importanti strumenti del linguaggio.

Dire che un processo utilizza una risorsa condivisa significa affermare che il processo ha accesso esclusivo alla risorsa, ed agisce quindi da solo su di essa. L'insieme di operazioni che vengono fatte su una risorsa a cui si ha accesso esclusivo, si chiama regione critica. Per delimitare una regione critica, Java mette a disposizione la parola chiave synchronized. Se, ad esempio, vogliamo disporre in modo esclusivo di oggetto basterà mettere le operazioni volute, all'interno di un blocco del tipo:

synchronized (oggetto){
    // Regione Critica
    . . .
}

In questo modo solo un processo alla volta accederà alle operazioni della regione critica. La parola chiave synchronized può essere utilizzata anche come modificatore nel caso in cui la regione critica corrisponda al corpo di un intero metodo, cioè:

public synchronized void MioMetodo(){

// Regione critica

...

}// fine metodo

che è equivalente a

public void MioMetodo(){

synchronized(this){

// Regione Critica

...

}

}

In questo caso l'esclusività è legata all'oggetto istanza della classe a cui il metodo appartiene: due o più processi non possono eseguire lo stesso metodo contemporaneamente sullo stesso oggetto (this). Ovviamente l'esclusività non vale nel caso di istanze diverse. A questo punto il lettore potrebbe dire di aver già gli strumenti per realizzare un semaforo, mettendo cioè le operazioni che lavorano sulla risorsa all'interno di un blocco synchronized associato alla risorsa stessa. Il risultato può sembrare lo stesso, e non si utilizzerebbero i due metodi, P() e V(), che caratterizzano il semaforo binario. Inoltre, esiste un classico problema di programmazione concorrente che il solo synchronized non può risolvere.

Supponiamo, infatti, che un processo acquisisca una risorsa in modo esclusivo. Supponiamo inoltre che, ad un certo punto, lo stesso processo, per proseguire, desideri che si verifichi una particolare condizione (ad esempio che siano disponibili dei dati su uno stream). Ma affinché tale condizione possa verificarsi, potrebbe essere necessario il rilascio della risorsa posseduta in modo esclusivo. In tal caso, si andrebbe incontro ad un cosiddetto "deadlock" (o "stallo"), ovvero ad una condizione "irrisolvibile". Per questo, il solo costrutto synchronized non è sufficiente.

Java mette a disposizione di ogni oggetto i seguenti metodi:

  • wait(): permette al thread di bloccarsi in attesa di una particolare condizione;
  • notify(): permette di svegliare un thread in attesa sulla stessa risorsa;
  • notifyAll(): permette di svegliare tutti i thread in attesa sulla stessa risorsa.

Essi possono essere utilizzati solamente all'interno di un blocco synchronized associato alla stessa risorsa. Come conseguenza non possiamo dire che le operazioni di un semaforo P() e V() sono equivalenti ai metodi wait() e notify(). Infatti il semaforo deve regolare l'ingresso ad una regione critica e non essere già parte di essa. Possiamo pensare, però, di realizzare un nostro oggetto Semaforo (vedi listato 5) sfruttando tutti gli strumenti Java finora introdotti. Il fatto che i metodi P() e V() siano atomici si può esprimere attraverso il modificatore synchronized.

Listato 5

Il fatto della disponibilità o meno della risorsa, si esprimerà con una variabile intera stato, che può valere 1 (semaforo verde) oppure 0 (semaforo rosso).

Utilizziamo anche un contatore (in_attesa) che memorizza il numero dei processi in attesa, ovvero che hanno eseguito la P() sul semaforo che era nello stato di rosso. Osservando il listato, vediamo che entrambi i metodi sono definiti synchronized in modo che solo uno di essi possa essere eseguito in un particolare istante.

Successivamente il procedimento è abbastanza semplice; se lo stato del semaforo è ad 1 (verde), possiamo impossessarci della risorsa e mettere lo stato del semaforo a 0 (rosso). Se lo stato del semaforo è rosso, ci mettiamo in attesa richiamando la

wait(). La V() consiste, invece, nel verificare se sono presenti processi in attesa da abilitare o meno attraverso il metodo notify(). Nel caso non vi siano processi in attesa, lo stato del semaforo verrà messo nuovamente a 1 (verde).

L'aggettivo binario deriva dal fatto che il semaforo può assumere solamente due stati, 0 e 1, in quanto la risorsa è unica. Nel caso in cui le risorse disponibili siano n il semaforo avrà un funzionamento analogo bloccandosi solamente nel caso in cui tutte le n risorse non fossero più disponibili. Per implementare un semaforo di quel tipo basterà mettere porre stato=n.

Ed ecco l'utilità dei due costruttori utilizzati nella classe Semaforo.

Iniziamo a cucinare...

Ora che abbiamo creato degli strumenti che permettono di regolare l'assegnazione delle risorse, e quindi di stabilire una certa politica di scheduling, vogliamo applicarli al nostro problema. Abbiamo infatti visto che la semplice esecuzione dei due thread era esatta da un punto di vista formale (l'applicazione "girava") ma non certo dal punto di vista dei risultati (entrambi utilizzavano contemporaneamente le stesse risorse).

Creiamo allora l'applicazione PreparaSpaghettiMutex.

Il metodo prepara_spaghetti() è diventato:

public void prepara_spaghetti(){

semafori\[0\].P();

Azione.esegui(cuoco," Preparara il sugo", risorse\[0\]);

semafori\[0\].V();

semafori\[1\].P();

Azione.esegui(cuoco," Cuoce gli spaghetti",

risorse\[1\]);

semafori\[1\].V();

semafori\[2\].P();

Azione.esegui(cuoco," Mette il tutto nel piatto", risorse\[2\]);

semafori\[2\].V();

}

Prima dell'acquisizione della risorsa si chiama la P() del semaforo ad esso associato, e per il rilascio si chiama il metodo V(). Se il lettore esegue l'applicazione può verificare che, questa volta, tutto procede nel modo corretto. Il lettore potrebbe anche verificare il funzionamento della nostra applicazione nel caso di un numero di risorse superiore utilizzando il costruttore appropriato della classe Semaforo.

Conclusioni

Questo mese abbiamo introdotto la maggior parte degli strumenti che fanno di Java un ottimo linguaggio multithreading. Attraverso l'introduzione del costrutto synchonized e dei metodi wait() e notify(), abbiamo costruito uno strumento alla base di molte tecniche di programmazione concorrente: il semaforo. Abbiamo poi visto come applicare il semaforo binario alla risoluzione di uno dei nostri problemi iniziali, ovvero la condivisione delle risorse.

Realizzare correttamente delle applicazioni multithreading non è cosa semplice; cosa succederebbe se i cuochi fossero più di due? Se dal punto di vista delle risorse tutto funziona correttamente (ogni risorsa è utilizzata da un solo processo in un determinato istante), cosa possiamo dire dal punto di vista dei processi?

Nel nostro caso, siamo sicuri che ogni cuoco riesca ad impossessarsi delle risorse?

Il prossimo mese cercheremo di rispondere a queste domande.

Bibliografia

  • \
  • [1\] *"Principi e tecniche di programmazione concorrente"*, Ed. UTET, Paolo Ancillotti, Maurelio Boari.
  • \
  • [2\] *"Introduzione al multiprocessing ed al multithreading"*, Dev 36 Dicembre 96, Salvatore Antonio Bello.
  • \
  • [3\] *"Concurrent Programming in Java - Design and Pattern"*, Doug Lea, Addison Wesley.
  • \
  • [4\] *"Java in a NutShell"* 2nd Edition, David Flanagan, O'Reilly.
  • \
  • [5\] *"Java Restaurant"*,F.Tisato, L.Nigro, Apogeo.
  • \
  • [6\] *"Java Threads"*, Scott Oaks, Henry Wong, O'Reilly.