MokaByte 60 - Febbraio 2002 
Multi-Threading Applicato
di
Andrea Mazzolini
Scrivere programmi multi-thread non è affatto banale. Questo articolo cerca di fornire alcuni strumenti utili e suggerimenti per evitare i trabocchetti in cui anche programmatori esperti possono cadere

Introduzione
La programmazione multithread consente di eseguire sequenze di codice in parallelo (o pseudo-parallelo su macchine con un singolo processore). Sfruttando il tempo di idle della CPU si può (in certe situazioni) migliorare il tempo di risposta dei programmi. Si possono anche sviluppare programmi più semplici ed efficienti modellando la soluzione come un insieme di thread che interagiscono in parallelo. Tuttavia programmare l'interazione tra i vari thread può risultare una faccenda molto complicata e soggetta ad errori.
Questo articolo comincia dove di solito altri finiscono. Si suppone, infatti, che il lettore conosca l'utilizzo della classe Thread, dell'interfaccia Runnable e della keyword synchronized.

 

Le basi
Uno dei problemi principali nelle architetture multi-thread è la gestione delle situazioni in cui più di un thread accede contemporaneamente alla stessa struttura. Per esempio, un thread cerca di aggiornare un elemento di una lista ed un altro thread cerca di ordinare la stessa lista. Per impedire che si abbiano risultati non corretti o comportamenti anomali occorre utilizzare la sincronizzazione dei thread.
Il principale meccanismo di sincronizzazione in Java è basato sul concetto di monitor object, introdotto per la prima volta da Dijkstra e Hoare. Ciascun oggetto in Java può essere utilizzato come oggetto monitor.
Dato un qualsiasi oggetto anObject quando si scrive

sinchronized(anObject){/*codice*/}

l'oggetto anObject è utilizzato come monitor e solo un thread alla volta può eseguire codice sincronizzato sullo stesso monitor.
In altri linguaggi si utilizzano lock e condizioni per gestire l'attività dei thread. Il monitor object contiene al suo interno sia il lock che la condizione.
Prima di vedere alcuni suggerimenti per scrivere programmi multi-thread, può essere il caso di ripassare il significato di synchronized e quello che succede ai thread quando sono chiamati i metodi wait, notify, notifyAll (metodi della classe Object e perciò comuni a tutti gli oggetti).
Ci sono due forme sintattiche della keyword synchronized, una relativa a blocchi di codice e una ai metodi.
Va notato che

synchronized void f() { /* body */ } //metodo sincronizzato

è equivalente a:

//Blocco di codice sincronizzato su this
void f() { synchronized(this) { /* body */ } }

Quindi la sincronizzazione di un metodo è equivalente alla sincronizzazione del blocco comprendente tutto il codice del metodo.
Nel caso della sincronizzazione di metodo occorre sottolineare che la keyword synchronized non è un reale modificatore del metodo e quindi non è automaticamente ereditato quando si creano sottoclassi; ne deriva che i metodi di una interface non possono essere dichiarati come sincronizzati.
Anche i costruttori non possono essere dichiarati synchronized: tuttavia la sincronizzazione può essere usata all'interno del costruttore.
Bloccare un oggetto non protegge l'accesso e la modifica di variabili statiche della classe o delle superclassi. L'accesso a campi statici è protetto tramite metodi e blocchi statici sincronizzati. Per proteggere campi statici in metodi di istanza di una classe C si ricorre a

synchronized(C.class) {/* */}

Il lock statico associato con ciascuna classe è scorrelato da quello di qualsiasi altra classe comprese le super-classi. Se si usa synchronized(getClass()) {/* codice */} non si sincronizza l'accesso ai campi statici delle superclassi.

 

Il metodo wait( )
Il thread è bloccato e il lock di sincronizzazione dell'oggetto è rilasciato. Il thread viene "risvegliato" da una chiamata notify. Dopodiché cercherà di riacquisire il lock. Se il thread è stato interrotto tramite la chiamata interrupt() il metodo wait() esce immediatamente e lancia una InterruptedException.

 

Il metodo wait(long msecs)
E' simile al metodo precedente ma specifica il massimo tempo desiderato per rimanere in attesa. Passato il lasso di tempo specificato, il thread viene sbloccato anche se non è stato risvegliato da un notify. Non c'è modo di sapere se si è usciti dal wait per time-out o per segnalazione.

 

Il metodo notify( )
Uno dei thread in wait sull'oggetto (se ne esistono) viene rilasciato. Questo Thread deve riottenere il lock e lo potrà fare non appena il thread che ha chiamato il metodo notify() rilascerà a sua volta il lock uscendo dal blocco di codice sincronizzato che comprende l'istruzione notify.

 

Il metodo notifyAll( )
Tutti i thread in wait sull'oggetto vengono rilasciati e potranno riacquisire il lock lasciando lo stato di wait (uno alla volta).
La funzione wait viene utilizzata per aspettare il verificarsi di una condizione bloccando il thread

public synchronized void waitCond(){
while(!cond)
try{
wait();
}
catch(InterruptedException e){}
//Altro codice...
}

Se la variabile cond è falsa si entra in stato di wait, il thread si blocca e il lock sull'oggetto viene rilasciato. Questo significa che altri thread possono (uno alla volta) acquisire il lock eseguendo un blocco di codice sincronizzato. Anche altri thread possono finire in wait. Quando il thread che ha il lock sull'oggetto chiama la funzione notifyAll(), la funzione wait ritorna e il thread che sta eseguendo waitCond aspetterà il proprio turno per riacquistare il lock. Solo a questo punto si testerà di nuovo la variabile cond e nel caso che sia ancora falsa il thread andrà di nuovo in wait rilasciando il lock. Altrimenti uscirà dal ciclo while.


L'utilizzo dei monitor in Java è semplice in molti casi perché si gestisce la serializzazione dell'esecuzione dei metodi o di blocchi di codice tramite l'utilizzo della keyword synchronized.
Tuttavia si possono evidenziare le seguenti problematiche:

  1. l'utilizzo di un singolo monitor lock su un oggetto comporta limiti di scalabilità quando molti thread devono accedere ad un oggetto, essendo l'accesso eseguito in serie;
  2. è complessa la derivazione da un monitor object quando le sottoclassi necessitano differenti criteri di sincronizzazione;
  3. un problema molto comune (e difficile da individuare) è quello che viene chiamato "nested monitor lockout". Questo si crea quando un monitor object è posto dentro un altro monitor object. Si consideri il seguente codice:

class A{
    protected boolean cond=false;
    public synchronized void waitCond(){
        while(!cond)
            try{
                wait();
        }
        catch(InterruptedException e){}
            //Altro codice...
        }
    

    public synchronized void notifyCond(boolean b){
        cond=b;
        notifyall();
    }
}

class B{
    protected A a=new A();
    public synchronized void do(){
    a.waitCond();
}

public synchronized void set(boolean b){
    a.notifyCond(b);
}
}

Se un thread t1 effettua una chiamata a B.do(), poiché A e B hanno ciascuno il proprio monitor, la chiamata a wait dentro A.waitCond rilascia il monitor dell'oggetto A ma non quello dell'oggetto B. In questo modo nessun thread potrà mai chiamare la funzione set di B e il thread t1 continuerà ad essere bloccato in wait per sempre.
Il problema del nested monitor lockout può essere evitato condividendo un unico lock tra diverse condizioni. Questo è facile da fare per esempio in Posix ma sorprendentemente complesso in Java.
Vediamo ora una serie di suggerimenti che possono aiutare a scrivere del codice multi-threading.

 

Utilizzo di classi Immutable
Le classi Immutable sono read-only cioè non ci sono metodi SetField. I dati della classe sono definiti per sempre nel costruttore (e ovviamente sono privati). Esempio di classi Immutable sono String e Integer.
Si evitano così problemi di accesso concorrente ai dati della classe, poiché non ci sono metodi che modificano lo stato dell'oggetto dopo la sua creazione. In un oggetto immutable i metodi sono solo read-only e quindi è inutile sincronizzarli.
Questo pattern è utilizzabile per oggetti che rappresentano valori (numeri, colori, etc.) e per classi il cui comportamento è separabile in mutable e immutable (come String e StringBuffer).
Varianti di questo pattern sono:

  1. metodi stateless: i metodi che non accedono allo stato dell'oggetto non necessitano di essere sincronizzati (e possono essere resi statici). Ogni stato temporaneo deve essere locale al metodo.
  2. stateless object: oggetti che non hanno stato non necessitano sincronizzazione.

Per le classi Immutable può essere conveniente:

  1. implementare Object.equals e Object.hashCode ( in generale è sbagliato fare l'override di un solo di questi 2 metodi).
  2. definire la classe come final.
  3. definire metodi che generano nuovi oggetti della classe anziché modificarne lo stato (vedi ad esempio la concatenazione di oggetti String).


Oggetti completamente sincronizzati
Si elimano tutti i conflitti di lettura-scrittura; tutti i metodi sono sincronizzati e non ci sono wait, notify, o cicli infiniti. I clienti della classe non si devono preoccupare di sincronizzare niente.
Non ci devono essere variabili pubbliche. I metodi che accedono a variabili statiche devono essere sincronizzati con static synchronized. Poiché i costruttori non possono essere dichiarati come sincronizzati si può (se necessario) sincronizzarli all'interno con synchronized(this).

 

Parziale sincronizzazione
Si reduce l'overhead separando i metodi in blocchi che trattano variabili Mutable da altre Immutable. Solo i blocchi che trattano dati Mutable vengono sincronizzati. Per gli altri non occorre sincronizzazione.

 

Managed Ownership
Si modellano gli oggetti contenuti come risorse fisiche: se un thread ha la risorsa può utilizzarla; se un thread ha la risorsa nessun altro thread può averla; se un thread dà la risorsa ad un altro thread non la può più utilizzare; se un thread distrugge la risorsa, nessun altro potrà più averla. In questo modo i metodi che accedono all'oggetto-risorsa non necessitano affatto di sincronizzazione. Occorre definire un semplice protocollo per trasferire gli oggetti-risorsa da un thread ad un altro.

 

Invocazione Asincrona
Si evita di aspettare che una richiesta sia servita disaccoppiando sending e receiving. E' applicabile quando non si necessita immediatamente la risposta di una invocazione. Esistono diverse possibili implementazioni di questo pattern. Considereremo qui una implementazione che prevede l'utilizzo di un future.
Il client chiama Helper.service() (metodo non sincronizzato) e riceve immediatamente un Future. Dopodiché è libero di fare altro lavoro. Ad un certo punto può chiamare future.value() e se l'oggetto helper non ha completato il metodo compute() rimane bloccato in attesa. Il client non si deve preoccupare di sincronizzare niente. Questo pattern è utilizzabile quando il servizio richiesto necessita di un tempo non trascurabile per essere completato.

class Future {
    private Object val_;
    private Slot slot_;

    public Future(Slot slot) {
        slot_ = slot;
    }

    public Object value() {
        if (val_ == null)
            val_ = slot_.get();
        return val_;
    }
}

class Helper{
    
//Questo è l'unico metodo sincronizzato !
    protected synchronized Object compute(){/**/}

    // non sincronizzato
    public Future service () {
        final Slot slot = new Slot();
        new Thread() {
            public void run() {
            slot.put(compute());
        }
    }.start();
    
return new Future(slot);
    }
}

class Slot{
    private Object obj;
    public sinchronized Object get(){
    while(obj==null){
        try{
             wait();
        
}
        catch(InterruptedException e){}
    }
    return obj;
};

    public synchronized void put(Object obb){
        
obj=obb; notify();
    }
}

 

ThreadBarrier
Si possono utilizzare barriere come punto di sincronizzazione di un insieme di thread che devono aspettarsi tra di loro prima di poter continuare il lavoro. Implementare un ThreadBarrier è semplicissimo:

class ThreadBarrier{
    private int mThreshold;
    private int mCount;

    // Il costruttore: si indicano quanti thread
    //
devono raggiungere
    // la barriera per poter essere rilasciati
    public ThreadBarrier(int count) {
        mThreshold = count;
        mCount = 0;
    }

    // Ciascun thread chiama barrierSynchronize()
    // e si blocca in attesa che tutti i thread siano
    // arrivati a questo punto

    public synchronized void barrierSynchronize()
        throws InterruptedException {
        if (mCount != mThreshold - 1) {
            mCount++;
            wait();
        }
        else{
            mCount = 0;
            //Qui tutti i thread vengono rilasciati
            notifyAll();
        }
    }
 
}

 

Mutex e ConditionalEvent
Quando si fa il porting di codice multi-thread da Posix può essere utile implementare in Java delle classi Mutex e ConditionalEvent. E' sconsigliato mescolare l'utilizzo di queste classi con metodi o blocchi sincronizzati.

public class Mutex{
    private boolean mAcquired = false;

    // La chiamata a questa funzione ottiene il lock
    // In questa semplice implementazione se un thread
    // ha già acquisito il mutex
    // e cerca di farlo di nuovo si avrà un deadloc

    public synchronized void acquire() throws InterruptedException{
        while (mAcquired == true) {
            wait();
        }
        mAcquired = true;
    }    

    //Questo metodo rilascia il lock
    public synchronized void release() {
        mAcquired = false;
        notify();
    }
}

public class ConditionalEvent{
    private boolean mState = false;
    private boolean mAutoReset = false;

    public ConditionalEvent(boolean pInitialState){
        mState = pInitialState;
    }

    public ConditionalEvent(boolean pInitialState,
                            boolean pAutoReset) {
        mState = pInitialState;
        mAutoReset = pAutoReset;
    }

    //Controlla se l'evento è segnalato
    public boolean isSignalled() {
        return mState;
    }

    // Segnala l'evento. Un thread bloccato
    // su waitForSignal è sbloccato
    public synchronized void signal() {
        mState = true;
        notify();
    }

    // Tutti i thread bloccati su waitForSignal
    // sono sbloccati
    public synchronized void signalAll() {
        mState = true;
        notifyAll();
    }

    //Resetta l'evento a non segnalato
    public synchronized void reset(){
        mState = false;
    }

    // Se l'evento è segnalato il metodo ritorna
    // immediatamente
    // Altrimenti il thread si blocca
    public synchronized void waitForSignal()
                             throws InterruptedException{
        while (mState == false){
            wait();
        }
        if (mAutoReset == true){
            mState = false;
        }
    }
}

 

Conclusioni
Per scrivere delle applicazioni multi-thread occorre dedicare molto tempo al progetto per evitare di cadere in errori che poi sarebbe difficile individuare e risolvere. L'articolo non ha la pretesa di aver fornito suggerimenti validi in tutte le situazioni. Non si sono per esempio affrontate le problematiche che si hanno quando diversi thread interagiscono con Swing né cosa succede in presenza di un Thread.interrupt().
Si rimanda tuttavia alla bibliografia per ulteriori approfondimenti.
Si consiglia anche di dare un'occhiata al sito http://gee.cs.oswego.edu/dl/classes/EDU/oswego/cs/dl/util/concurrent/intro.html
dove si può trovare un package realizzato da Doug Lea che può essere molto utile per non doversi reinventare la ruota tutte le volte che si deve scrivere un programma multi-thread.

Bibliografia
[1] Doug Lea - "Concurrent Programming in Java. Design Principles and Patterns", Addison-Wesley, 1999
[2] Douglas Schmidt et al. - "Pattern-oriented software architecture. Vol.2 Patterns for Concurrent and Networked Objects", Wiley, 2000


Andrea Mazzolini si è laureato con lode in Ingegneria Elettronica nel 1997 presso l'Università degli studi di Firenze. Da allora (tra le altre cose) ha programmato applicazioni grafiche e multi-thread in ambiente C++. Dal 2000 è passato a Java e attualmente si occupa di applicazioni Enterprise. Può essere contattato scrivendo a andreamazzolini@yahoo.it.

MokaByte® è un marchio registrato da MokaByte s.r.l. 
Java®, Jini® e tutti i nomi derivati sono marchi registrati da Sun Microsystems.
Tutti i diritti riservati. E' vietata la riproduzione anche parziale.
Per comunicazioni inviare una mail a info@mokabyte.it