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
|