MokaByte 94 - Marzo 2005 
La programmazione concorrente
I parte
di
Andrea Gini
La programmazione concorrente è un insieme di tecniche per gestire un ambiente di esecuzione caratterizzato da una moltitudine di flussi di esecuzione attivi in contemporanea. Esso si contrappone alla programmazione sequenziale, in cui il flusso di esecuzione è unico. Come si può facilmente intuire, la programmazione concorrente presenta problematiche molto diverse da quelle della programmazione sequenziale: questo mese verrà svolta una breve, ma necessaria, panoramica sulla teoria sottostante. Sarà quindi possibile introdurre le principali classi di supporto alla programmazione concorrente e illustrare un esempio funzionante.

Introduzione: il multi tasking
Ogni computer moderno è in grado di mandare in esecuzione più di un programma alla volta, nonostante disponga, nella maggioranza dei casi, di un solo microprocessore. La tecnica attraverso la quale un computer esegue in parallelo più programmi si chiama time sharing (condivisione di tempo): il processore viene assegnato dal sistema operativo ad ogni programma a turno per una frazione di secondo, in modo da dare l'illusione di simultaneità. La cosa sorprendente è che ciascun programma viene eseguito senza apparente perdita di prestazioni, una cosa che potrebbe sembrare paradossale. Se si lanciano dieci programmi contemporaneamente, ci si aspetterebbe che questi vengano eseguiti ciascuno ad un decimo della velocità massima.

In realtà questo non avviene per una serie di ragioni, alcune intuitive, altre meno. Programmi come Word o Excel, ad esempio, passano la maggior parte del loro tempo in attesa che l'utente prema un tasto; fino a quel momento lasciano la CPU pressoché libera. Esistono programmi più esigenti dal punto di vista dell'uso della CPU, come i lettori multimediali o i programmi di rendering video, ma non bisogna credere che questo tipo di programmi non presentino tempi morti: ogni qual volta un programma accede al disco per leggere o scrivere dei dati, cosa che in genere avviene svariate volte al secondo, esso lascia libera la CPU per alcuni millisecondi. Nonostante i progressi della tecnologia, gli hard disc sono dispositivi milioni di volte più lenti della CPU. Un moderno hard disc, ad esempio, ha un tempo di latenza che va tra i 2 e i 5 millisecondi. La latenza, una misura che non ha niente a che vedere con la velocità di trasferimento dei dati, è la somma dei tempi in cui l'elettricità si propaga nei circuiti, la testina si sposta sulla traccia da leggere, il disco completa la sua rotazione fino a quando il settore desiderato non si trova sotto la testina e altri dettagli di questo tipo. Il tempo di latenza dipende dalle leggi dalla fisica, e per questa ragione non può essere migliorato sensibilmente. Un processore come il Pentium 4, nell'arco di quei pochi millisecondi, è in grado di eseguire più di un miliardo di operazioni: non deve pertanto stupire che una moderna CPU sia in grado di soddisfare le esigenze di una decina di processi senza apparente calo di prestazioni. E così, mentre un programma aspetta che l'utente prema un tasto e un altro attende che il disco sia pronto a trasferire i dati, c'è tutto il tempo per mandare in esecuzione un terzo programma senza che nessuno noti alcun calo di prestazioni.

Se un computer dispone di più di un processore, un fatto sempre più comune anche in ambiente desktop, visto che l'ultima generazione di Pentium IV Hiper Threading è di fatto un bi-processore, l'esecuzione dei processi viene suddivisa tra le CPU disponibili, con un notevole miglioramento nell'efficienza complessiva.

 

Cosa è un processo
Un processo è un flusso di esecuzione, caratterizzato da un program counter, uno stack e una propria area di memoria riservata. Ogni volta che si lancia un'istanza di un programma si avvia un processo. Alcuni programmi, come Internet Explorer, permettono il lancio di più istanze, altri, come Outlook Express, no. Un processo occupa molte risorse del computer: ogni processo infatti dispone di una serie di risorse in esclusiva, come ad esempio uno spazio di memoria privato. Per questa ragione un computer non è in grado di reggere più di qualche decina di processi in esecuzione simultanea.

Nota: il Program Counter è una cella di memoria che contiene la locazione dell'istruzione macchina da mandare in esecuzione; il valore del program counter viene incrementato automaticamente dopo l'esecuzione di un'istruzione. Lo stack è un'area di memoria in cui vengono registrate alcune importanti informazioni prima di eseguire una chiamata a subroutine, in modo da poterle ripristinare al ritorno. I dettagli su questi due importanti elementi dell'architettura di un calcolatore possono essere trovati su libri specializzati come [3].

La gestione dei processi
Il sistema operativo è, tra le altre cose, la piattaforma sulla quale vengono eseguiti i processi. Non tutti i processi corrispondono a programmi utente: alcuni di questi sono creati dal sistema operativo stesso a supporto delle varie attività che il computer è tenuto a svolgere, Il componente preposto alla gestione dei processi si chiama scheduler. Lo scheduler dispone di una coda in cui vengono mantenuti tutti i processi in attesa di essere eseguiti e di una specie di orologio che indica quando mettere a riposo il processo attivo per mandarne in esecuzione un altro.

La letteratura sui sistemi operativi è sterminata, per un approfondimento si possono consultare testi come [1] e [2]. Per quanto riguarda la presente trattazione, è sufficiente accennare al fatto che tutti i moderni sistemi operativi su cui si appoggia Java (Windows, Linux e Solaris) sono preemptive, un termine che denota la facoltà che lo scheduler ha di interrompere un processo in esecuzione non appena questo ha terminato il tempo a sua disposizione. Nei vecchi sistemi operativi con multitasking non preemptive, come Windows 3.11, poteva capitare che un processo prendesse possesso esclusivo della CPU per un tempo indeterminato: al povero utente non restava altro da fare che guardare sconsolato il cursore a clessidra e aspettare.

Inoltre, tutti i sistemi citati supportano un meccanismo di scheduling a priorità: in altre parole, un processo in esecuzione può essere interrotto prima del tempo per mandare in esecuzione un processo a priorità maggiore. La priorità è un attributo di un processo che denota l'urgenza con cui quest'ultimo deve essere mandato in esecuzione. Il programmatore può assegnare ai propri processi una priorità, anche se i livelli più elevati sono riservati ad alcuni processi di sistema, proprio in virtù della criticità dei compiti che devono svolgere.

Infine ci sono casi in cui un processo viene messo a riposo prima dello scadere del tempo a sua disposizione: ad esempio quando un processo effettua una chiamata bloccante, come una read su disco, lo scheduler interviene per metterlo nella coda di attesa e ne manda subito in esecuzione un altro.

 

Thread e multithreading
L'esecuzione simultanea di più operazioni è una cosa utile anche all'interno di un processo. Un programma di videoscrittura, ad esempio, dispone in genere di diversi flussi di esecuzione: uno resta in attesa che l'utente prema un tasto, un altro fa lampeggiare il cursore due volte al secondo, un terzo esegue in background la correzione sintattica del testo e così via. Il multi threading è una riproposizione in scala ridotta del multi tasking. Un thread è un flusso di esecuzione caratterizzato da un proprio program counter, uno stack ma che, al contrario di un processo, condivide la memoria e le altre risorse di sistema con il processo padre e con tutti i thread in esecuzione nello stesso processo. Per questa ragione un thread occupa molte meno risorse di sistema rispetto ad un processo, al punto che un moderno calcolatore può tranquillamente eseguire decine di migliaia di thread simultaneamente.

Per quanto concerne la presente trattazione, è importante precisare che la JVM viene eseguita all'interno di un processo di sistema, mentre ogni programma da essa eseguito gira sotto forma di thread all'interno della JVM stessa.

Le versioni più recenti di Java e dei principali sistemi operativi su cui gira (Windows, Linux e Solaris) delegano al sistema operativo lo scheduling dei thread, mentre nelle prime versioni la gestione dei thread era affidata alla JVM stessa. Questa evoluzione ha garantito un uso più corretto ed efficiente delle risorse di sistema, e una maggior equità nell'esecuzione dei thread java rispetto a quelli degli altri processi in esecuzione sul sistema operativo.
Quando usare la programmazione concorrente
La programmazione concorrente è un'interessante possibilità, che ha molti contesti di utilizzo; tuttavia essa non deve diventare una regola. Scrivere programmi concorrenti è complicato, e il debugging può rivelarsi un vero inferno. A volte la programmazione concorrente è completamente inutile: in molte circostanze infatti, un programma sequenziale ben fatto è più efficiente di un programma concorrente scritto male.

Non bisogna infatti dimenticare che qualunque programma Java è assistito da un buon numero di thread di supporto, che rendono inutili altri tentativi di ottimizzazione. Per fare qualche esempio, il garbage collector gira in un thread autonomo; il sistema grafico si appoggia su una serie di thread che lavorano in modo trasparente rispetto al programmatore, e si occupano, tra le altre cose, della ricezione degli eventi e del disegno; infine anche l'IO è supportato dalla concorrenza sia a livello di Virtual Machine, che a livello di sistema operativo. Per questa ragione non si deve cedere alla tentazione di usare il multi threading ovunque.

Esistono precise circostanze in cui il multi threading può tornare utile anche a livello applicativo: in genere si ricorre alla programmazione concorrente quando si realizza un server di rete, che in un determinato momento deve gestire la comunicazione con più di un client. In generale il multi-threading torna utile in tutti quei casi in cui un problema può essere modellato come una iterazione tra un thread che produce un flusso di dati ad una certa velocità ed un'altro che li preleva con un ritmo differente.

 

Stati di un Thread
Ogni thread, come pure ogni processo, può trovarsi, in un determinato momento, in uno dei seguenti cinque stati: idle, ready, running, waiting e dead.

 


Figura 1
- Diagramma degli stati di un thread

Un thread è idle quando non è ancora stato avviato. Appena avviato passa in stato di ready, uno stato di attesa che condivide in un'apposita coda con tutti i thread e i processi che possono essere mandati in esecuzione. Dallo stato di ready può passare allo stato di running appena lo scheduler decide, in base alle proprie politiche e alla priorità del thread stesso, di mandarlo in esecuzione.

In un dato momento il numero massimo di thread che si possono trovare in stato di running è pari al numero di CPU presenti nel sistema. Un thread rimane in stato running per tutto il tempo concesso dallo scheduler, quindi ritorna in stato di ready.

Come già accennato in precedenza, un thread può eseguire chiamate bloccanti che ne causano la sospensione: in questo caso esso viene messo in stato di waiting, uno stato di attesa diverso da ready, in quanto un thread in waiting non è pronto ad essere mandato in esecuzione. Il passaggio dallo stato di waiting a quello di ready avviene non appena la causa del blocco è stata rimossa (ad esempio sono arrivati i dati dal disco). Dallo stato di running un thread può passare anche allo stato di dead, qualora esso abbia eseguito tutte le proprie istruzioni. Un thread che ha raggiunto lo stato di dead non può essere riavviato.

 

L'interfaccia Runnable
L'interfaccia Runnable è il primo elemento su cui si appoggia la programmazione concorrente in Java. Essa è caratterizzata da un unico metodo:

void run()

Questo metodo deve contenere il codice da mandare in esecuzione all'interno di un apposito thread. L'esecuzione di un thread inizia dal metodo run(), ma successivamente il flusso di esecuzione può propagarsi liberamente ai metodi di altre classi: è proprio in circostanze come questa che possono nascere dei problemi. Fino a quando un flusso di esecuzione attraversa i metodi una serie di oggetti a lui riservati non c'è niente da preoccuparsi; i problemi nascono quando più thread devono accedere ad un medesimo oggetto per modificarne il contenuto. L'insieme delle problematiche di questo tipo prende il nome di "sincronizzazione", e verranno analizzati nel prossimo articolo.

 

La classe Thread
La classe Thread è un'importante classe di supporto alla programmazione concorrente. Il fatto che esista una classe Thread non deve trarre in inganno: un thread non va confuso con l'oggetto Thread che lo supporta, dal momento che un thread non è un oggetto, ma un flusso di esecuzione. La classe Thread è solamente una infrastruttura che rende possibile il multi threading. Nella presente trattazione la differenza verrà sottolineata anche visivamente, usando l'iniziale maiuscola (Thread) quando si parla della classe, e la minuscola quando si parla del flusso di esecuzione ad esso associata (thread).

La classe Thread implementa l'interfaccia Runnable: questa parentela permette di realizzare sottoclassi di Thread il cui metodo run() contiene il codice da eseguire al momento della chiamata del metodo start(). Questa pratica viene sconsigliata per motivi di flessibilità e di pulizia del codice.

 

Costruttori
I costruttori principali sono quattro: i primi due sono riservati alle sottoclassi di Thread che forniscono una realizzazione del metodo run(). Si noti la possibilità di assegnare un nome simbolico al Thread, una funzionalità utile nel debugging. Gli altri due costruttori richiedono il passaggio di un oggetto Runnable contenente il codice da eseguire; anche in questo caso è possibile specificare un nome simbolico:

Thread()
Thread(String name)
Thread(Runnable target)
Thread(Runnable target, String name)

Come già precisato, nella presente trattazione si ricorrerà solamente a quest'ultima coppia di costruttori.

 

Metodi
Ecco un elenco dei principali metodi della classe Thread:

void start()

Questo metodo provoca l'avvio di un nuovo flusso di esecuzione a partire dal metodo run() dell'oggetto Runnable passato al costruttore. Se durante la costruzione non è stato specificato nessun oggetto Runnable, il flusso di esecuzione prenderà il via dal metodo run() presente nella classe stessa.

void run()

Questo metodo è un'implementazione vuota del metodo run() presente nell'interfaccia Runnable. Se si desidera creare un oggetto Thread che contenga al proprio interno il codice da eseguire al momento della chiamata del metodo start(), bisogna creare una opportuna sottoclasse di Thread che fornisca un'implementazione di questo metodo.

void join()
void join(long millis)

Questa metodo blocca il thread chiamante fino a quando il thread abbinato all'oggetto Thread su cui è stata effettuata la chiamata non termina la sua esecuzione passando allo stato di dead; durante l'attesa il thread chiamante viene messo in waiting. La seconda versione del metodo prevede un parametro millis che denota il tempo massimo in millisecondi in cui il thread chiamante resta in attesa.

static void sleep(long millis)

Questo metodo statico provoca la sospensione del thread attualmente in esecuzione per il numero di millisecondi specificati nel parametro. Durante l'attesa il thread viene messo in waiting, al termine della sospensione lo scheduler mette il thread in ready.

boolean isAlive()

Verifica se il thread abbinato a questo oggetto è ancora vivo o se è passato in stato dead. Come già detto, quando un thread passa allo stato di dead, non può più essere riavviato, tuttavia gli oggetti di appoggio Thread e Runnable rimangono in memoria fino a quando esiste un reference ad essi.

static Thread currentThread()

Questa chiamata statica restituisce un reference al Thread che supporta il flusso di esecuzione corrente

static void dumpStack()

Stampa a schermo il contenuto dello stack del thread corrente.

long getId()

Restituisce l'identificatore numerico assegnato al Thread.

boolean isDaemon()
void setDaemon(boolean on)

Questa coppia di metodi permette di impostare su un thread la proprietà Daemon, o di verificarne lo stato. Un thread Daemon cessa di girare nel momento in cui il thread principale (quello che parte dal metodo main()) arriva al termine della sua esecuzione.

String getName()
void setName(String name)

Questa coppia di metodi permettono di assegnare un nome simbolico al Thread o di leggerlo.

int getPriority()
void setPriority(int newPriority)

Questa coppia di metodi permette di impostare o di testare la priorità del thread. I valori possibili possono essere scelti tra Thread.MAX_PRIORITY, Thread.NORM_PRIORITY e Thread.MIN_PRIORITY.

static void yield()

Metodo statico che causa la sospensione del thread corrente in modo tale da cedere il controllo ad un altro thread. Questo metodo aveva un senso quando Java era supportato anche da sistemi operativi non preemptive: di fatto si tratta in assoluto del metodo meno usato del JSDK.

 

Esempio
I concetti analizzati fino ad ora rischiano di restare una pura astrazione, senza l'ausilio di un esempio funzionante. Si osservi questo semplice programma giocattolo:

public class MultithreadingExample {

static class Runnable1 implements Runnable {
public void run() {
while(true)
System.out.println("Runnable 1");
}
}
static class Runnable2 implements Runnable {
public void run() {
while(true)
System.out.println("Runnable 2");
}
}
public static void main(String argv[]) {
Thread t1 = new Thread(new Runnable1());
Thread t2 = new Thread(new Runnable2());
t1.start();
t2.start();
}
}

In questo esempio vengono dichiarate una classe principale, MultithreadingExample, al cui interno vengono definite due classi, Runnable1 e Runnable2, ciascuna delle quali implementa l'interfaccia Runnable. Il metodo run() di queste classi si limita a stampare a ciclo continuo una scritta sullo schermo. All'interno del metodo main() si può vedere come questi oggetti vengono abbinati alle apposite istanze della classe Thread; infine viene chiamato il metodo start() su tali oggetti, dando il via all'esecuzione concorrente vera e propria: i metodi run() delle classi Runnable1 e Runnable2 vengono eseguiti alternativamente in pseudo-parallelismo, secondo la tecnica time sharing descritta all'inizio di questo articolo. Come conseguenza, sullo schermo cominceranno ad apparire alternativamente sequenze di di scritte "Runnable 1" alternate a sequenze di scritte "Runnable 2". La durata di tali sequenze è assolutamente casuale, in quanto dipende da circostanze contingenti, come l'occupazione di CPU, la presenza di thread o processi a priorità maggiore, la politica di scheduling del sistema operativo e così via. Per interrompere l'esecuzione del programma è necessario premere la combinazione CTRL-C.

 

Conclusioni
Questo mese sono stati introdotti i concetti fondamentali della programmazione concorrente, un tipo di programmazione caratterizzata da flussi multipli di esecuzione che lavorano in parallelo. Dopo aver illustrato un po' di teoria relativa al multi tasking e ai processi, si è visto nei dettagli cos'è un thread e come viene implementato in Java. Quindi sono state illustrate l'interfaccia Runnable e la classe Thread, che costituiscono la principale infrastruttura del multi threading in Java. Infine un semplice esempio ha permesso di vedere un esempio funzionante di programma concorrente. Il mese prossimo verranno illustrate alcune tecniche di sincronizzazione.

 

Bibliografia
[1] Modern Operating Systems Andrew S. Tanenbaum Prentice Hall Paperback - December 6, 2001
[2] Operating System Concepts, 6th edition: XP Version James L. Peterson, Abraham Silberschatz
John Wiley & Sons Inc Hardcover - April 5, 2002
[3] Structured Computer Organization Andrew S. Tanenbaum, G. Goodman (Editor) Pearson US Imports & PHIPEs
Paperback - November 1998

 

Risorse
Scarica l'esempio mostrato nell'articolo

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