Programmazione concorrente in java (parte 1)

Una delle caratteristiche principali di java è il supporto al multithreading o programmazione concorrente. Ogni programma che viene mandato in esecuzione sulla JVM dà origine ad un processo. Per la gestione del multithreading la JVM non si affida al sistema operativo, dovendo garantire la portabilità, ed utilizza una politica di gestione dei thread di tipo fixed-priority scheduling. Questa é basata essenzialmente sulla priorità ed è preemptive poiché garantisce che, se in qualunque momento si rende eseguibile un thread con priorità maggiore di quella del thread attualmente in esecuzione, il thread a maggiore priorità prevalga sull’altro, fatto salvo casi particolari in cui debbono essere gestite potenziali situazioni di stallo.

Creazione di thread

In java il supporto alla programmazione concorrente è realizzato attraverso l’utilizzo della classe java.lang.Thread e l’interfaccia java.lang.Runnable. Cone è naturale aspettarsi un un linguaggio ad oggetti come è java, un processo in esecuzione è un oggetto che può essere di tipo java.lang.Thread o appartenere ad una sua sottoclasse, oppure può implementare l’interfaccia java.lang.Runnable.

La classe Thread è una classe concreta che espone un metodo run(). Sebbene sia possibile istanziare un oggetto Thread, la sua esecuzione terminerebbe immediatamente perché il metodo run non fa nulla. E’ necessario quindi estendere tale classe e ridefinire il metodo per fare in modo che il nostro thread esegua un task, come nell’esempio seguente.

Come si evince dall’esempio, il thread è avviato invocando il metodo start() il quale provoca l’invocazione del metodo run() (invocare il metodo run direttamente non comporta l’esecuzione del metodo in un thread separato dal thread corrente). Terminata tale esecuzione le risorse impiegate vengono rilasciate. Ai fini dell’ottimizzazione della memoria è sempre opportuno impostare i riferimenti al thread a null, soprattutto se se ne utilizzano parecchi. In alternativa è possibile creare thread senza riferimenti:

in questo modo non appena termina la JVM rende disponibile il thread al processo di garbage collection.

Un metodo alternativo per creare ed eseguire un thread è l’implementazione dell’interfaccia Runnable. Essa contiene la definizione di un unico metodo run(), analogamente alla classe Thread. Questa stessa classe, infatti, è una implementazione di tale interfaccia. L’esempio precedente con l’utilizzo dell’interfaccia Runnable diviene:

Si nota immediatamente che per eseguire il task abbiamo ancora bisogno della classe Thread, al quale passiamo l’oggetto Runnable come parametro del costruttore. Può quindi sembrare, a prima vista, che tale interfaccia sia inutile potendo implementare un thread direttamente utilizzando la classe Thread. In realtà ricordiamo che java non supporta l’ereditarietà multipla. Questo implica che una classe non può implementare contemporaneamente la funzione di thread e specializzare un’altra classe concreta. A tale scopo ci viene in aiuto l’interfaccia Runnable che permette di avere una classe che ne estende un’altra pur essendo utilizzabile come thread:

Controllo dell’esecuzione

L’esempio che abbiamo visto, oltre ad essere molto semplice, ha il vantaggio di generare un thread che ha una vita limitata. Conta fino a 100 e poi termina. In generale questa situazione è abbastanza rara, un thread può non terminare mai o comunque eseguire task molto complessi e che richiedono un tempo indefinito.  Poiché l’implementazione della JVM non è detto che supporti il time-slicing, un thread potenzialmente può prendere il controllo in modo indefinito e senza rilasciare le risorse, provocando un rallentamento degli altri thread in attesa. Il programmatore può venire in aiuto al sistema con due accorgimenti descritti nel seguito.

Il primo consiste nell’utilizzare il metodo sleep(), che consente di sospendere l’esecuzione del thread per un periodo ti tempo specificato in millisecondi. Il metodo run() del nostro esempio potrebbe quindi divenire:

L’effetto di tale invocazione è quello di mettere in attesa il thread corrente e l’esecuzione del primo thread disponibile, con un conseguente e non previsto cambio di contesto. Quindi se da un lato libera delle risorse, dall’altro comporta un costo computazionale del quale va tenuto conto.

Altra possibilità è l’utilizzo del metodo yield(), che consente di cedere l’uso del processore a un altro thread in attesa, con il vantaggio che, nel caso in cui nessuno sia in attesa di essere servito, permette al thread corrente il proseguimento delle operazioni senza un inutile e costoso cambio di contesto. Il thread sospeso è portato in fondo alla coda dei thread di uguale priorità, comportando che i thread a priorità inferiore non potranno comunque prendere il posto del thread corrente.

Un altro modo per controllare l’esecuzione dei thread è l’utilizzo della priorità. Come detto la virtual machine adotta uno scheduling di tipo preemptive, basato sulla priorità, ed un thread a priorità maggiore può interrompere quello in esecuzione. In linea di principio quindi, il thread in esecuzione è sempre quello a massima priorità. Inoltre, se la JVM dovesse supportare il time-slicing, il thread a priorità maggiore sarebbe anche quello che occupa la CPU per un tempo maggiore.

La priorità può essere impostata utilizzando il metodo setPriority dell’oggetto Thread, considerando che la massima priorità è 10 (Thread.MAX_PRIORITY) mentre la minima è 1 (Thread.MIN_PRIORITY). Per default i thread partono con la priorità 5 (Thread.NORM_PRIORITY).

Sincronizzazione tra thread

Attraverso il metodo join della classe Thread è possibile attendere la terminazione sul quale è stato invocato. Questo è utile, ad esempio, quando per continuare l’esecuzione è necessario disporre dei risultati dell’elaborazione di un altro thread. Consideriamo ad esempio un thread che calcolo l’n-esimo numero di Fibonacci:

Il metodo main istanzia il thread richiedendo il 30-esimo numero di Fibonacci, lo avvia ed attende il completamento dello stesso per leggere il risultato della computazione. Il metodo join sospende il thread corrente e lo mette in attesa, analogamente allo sleep. La differenza è che l’attesa potrebbe protrarsi per un tempo indefinito, anche non terminare mai se siamo in presenza di errori. Per ovviare a ciò è possibile passare al metodo join, analogamente allo sleep, il numero massimo di millisecondi che si è disposti ad attendere la terminazione del thread.

Un’altro caratteristica dei thread è quella di poter essere interrotti. Tale possibilità si ottiene invocando il metodo interrupt() sulla variabile di istanza del thread che si vuole interrompere. L’aspetto importante è che l’interruzione avviene anche nel caso in cui il thread si trovi in stato di attesa, ad esempio dovuto ad una chiama sleep() o join(). Questo è il motivo per cui negli esempi precedenti tali metodi erano inseriti all’interno di un blocco try...catch che intercetta l’eccezione InterruptedException.

Nel caso in cui l’interruzione avviene quando il thread è i esecuzione, la JVM pone TRUE una variabile di stato che può essere testata dal thread invocando il metodo isInterrupted() (disponibile solamente se si estende la classe Thread). Si noti comunque che l’effetto non è, come ci si potrebbe aspettare, quello di interrompere l’esecuzione del thread ma semplicemente di notificare la richiesta di interruzione. Ad esempio se modifichiamo la classe Fibonacci come mostrato di seguito:

L’output dell’esecuzione è:

Dove è evidente che nonostante l’interruzione il metodo run() è arrivato alla sua naturale conclusione.

Un altro utile meccanismo di sincronizzazione tra thread è l’utilizzo dei metodo wait() e notify(). Si tratta di metodi della classe Object e quindi invocabili su un qualsiasi oggetto. Se un thread esegue il wait su un oggetto, la sua esecuzione sarà interrotta fino a un altro thread non esegua il notify sullo stesso oggetto. Unico vincolo è l’aver ottenuto prima il lock sull’oggetto con un blocco sincronizzato, descritto nel prossimo paragrafo. Di seguito un esempio complesso con due thread, un Trasmitter ed un Receiver:

Se i thread in accesso concorrente sono più di uno, e tutti in attesa a causa di una wait(), allora una chiamata alla notify() sbloccherà uno solo dei thread in attesa, senza la possibilità di sapere quale. In questo caso il metodo notifyAll(), consente di inviare la notifica a tutti i thread in attesa.

Accesso concorrente

Molto spesso è necessario che più thread accedano ad una risorsa condivisa, come ad esempio un’area di memoria, il cui accesso contemporaneo può provocare situazioni inaspettate e comunque non corrette. Nella programmazione concorrente questi casi cono gestiti attraverso meccanismi di looking basati sull’utilizzo di semafori. I semafori può essere visti come variabile visibile a tutti i thread ed utilizzate da questi per indicare che si è entrati in una porzione di codice critica. Il meccanismo di schedulazione si fa carico di garantire che, fin tanto che un thread ha eseguito un lock sul semaforo nessun altro thread vi può accedere. Eventuali thread che chiedono l’accesso sono messi in attesa finché il thread corrente non ha rilasciato il lock sul semaforo.

Java offre diversi meccanismi di looking. Il primo e più semplice è l’utilizzo della keyword synchronized applicato ad un metodo:

Dietro le quinte la JVM prima di eseguire il metodo acquisisce il lock sull’oggetto stesso (che in questo caso funge da semaforo) e lo rilascia al termine. Con questa modalità tutti i metodo dell’oggetto che sono synchronized non possono essere eseguiti se non dal thread che detiene il lock.

Può capitare però che solo una porzione di codice all’interno di un metodo necessiti di sincronizzazione. In questi casi può essere opportuno usare un blocco sincronizzato:

come si nota in questo caso deve essere indicato l’oggetto su cui eseguire il lock, nell’esempio this, che può essere di qualsiasi tipo. Utilizzare la sincronizzazione a livello di metodo o di blocco è una scelta che dipende dalla specifica situazione. In generale blocchi sincronizzati di grandi dimensioni possono provocare inutili rallentamenti ed occasionalmente generare deadlock.

Una classe che ha i blocchi di codice critici sincronizzati è detta thread safe.

Una alternativa ai blocchi di sincronizzazione per rendere l’accesso alle variabili thread safe è l’utilizzo della classe ThreadLocal. Questa consente di istanziare variabili locali al thread in modo che gli altri thread non possano accedervi. Un esempio d’uso è quando abbiamo la necessità di associare uno stato specifico ad un thread (es. uno user id o un transaction id).

L’esempio che segue assegna un ID ad ogni esecuzione del thread:

L’output prodotto sarà:

Thread Thread-1 Value - 0
Thread Thread-2 Value - 1
Thread Thread-3 Value - 2

© 2018 Java Boss - Theme by HappyThemes