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/
|