MokaByte 101 - 9mbre 2005
 
MokaByte 101 - 9mbre 2005 Prima pagina Cerca Home Page

 

 

 

Gestione delle risorse all'interno degli application server

La scrittura di applicazioni server side è stato l'elemento trainante della diffusione di Java. Le specifiche J2EE che comprendono servlet, jsp, ejb, etc consentono di realizzare applicazioni web in grado di soddisfare specifiche funzionali decisamente articolate. A differenza della "generazione precedente", quella client/server, dove ogni computer esegue un programma che salva le infomazioni su una base dati comune, le applicazioni che prevedono un application server concentrano un insieme nutrito di funzionalità all'interno di un unico componente condiviso da tutti gli utenti. La condivisione di uno strato software fra vari utenti richiede una particolare cura nella programmazione in quanto la concorrenza rende particolarmente critica la gestione delle risorse condivise. Se una sessione utente richiede o blocca un numero elevato si risorse ne soffre l'intero sistema con l'aggravante che un eventuale riavvio coinvolge anche altri utenti collegati.

Differenze fra applicazioni a due e tre livelli
Le applicazioni a due livelli, in cuil'utente segue direttamente il programma sul proprio computer e salva i dati in maniera centralizzata (tipicamente sun database relazionale), implicano un modello di gestione delle risorse dedicato, ovvero in cui esiste una risorsa che viene utilizzata in maniera sequenziale dai vari moduli software. Un esempio classico è la connessione al database, che viene creata all'avvio del programma e terminata alla fine della sessione di lavoro. Un discorso analogo vale per la lettura da file system o per l'esecuzione di processi locali. Quando si riscontra un errore bloccante, ovvero l'utente non è più in grado di continuare a lavorare, uno stop del programma, o nei casi peggiori un riavvio del sistema, sono generalmente sufficienti per risolvere il problema.
Se si lavora con una applicazione a tre livelli, in cui ne esiste uno condiviso che si fa carico della logica applicativa, la prospettiva cambia, in quanto un errore bloccante a livello di application server costringe tutti gli utenti a fermarsi e ad attendere. E' importante progettare gli applicativi in maniera differente, per ridurre al minimo la possibilità di blocco. Il fatto che un application server esegue più thread contemporaneamente complica ulteriormente la situazione in quanto non solo è necessario gestire le risorse secondo un modello "acquisizione-rilascio", ma è necessario "proteggerle" per essere sicuri di averne l'utilizzo esclusivo. Lo scopo dell'articolo è quello di passare in rassegna i pattern più comuni che sottendono la gestione delle risorse (singleton e pool) e capire quando e come possono essere applicati.

 

I due pattern più comuni: singleton e pool
L'architettura di un applicazione a tre livelli concentra l'utilizzo delle risorse a livello di application e database server, sono questi due componenti che devono far fronte alle richieste degli utenti e che risultano i più stressati dal punto di vista del carico computazionale, se l'interfaccia è di tipo HTML non è possibile utilizzare le risorse hardware del client per eseguire operazioni applicative. La diretta conseguenza è che le applicazioni J2EE devono essere progettate utilizzando meccanismi che consentano di ottimizzare l'uso delle risorse del sistema operativo e dell'hardware sottostante. Esistono due pattern di programmazione che possono risolvere il problema: il singleton ed il pool; il loro scopo è quello di garantire che un determinato componente possa venire condiviso da tutte le sessioni utente e che il suo utilizzo garantisca un minor carico elaborativo.

 

Singleton
Il singleton, come si deduce dal significato della parola stessa, rappresenta un componente unico all'interno del sistema; questo significa che all'interno di una web application esiste un solo oggetto di quella classe che viene utilizzato da tutte le sessioni. Per garantire una corretta utilizzo dei suoi metodi è necessario progettare il singleton in manaiera tale da gesite la concorrenza, ovvero l'utilizzo contemporaneo da parte di più sessioni.
Quando è conveniente utilizzare un Singleton?
Esistono vari casi, i più frequenti sono:

  • Quando il singleton modella un oggetto la cui creazione è molto costosa, mentre il suo utilizzo non richiede particolari risorse
  • Quando si vuole centralizzare l'accesso e la gestione di risorse comuni

Un esempio del primo punto potrebbe essere un parser di documenti XML con schemi XSD particolarmente complicati e corposi. Esistono protocolli di messaggistica XML i cui file XSD sono costituiti da file della dimensione complessiva di qualche MB; in questi casi se si utilizzano le normali api XML, per ogni messaggio che l'applicativo deve elaborare si crea un parser che carica gli schemi XSD e verifica la correttezza del messaggio in ingresso. In un sistema simile la maggior parte del tempo (ma anche della memoria) è spesa nella lettura della grammatica XSD e nella creazione di una sua rappresentazione all'interno della JVM (con circa due MB di XSD i tempi di analisi degli XSD costituiscono più del 90% del tempo di parsing complessivo), operazione che potrebbe essere centralizzata ed eseguita una volta sola in quanto, di solito, gli schemi XSD di protocolli standard tendono a cambiare con una frequenza molto molto bassa. Se si crea un oggetto deputato al parsing dei messaggi XML come un singleton, è possibile leggere e analizzare gli schemi in fase di inizializzazione del sistema; successivamente ogni sessione può richiedere l'uso del parser per verificare la correttezza del messaggio XML in ingresso. Il grosso vantaggio è costituito dal risparmio di tempo di processore e di memoria, l'unico svantaggio è che il parsing diventa un'operazione concorrente per cui è necessario utilizzare il costrutto synchronized nelle parti di codice che accedono allo stato dell'oggetto.
L'utilizzo di un singleton, se da un lato garantisce un uso più intelligente delle risorse del sistema, dall'altro introduce un problema di scalabilità: tutte le sessioni devono attendere il proprio turno per utilizzare l'oggetto, per cui questo può facilmente diventare un collo di bottiglia del sistema. Per evitare situazioni simili si ricorre ad un altro pattern: il pool

 

Pool
Lo scopo di un pool è quello di gestire l'allocazione delle risorse "costose" in maniera tale da garantirne l'uso intelligente che si ottiene con il singleton, ma senza sacrificare la scalabilità. Il pool infatti è in grado di gestire più risorse contemporaneamente e di assicurare che altri oggetti possanono utilizzarle in maniera esclusiva. La modalità di utilizzo del pool prevede sempre due operazioni, la richiesta della risorsa ed il suo rilascio; lo schema di implementazione solitamente prevede la seguente logica:

  • all'atto della creazione del pool vengono allocate min risorse e vengono collocate nella lista di quelle disponibili
  • quando un oggetto chiede una risorsa il pool verifica che ce ne sia una disponibile
  • se non ci sono risorse disponibili il pool prova crearne una nuova se il numero complessivo non supera max
  • se il numero complessivo di risorse create è pari a max e non ce ne sono di disponibili ci si mette in attesa per t millisecondi, al termine dei quali, se la scarsità persiste si ritorna un errore

E' importante che il pool implementi correttamente il concetto di max risorse allocate, altrimenti si vanifica lo scopo del pattern stesso, ovvero quello di limitare la creazione di oggetti costosi, il cui sovranumero può causare rallentamenti e blocchi; è altrettanto importante che gli utilizzatori del pool lo facciano assicurandosi di garantire il rilascio della risorsa onde evitare di raggiungere subito il numero massimo di allocazioni.
Quali sono le risorse che la cui gestione dovrebbe essere delegata ad un pool?
Abbiamo già visto, nel caso del parser XML, quelle in cui la fase di creazione ha un costo elevato rispetto al suo utilizzo; esiste anche un'altra categoria ed è quella in cui la risorsa è disponibile in numero finito. A questa seconda categoria può appartenere per esempio la connessione al database; analizziamo perchè è importante che queste vengano gestite in maniera controllata.
Per poter eseguire delle operazioni su un database è necessario utilizzare un canale di comunicazione che permetta di inviare le istruzioni al motore DBMS; nella maggior parte dei casi il programma che richiede l'operazione e il database sono in esecuzione su eleboratori differenti per cui il protocollo prevede un processo client ed uno server. La conseguenza diretta è che all'atto di una connessione al database sul server viene creato un processo (o in alcuni casi un thread) di sistema operativo dedicato a gestire i comandi inviati da uno specifico client. La creazione di un processo può essere una operazione costosa in quanto spesso il software del database deve inizializzare tutte le strutture dati che serviranno per eseguire i comandi della sessione. Esistono database, Oracle per esempio, in cui la creazione di una sessione può richiedere qualche decimo di secondo, mentre ce ne sono altri, come MySQL, in cui tale operazione richiede molto meno tempo; in ogni caso entrambi richiedono la creazione di un processo lato server. Su molti sistemi Unix il numero di processi che un utente di sistema operativo può creare è limitato da un parametro del kernel (la cui modifica spesso richiede un riavvio del sistema); se non si usa il connection pool per collegarsi al database è possibile che una applicazione mal progettata saturi il numero di processi UNIX relativi a quel determinato utente ottenendo lo spiacevole effetto che non è possibile nemmero riuscire a fare login sul server del DBMS!
Per evitare in ogni caso problemi del genere è opportuno assicurarsi (quando possibile), a livello di database, di limitare il numero di connessioni ad un valore inferiore al numero di processi di sistema operativo in maniera tale da avere sempre il controllo del server.

 

La gestione delle risorse al livello di codice
Da quanto abbiamo detto finora è importante per tutte le risorse costose e/o finite (in realtà tutte le risorse sono finite, ma alcune lo sono più di altre) adottare un pattern di programmazione del tipo "acquisizione-rilascio" per garantire che ve ne siano sempre di disponibili.
Consideriamo il seguente frammento di codice (preso da una applicazione reale):

......
Properties confProperty;
......
confProperty.load(new FileInputStream(confFile));
......

Queste poche righe di codice che a prima vista sembrano "innocenti" hanno causato un errore sull'application server (linux) che lo eseguiva e hanno costretto l'amministratore allo spegnimento e avvio della web application. Dove sta l'errore?
L'errore sta nel fatto che la classe Properties si aspetta un InputStream e alla fine del metodo load non invoca il metodo close dello stream in ingresso. Poiche' lo stream viene creato al volo a partire dal nome del file non esiste nessuna possibilità di chiuderlo. Le conseguenze del frammento di codice che abbiamo visto sono il progessivo esaurimento dello spazio libero nella tabella dei file descriptor di Linux, per cui ad un certo punto si ottiene un errore di sistema operativo "too many files open". L'aspetto più sfortunato è l'errore si verifica quando viene aperto n+1-esimo file descriptor, operazione che può avvenire in un altro punto del codice, magari in cui, poche righe dopo è presente l'istruzione di close. In situazioni simili (ottenere un errore "too many files opern" in concomitanza di una chiamata a cui segue una close) diventa difficile venire a capo dell'errore, a meno di non intuire subito che si tratta di un problema di gestione di risorse e cercare in tutto il codice la possibile causa. Se la chiamata viene fatta una tantum il primo errore si può verificare giorni o addirittura mesi dopo il deploy. Qual' è il modo giusto per risolvere il problema?
La soluzione sta nell'utilizzo di un pattern di programmazione che prevede di assicurarsi che la risorsa venga effettivamene rilasciata dopo il suo utilizzo, per cui una prima ri-scrittura del codice porta a:


......
Properties confProperty;
......
InputStream inputStream=new FileInputStream(confFile)
confProperty.load(inputStream);
inputStream.close();
......

Questa versione, che migliora decisamente la gestione delle risorse soffre ancora di un punto debole, la certezza del del rilascio della risorsa; infatti cosa succede nel caso che il metodo invocato (in questo caso load) generi un'eccezione? La risorsa non viene deallocata e il problema si ripresenta in maniera ancora più sottile, perchè i file descriptor "fantasma" aumentano solo in caso di eccezione. L'approccio corretto corrisponde a:

......
Properties confProperty;
......
InputStream inputStream=new FileInputStream(confFile)
try {
confProperty.load(inputStream);
} finally {
inputStream.close();
}
......

In questo caso siamo sicuri che ad ogni allocazione corrisponde una deallocazione, anche in caso di errore.

 

Il caso generale
Abbiamo concluso il paragrafo precedente con un esempio che illustra come gestire un singolo caso, vediamo ora come è possibile generalizzarlo per adattarlo ad ogni esigenza. Una corretta gestione delle risorse prevede la strutturazione del codice nel seguente modo:

......
//Creazione/Acquisizione risorsa
try {
//uso della risorsa
} finally {
//Rilascio della risorsa
}
......

Alla fine il patten di utilizzo è un semplice try/finally, che se correttamente utilizzato può risolvere una serie infinita di grattacapi. La scomodità di questo pattern deriva dal fatto che spesso si usano molte risorse contemporaneamente si ottiene un codice più difficile da leggere a causa dei vari livelli di indentazione (ma anche logici) introdotti. Consideriamo il seguente frammento di codice che opera una lettura su database:

Connection connection=pool.getConnection();
try {
PreparedStatement ps=connection.prepareStatement(SQLQuery);
try {
ps.setString(PARAM,paramValue);
ResultSet rs=ps.executeQuery();
try {
while (rs.next()) {
System.out.println(rs.getString(1));
}
} finally {
rs.close();
}
} finally {
ps.close();
}
} finally {
pool.releaseConnection(connection);
}

Se non fossimo "costretti" ad utilizzare il costrutto try finally otterremmo dei programmi decisamente più compatti e più leggibili, ma decisamente a rischio; se nell'esempio sopra riportato ci si dimentica la riga con ps.close() e si sta utilizzado un database Oracle è certo che prima o poi si incontrerà l'errore:

ORA-01000: maximum open cursors exceeded

E' importante quindi assicurarsi che per dopo aver utilizzato un oggetto che rappresenta una risorsa finita, invocare il metodo corrispodente per assicurarsi che questa venga effettivamente rilasciata.

 

Si può evitare il finally?
Concludiamo la nostra esplorazione delle gestione delle risorse con una rilessione sulla natura del costrutto finally; esso infatti non è presente in tutti i linguaggi, ad esempio il C++ manca, come vengono gestiti gli stessi problemi?
La risposta sta nel diverso modello di gestione della memoria; in Java, dove esiste un garbage collector, non sappiamo con certezza quando l'oggetto verrà deallocato (e presubilmente "chiuso") per cui dobbiamo assicurarci di farlo noi esplicitamente; in C++ possiamo allocare gli oggetti sullo stack con la garanzia che quando si esce dal blocco di codice l'oggetto verrà automaticamente deallocato (tramite distruttore). L'utilizzo di linguaggi con garbage collector e gestione della memoria basata su heap, se da un lato rende più semplice la gestione della memoria nascondendo le operazioni di base sui puntatori, dall'altro ci obbliga ad avere codice più pesante nella gestione delle risorse in quanto dobbiamo gestire la clausola finally.

 

Conclusioni
L'articolo ha evidenziato come la gestione delle risorse all'interno degli application server richieda un paradigma di programazione diverso, basato sua alcuni pattern di programmazione come il singleton ed il pool; al livello di codice è necessario utilizzare il concetto di acquisizione-rilascio; ovvero un corretto utilizzo del costrutto finally. Questo se da un lato garantisce l'uso corretto delle risorse, dall'altro obbliga a codice più verboso e difficile da leggere, ma è il prezzo da pagare per chi usa un linguaggio che ha un modello di memoria che utilizza un garbage collector.

Giovanni Cuccu è laureato in ingegneria informatica a Bologna. Attualmente svolge attività di consulenza per lo sviluppo di applicazioni web tramite Java, Oracle. Si interessa di applicazioni open source, usa Linux regolarmente ed è autore di un Oracle session profiler open source (scritto in Java) scaricabile da http://sourceforge.net/projects/oraresprof/