MokaByte 54 - Luglio/Agosto   2001
Foto dell'autore non disponibile
di
Giovanni Tommasi
Object Pooling 
Parte III: un pool di thread
Bene… questa è l’ultima puntata. E come ultimo argomento abbiamo riservato quello secondo me più interessante relativamente al discorso dei Pool di Oggetti: i pool di thread. Infatti, un pool di thread ha delle caratteristiche peculiari, sia per quanto riguarda il suo utilizzo, sia per le difficoltà che presenta. 


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!

Vai alla Home Page di MokaByte
Vai alla prima pagina di questo mese


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
mokainfo@mokabyte.it