Una
corretta gestione dei thread in un applicativo è fondamentale, se
vogliamo che esso gestisca le operazioni in maniera parallela e non sequenziale,
cioè se vogliamo che richieste contemporanee di uno o più
client, di qualunque natura esse siano, vengano gestite nello stesso momento
e non serializzate. Un esempio piuttosto concreto e diffuso di questo discorso
sono i server, di qualsiasi tipo essi siano. Ma prima di andare a vederne
uno semplice ma funzionante nei dettagli, vediamo in che modo possiamo
influenzare le prestazioni e la sicurezza del sistema agendo sulla gestione
dei thread.
Nota:
tutto quello che segue presuppone che il lettore abbia una buona conoscenza
del concetto di thread.
Il
concetto è che per ogni richiesta da parte di un utente, ci
deve essere un thread che se ne faccia carico.
Per
realizzare una situazione come questa abbiamo due possibilità:
1.
Lanciare un nuovo thread ogni volta che arriva una richiesta.
2.
Lanciare n thread allo startup dell’applicazione, e poi trovare un sistema
efficiente per prelevarne uno ogni volta che serve (ossia realizzare un
pool).
Nota:
trattandosi di thread, è bene ricordare che qualsiasi soluzione
decidiamo di adottare è potenzialmente pericolosa, perché
il rischio deadlock è sempre in agguato!
Rispetto
alla prima ipotesi, la seconda soluzione presenta alcuni vantaggi indiscutibili.
Quello più rilevante riguarda il tempo macchina necessario per lanciare
un thread, che va moltiplicato per il numero delle richieste che si sovrappongono
nel loro ciclo di vita. Questo tempo è abbastanza irrilevante sia
con VM windows che solaris. Ma quando il numero di richieste è molto
elevato (ad esempio il caso di un server web) il tempo di avvio dell’ennesimo
thread diviene, al margine, un fattore abbastanza critico. Mi spiego meglio.
Pensiamo ad una macchina di media capacità, con un carico di lavoro
anch’esso ‘medio’ ed ipotizzato costante nel tempo (questa è già
una rilevante semplificazione). La nostra applicazione viene lanciata,
e comincia a rispondere alle richieste dell’utente. Facciamo l’ulteriore
ipotesi che, dato l’ambiente descritto, il massimo carico sopportabile
senza perdita di prestazioni sia di 100 thread lanciati simultaneamente,
dove per simultaneamente si intende che un thread successivo viene lanciato
prima che il /i precedenti abbiano concluso il loro ciclo di vita. Superato
questo limite, il tempo macchina necessario al lancio del 101esimo thread
comincia ad interferire con le prestazioni del sistema, ed ogni thread
successivo incide in maniera sempre più rilevante. Cioè,
ogni nuovo thread lanciato comporta un’inefficienza che è marginalmente
crescente.
Vediamo
cosa succede predisponendo prima tutti i thread che noi presumiamo ci serviranno.
L’applicazione viene lanciata, e la prima cosa che fa è istanziare
e lanciare n thread che rimangono a girare, in attesa. Questa operazione
viene eseguita prima di iniziare a rispondere alle richieste dei client,
inoltre i thread vengono lanciati in rapida successione, ma sequenzialmente.
Terminata la fase di inizializzazione, l’applicazione inizia ad evadere
le richieste, prelevando ed utilizzando thread già pronti. Quando
il carico di lavoro supera la soglia critica, il miglioramento delle prestazioni
relativo all’uso di un pool diviene percepibile in maniera molto evidente
(cioè, non si parla più dei 20 –30 millisecondi che servono
alla VM per lanciare un thread, ma di secondi).
La
realizzazione di un pool di thread è un’attività più
complessa rispetto ad un pool di connessioni (visto il mese scorso), ma
ho comunque pensato di scrivere un piccolo web server che fa uso di questo
meccanismo. Vedrete, al di là della sua semplicità, quanto
esso sia efficiente.
Per
prima cosa, è necessario predisporre il pool di thread che vogliamo
utilizzare! Esso avrà una forma un po’ diversa rispetto al pool
di connessioni, anche se grossomodo i concetti sono gli stessi. Innanzitutto
dobbiamo creare dei thread che abbiano la caratteristica di poter gestire
le richieste! Cominciamo da questi.
public
class ClientManager extends Thread
questa
classe estende thread, ma racchiude le caratteristiche necessarie evadere
le richieste di un browser. Il costruttore lancia il thread:
public
ClientManager(){
start();
m_toRun = true;
}
m_toRun
è un booleano di controllo per gestire la fermata del thread.
Nel
metodo run troviamo il cuore della logica applicativa:
public
void run(){
try{
while(m_toRun){
while (! controlExecution)
sleep(100);
manageRequest();
}
}
catch(InterruptedException ie){
ie.printStackTrace();
}
}
il
flag controlExecution controlla che il thread rimanga in attesa oppure
cominci a gestire una richiesta.
Quando
il server ottiene un ClientManager dal pool, invoca il suo metodo init(),
passandogli lo stream in entrata (che contiene la stringa di richiesta
del browser) e quello per la risposta:
public
synchronized void init(InputStream is, OutputStream os){
iS = is;
oS = os;
controlExecution = true;
//notify();
}
Il
metodo manageRequest() ed il metodo answerRequest() , invocati nel run(),
gestiscono la risposta al client:
/**
*
questo metodo si prende carico della richiesta e cerca di gestirla.<br>
*/
public
void manageRequest(){
try{
//buffer per la richiesta
byte b[] = new byte[iS.available()];
iS.read(b);
String request = new String(b);
System.out.println("client request>"+request);
answerRequest(request);
}
catch(Exception e)
{
e.printStackTrace();
}
finally{
//questo thread ritorna al suo posto da solo
manager.returnThreadToPool(this);
controlExecution = false;
}
}
Vediamo
ora il pool. Esso è stato realizzato in che i thread vengano gestiti
in uno stack, cioè in una coda, che evade le richieste di thread
con il criterio FIFO (First In, First Out). Ma cominciamo dall’inizializzazione
del pool:
public
synchronized void initializePool(){
if(m_pool == null)
m_pool = new LinkedList();
if(size == 0)
size = MIN_THREADS;
if(maxThreads == 0)
maxThreads = MAX_THREADS;
if(size > maxThreads)
size = maxThreads;
for(int i = 0;i < size; i ++)
{
//avvia il thread
ClientManager cm = new ClientManager(this);
//aggiunge il thread, già avviato, al pool
m_pool.add(cm);
}
}
m_pool
è una LinkedList, un oggetto molto utile per realizzare code, ma
lo stesso risultato si sarebbe potuto ottenere anche con un più
familiare vettore. Fin qui il meccanismo è lo stesso del pool di
connessioni! Quello che cambia è il modo in cui il pool distribuisce
le sue risorse:
public
synchronized ClientManager getClientManager()
throws InterruptedException{
while (m_pool.size() < 0)
Thread.sleep(100);
//estrae dalla coda il primo thread e lo rende disponibile
return (ClientManager)m_pool.removeFirst();
}
quindi,
il thread viene fisicamente rimosso dallo stack. Quando poi il ciclo di
vita del thread termina, esso viene reinserito nella coda all’ultimo posto!
In questo modo, ad ogni richiesta il thread che viene distribuito è
sempre quello ‘più vecchio’ della coda… il che risponde al criterio
FIFO, appunto:
public
synchronized void returnThreadToPool(ClientManager cm){
m_pool.addLast(cm);
}
Quindi
ogni oggetto ClientManager che ha terminato il suo ciclo di vita viene
reinserito nello stack all’ultimo posto, ed in questa maniera tutti i thread
vengono prima o dopo utilizzati, ma secondo un criterio definito. Non c’è
una ragione tecnica per cui dobbiamo seguire un criterio come il FIFO per
gestire il pool (per le connessioni non l’avevamo fatto), ma è comunque
desiderabile avere una certa organizzazione nell’uso degli oggetti.
Ci
resta da vedere come implementare il server vero e proprio. Nel metodo
main() del server dobbiamo ovviamente implementare una ServerSocket, e
realizzare un ciclo infinito (a meno che noi non vogliamo che finisca ovviamente)
public
static void main(String args[])
{
try{
//System.out.println("1s");
ThreadPool tp = new ThreadPool(100);
tp.initializePool();
//System.out.println("2s");
ServerSocket ss = new ServerSocket(7777);
//server_toRun sarà impostato a false quando vorremo fermare il
server
while(server_toRun)
{
Socket s = ss.accept();
ClientManager cm = tp.getClientManager();
System.out.println("thread in use: "+cm);
//System.out.println("3s");
cm.init(s.getInputStream(), s.getOutputStream());
//System.out.println("4s");
}
tp.closePool();
System.out.println("Server quits... GoodBye");
System.exit(0);
}
catch(Exception e)
{
e.printStackTrace();
}
}
per
prima cosa viene inizializzato un pool con un certo numero di threads,
e quindi istanziato un ServerSocket. A questo punto tutti i thread del
pool stanno già girando! Nel ciclo while() invochiamo il metodo
accept(), che provoca il blocco del ciclo in attesa di una richiesta. Appena
arriva una richiesta, un ClientManager viene prelevato ed inizializzato
con i valori necessari. A questo punto il controllo passa nelle mani di
ClientManager, che è un thread già attivo e si prende carico
della richiesta. Appena la richiesta viene affidata a ClientManager (con
il metodo init())il ciclo prosegue e ritorna a fermarsi sull’accept().
Il tempo necessario per prelevare un ClientManager e per affidargli il
pacchetto con la richiesta è irrisorio, perché ClientManager
è già attivo ed in attesa. Inoltre una volta invocato ClientManager
prosegue la sua esecuzione come un thread separato da quello chiamante,
quindi il server è immediatamente in grado di gestire altre richieste.
Allegati
Nel
file .zip contenente gli esempi
il server è leggermente diverso, essendo anch’esso un oggetto Runnable,
ed è presente una classe che, in modo a dire il vero rudimentale,
si occupa di fare lo shutdown del server.
Vi
invito comunque a concentrarvi sul pool. Esso non è infatti scevro
da difetti (avevo preannunciato che sarebbe stato un pool semplice), soprattutto
perché non è stato realizzato un meccanismo di protezione
sufficientemente robusto contro eventuali deadlock. Tuttavia questo avviene
per esigenze di semplificazione che aiutano a mio parere nella comprensione
del meccanismo. Inoltre, il server non è completo…manca la gestione
delle chiamate POST, manca un meccanismo di log degli errori e di gestione
degli errori stessi (attraverso l’invio al browser di messaggi opportuni).
Ritengo
che sia un esercizio divertente tentare di sviluppare questi aspetti! Il
pool di thread così realizzato è infatti abbastanza flessibile
per poter essere facilmente applicato anche a server molto più complessi!
Provare per credere! |