Nell'articolo pubblicato sul Mokabyte di aprile<sup>(1)</sup> abbiamo effettuato una panoramica sulle tante iniziative che ruotano attorno all'applicazione di Java ai sistemi real-time ed embedded.
Questo mese cerchiamo di approfondire le caratteristiche del PERC, un prodotto che si rivela particolarmente interessante, non tanto per la sua posizione di mercato o la sua attuale diffusione, quanto, una volta tanto, per le soluzioni tecniche e l'impostazione teorica volta all'applicazione in settori come l'aereonautica o il nucleare dove la rispondenza o meno a criteri rigorosamente real-time si misura in termini di vite umane e salvagurdia dell'ambiente più che in semplici montagne di dollari!
Un ringraziamento a Kelvin Nielsen per averci permesso di utilizzare materiale tratto direttamente dalla documentazione ufficiale del sito NewMonics.
Origini del PERC
Il papà del PERC, Kelvin Nilsen, spiega in questo modo la ragion d'essere del suo prodotto sin dall'inizio del progetto: "".<sup>(2)</sup>
Nell'affrontare il compito di creare un Java real-time, bisogna affrontare due principali categorie di problemi:
- Caratteristiche mancanti nello standard
- Caratteristiche esistenti non compatibili con i vincoli RT
Questi temi sono approfonditi nella documentazione originale del PERC<sup>(2)</sup>, ma è possibile accedere ad una trattazione assai più informale e divulgativa nel mio articolo dello scorso mese.<sup>(1)</sup>. Da queste fonti è anche possibile approfondire i motivi per cui, comunque, vale la pena di cercare di applicare Java al mercato embedded.
In questa sede riassumiamo i punti salienti per cui una comune implementazione Java, anche costruita su un sistema operativo RT (RTOS), fallisce nel rispettare i vincoli richiesti da applicazione effettivamente real-time:
Gestione della memoria
L'implementazione del garbage-collector<sup>(7)</sup>, tanto caro ad ogni programmatore Java, costituisce uno dei problemi principali nel controllo dei tempi e della disponibilità di risorse. Il GC di Java risente dei seguenti problemi:
Frammentazione e schedulazione
la memoria viene allocata e rilasciata in modo non tipizzato e, inoltre, non esiste alcun meccanismo di recupero degli oggetti "vivi" dalla memoria rilasciata. Questo provoca una progressiva frammentazione della memoria che, da un lato, tende a degradare nel tempo le prestazioni di un'applicazione Java e, dall'altro, richiede periodiche interruzioni del normale flusso d'esecuzione dei programmi per ricompattare la memoria in modo da ottenere spazio contiguo sufficiente a soddisfare le successive richieste di allocazione. In effetti, così come la politica di schedulazione della CPU, il GC di Java è pensato nella direzione di ottimizzare la condivisione delle risorse da parte dei vari task così che, anche se la memoria totale del sistema è inferiore alla somma delle massime richieste di ogni thread, l'esecuzione di questi può di norma proseguire in quanto tali richieste massime non avengono contemporaneamente. In caso di situazioni critiche, l'esecuzione di qualche task viene penalizzata in quanto l'istruzione new può fallire lanciando un'eccezione che, eventualmente, può venire catturata per implementare un meccanismo di attesa fino a che la memoria neccessaria non si renda disponibile. Tutto ciò e semplicemente l'opposto dei requisiti di un programma RT. Tanto è vero che, spesso, i sistemi real-time classici non prevedono l'allocazione dinamica della memoria obbligando i vari task ad allocare a priori tutte le risorse necessarie per ogni successivo stato d'esecuzione, con uno spreco sull'economia generale durante l'esecuzione media ma con la garanzia di avere sempre a disposizione tutto il necessario per un'esecuzione effettivamente real-time. In fondo a questo articolo vedremo una piccola ricerca che ho svolto per evidenziare vantaggi e svantaggi di una gestione tipizzata della memoria.
Mancanza di controllo
Per un thread non c'è modo di sapere di quanta memoria ha bisogno, quanta è richesta da tutti gli altri thread ospitati dal sistema e quanta memoria abbia a disposizione l'intera JVM. La reliability d'esecuzione diventa quindi una vera e propria scommessa.
Impossibilità di stabilire budget di memoria per le diverse attività
Non esistono metodi per stabilire i budget di memoria necessari per l'esecuzione di ogni attività. Questo è vero sia in compilazione che a tempo d'esecuzione. Questo non consente politiche di configurazione per contratto, ovvero dove il sistema garantisce ad un'oggetto allocato la disponibilità piena della sua massima richiesta. Inoltre, è evidente, un qualsiasi task può candidamente richiedere tutte le risorse di memoria del sistema lasciando gli altri all'asciutto!
- Schedulazione dei task
-
- Lo scheduler non garantisce che un thread selezionato per l'esecuzione abbia la massima priorità (qusto può sembrare banale, ma non lo è nei sistemi RT)
- Un task che chieda di dormire per un certo numero di millisecondi non ha la garanzia di essere risvegliato all'esatto momento richiesto piuttosto che ad un qualunque istante successivo
- Non è possibile conoscere quanti altri thread siano in esecuzione e con quali priorità. Quindi un task non sa se, al momento del risveglio, avrà a disposizione tutte le risorse necessarie per svolgere i propri compiti.
Sincronizzazione
Un sistema real-time ha bisogno di conoscere a priori per quanto tempo ogni task manterrà il lock su ogni singola risorsa. Il sistema si sincronizzazione tipo monitor di Java non fornisce informazioni sufficienti per questo tipo di analisi. In particolare mancano dati su:
-
- Numero e priorità dei thread in copetizione su ogni risorsa. Come ulteriore conseguenza, non è possibile attuare meccanismi per evitare l'effetto detto "inversione di priorità", dove un task di priorità alta attende il rilascio di una risorsa da parte di un task di priorità bassa che, a sua volta, è fermo in attesa di un terzo task di priorità media.
- Tempo massimo e medio di percorrenza del codice all'interno di un monitor
Analisi real-time
Le informazioni sulle necessita di tempo e memoria dei vari thread non sono note a priori (ad esempio registrate nei file di classe). Di conseguenza:
-
- Non esistono meccanismi che, al momento del caricamento di una nuova classe, verifichino la compatibilità tra le sue richieste e le risorse diponibili nel sistema
- Non esiste un meccanismo di assoluta sicurezza per sapere, già dal file di classe, quale sarà l'occupazione di memoria di un oggetto allocato
- La libreria standard Java non consente di conoscere la capacità totale di memoria del sistema o quanta CPU e memoria siano attualmente richieste dalle altre attività caricate sul sistema.
Oltre alle suelencate problematiche di tipo teorico, esistono una serie di altri problemi dovuti alle correnti implementazioni delle JVM che non sono ottimizzate per l'esecuzione in spazi ridotti, usando per esempio 32 bit anche per rappresentare il tipo byte. E' inoltre evidente che la tecnologia su cui punta la SUN per ottenere performance professionali sui normali computer, ovvero la compilazione dinamica, non è assolutamente compatibile con i sistemi RT, non solo a causa dei tempi di compilazione attivati in modo asincrono, ma anche perchè i calcoli sui tempi d'esecuzione del codice JVM vengono stravolti non appena qualche routine venga arbitrariamente compilata dal JIT (comunque la NewMonics sta lavorando su un JIT che consenta di mantenere il controllo dei tempi d'esecuzione dei vari metodi).
La via "dura" del PERC
Per conservare tutti i vantaggi di Java e mantenere il pieno rispetto dei vincoli RT, il PERC, al contrario di molti altri prodotti che si affacciano sul mercato, non sceglie la strada dell'adattamento o dell'implementazione ad hoc dell JVM. La scelta radicale è, invece, quella di introdurre alcuni nuovi costrutti nel linguaggio, come vedremo in seguito, modificare le politiche di scheduling e la JVM in modo da gestire le informazioni addizionali necessarie ad un sistema RT ed aggiungere nei file di classe, che del resto già prevedono un meccanismo per l'estensione, tutti i dati già computabili staticamente in fase di compilazione. L'uso delle estensioni viene semplificato da specifiche librerie di classi.
Il risultato è, a mio avviso, sorprendente: la PVM (PERC Virtual Machine) conserva la piena compatibilità con il codice Java e con il formato standard dei file di classe. E' quindi in grado di eseguire, nel tempo residuo di CPU lasciato dai task RT, qualsiasi programma Java standard. Ma ancora di più, i programmi PERC sono eseguibili dalle normali JVM perdendo unicamente le caratteristiche real-time. In effetti è anche difficile distinguere un programma scritto in PERC da un normale programma Java, se non fosse per un paio di costrutti addizionali, il che apre potenzialmente le porte di nuovi campi d'applicazione anche a programmatori provenienti da settori dell'informatica da tavolo, e questo è un aspetto da non sottovalutare in un mercato che richiede una sempre più stretta integrazione tra i programmi manageriali, i sistemi di progettazione e quelli di controllo di processo.
Il PERC è attualmente un prodotto commerciale distribuito dalla NewMonics Inc.
Un po' di teoria
Il mese scorso ho introdotto il modelo di computazione real-time su cui si fonda il PERC<sup>(1)</sup>, adesso daremo uno sguardo, non troppo formale, ai fondamenti teorici su cui il linguaggio è stato costuito, sempre prendendo come riferimento il lavoro di Nielsen<sup>(2)</sup>.
Uno dei concetti fondamentali è il rate-monotonic scheduling, ovvero il meccanismo che consente di determinare in fase di sviluppo se un insieme di task RT verrà eseguita entro un tempo determinato. La tecnica utilizzata prevede la computazione, per ogni thread, del tempo massimo d'esecuzione (wrost execution time) e della sua massima frequenza d'esecuzione. La priorità di un task risulta direttamente proporzionale alla sua frequenza d'esecuzione. Traduco testualmente (nei limiti del mio pessimo inglese) un esempio fatto da Nielsen in (2) per spiegare il calcolo della percentuale d'occupazione della CPU da parte di un determinato task RT:
"Sia Ci il tempo di computazione del task i e sia Ti il periodo minimo d'esecuzione del task i. Per esempio, il task 1 e responsabile di visualizzare i frame aggiornati a 20 frames al secondo ed ogni aggiornamento richiede 10 ms di tempo CPU, quindi C1 è 10 ms e T1 è (1/20) s = 50 ms. Notare che questo task utilizza 1/5=20% del tempo totale della CPU di sistema. L'utilizzazione totale, Utotal, di un sistema di n task real-time è data da
(...) il limite d'utilizzazione UB(n) per questo insieme di n task real-time è dato da:
per n grandi, UB(n) approssima ln 2, che è circa 69%. Fin tanto che Utotal \< UB(n), ogni task completerà l'esecuzione prima del prossimo periodo in cui è richiesta l'esecuzione."
Nell'esempio non sono previsti blochi di sincronizzazione per dati condivisi. Per questi casi vengono attuate analisi più complesse. Un punto da tener presente in ogni sistema che compia delle analisi al volo della schedulabilità, è che il peso computazionale dell'analisi stessa venga minimizzato. Il tipo di analisi quì riportato ha un peso relativamente basso e, soprattutto, presenta un andamento lineare al crescere del numero dei task.
Come ormai ogni lettore immaginerà, un altro concetto fondamentale del PERC è la realizzazione di un real time garbage collector che assicuri la disponibilità di memoria ai vari task senza interferire con i vincoli real-time di qusti ultimi. Esitono principalmente due modi in cui un GC può rompere le uova nel paniere ad un sistema RT: ritardando, o dando in tempi non deterministici, la memoria richiesta e fermando in modo asincrono l'intero sistema, a seguito di un'allocazione fallita, per ristitemare la memoria frammentata. Secondo Nielsen, la caratteristica principale di un RT-GC è quella di progredire in modo incrementale dividendo il lavoro totale in piccoli intervalli di tempo che vengano schedulati dal real-time kernel alla pari degli altri task RT e, quindi, assicurando che il suo peso computazionale (in particolare la percentuale di tempo di CPU occupata) sia conosciuto dal sistema. Resta il problema di assicurare che l'avanzamento del lavoro del GC sia sufficiente a garantire il fabbisogno di memoria dell'intero sistema. Per capire come si regola il PERC ricorrerò ancora una volta ad un esempio preso, per gentile concessione di K. Nielsen, dalla documentazione del PERC<sup>(2)</sup>:
"Supponiamo, ad esempio, che la memoria totale disponibole sia M bytes e sia conosciuto il tempo S di CPU necessario a compiere una completa garbage collection. Supponiamo inoltre che il fabbisogno totale di memoria delle attivita real-time del sistema sia U bytes in totale e che il througput combinato sia di V bytes totali allocati al secondo. Infine, sia R la frazione di tempo di CPU dedicata al garbage collector. Si noti che il tempo reale richiesto per completare la garbage collection incrementale è S / R. Si consideri lo stato di memoria immediatamente seguente il completamento della garbage collection. Nel peggior stato stabile ci sono U bytes di memoria viva e V (S / R) bytes di memoria morta occupante al momento la heap. Se iniziamo il prossimo passo di garbage collection appena è stato completato il precedente, una quantità addizionale di V (S / R) bytes di memoria sarà allocata mentre questo passo di garbage collection è in esecuzione. Di conseguenza, l'ampiezza dello spazio richiesto per sopportare questo carico di lavoro, M, misurato in bytes, deve essere maggiore o uguale a U + 2V(S/R). In base ai fabbisogni combinati di memoria e alla massima frequenza d'allocazione sopra descritta, la frazione minima di tempo di CPU che deve essere trascorso in garbage collection e dato da:
Si noti che R è proporzionale alla massima frequenza alla quale la memoria è allocata moltiplicata per il tempo richiesto per eseguire un passo stop-and-wait di garbage collection. R è inversamente proporzionale alla differenza tra la quantità di memoria disponibile e la massima quantità di memoria viva."
Per una discussione più dettagliata dell'argomento vedere <sup>(4)</sup> e <sup>(5)</sup>. Ciò che invece interessa noi è la possibilità, avendo a disposizione le necessarie informazioni, di computare in modo deterministico il peso del GC nel sistema e, di conseguenza, poter mantenere una programmazione Pure Java anche nell'ambito dei sistemi più strettamente real-time piuttosto che rinunciare del tutto al GC, come fanno alcuni dei sistemi che intendono essere Java RT.
Vorrei comunque fare un'annotazione personale. L'indirizzo preso dagli ingegneri coinvolti nell'evoluzione di Java è tale da rendere effettivamente difficile il costruire sistemi che non degradino le proprie prestazioni nel tempo a causa dell'utilizzo sovrabbondante dell'allocazione di memoria. Un porgramma Java, attualmente, crea allegramente oggetti in continuazione, anche quando in altri linguaggi ci si limiterebbe a modificare il contenuto degli oggetti già esistenti. Un esempio tra tutti: la serializzazione. Per propria specifica, il meccanismo di serializzazione non memorizza in uno stream più di una copia dello stesso oggetto, anche se il contenuto dell'oggetto stesso cambia mentre lo stream è aperto. In effetti il motore di serializzazione tiene presente solo il reference (o puntatore) che identifica l'oggetto. Se consideriamo che la serializzazione è il meccanismo di base per comunicare tra sistemi remoti, ad esempio via RMI, ci troviamo nell'obbligo di creare un nuovo oggetto ogni volta che i dati da esso contenuti devono essere scambiati con un altro device. In altre parole, il seguente codice non funziona:
class Message implements Serializable{
int code;
long\[\] data;
}
classe Test{
...
ObjectOutputStream oos;
...
Message mess=new Message();
while(true){
int c=waitForDataChanged();
mess.code = c;
mess.data = getDataFromBlock(c);
oos.writeObject(mess); //I dati inviati saranno sempre uguali a quelli spediti la prima volta
}
...
}
La versione funzionante, invece, costringe alla continua creazione di nuovi oggetti di tipo Message:
class Message implements Serializable{
int code;
long\[\] data;
}
classe Test{
...
ObjectOutputStream oos;
...
while(true){
int c=waitForDataChanged();
Message mess=new Message();
mess.code = c;
mess.data = getDataFromBlock(c);
oos.writeObject(mess);
}
...
}
Mi sono imbattuto in questo problema dovendo spedire i dati collezionati, più o meno in real-time, da un programma di controllo di processo a sistemi di monitoraggio remoto. Ho cercato nella bug parade della Java Developper Connection qualche notizia in merito.Ebbene, ho scoperto di non essere l'unico ad ever avuto il medesimo problema ma, con mia grande sorpresa, il punto era considerato chiuso da SunSoft e liquidato come normale comportamento della serializzazione. Questa e molte altre impostazioni del JDK indicano una precisa volontà di utilizzare al massimo l'allocazione dinamica assistita dal GC, anche se questo finisce per essere uno dei motivi principali di degrado delle prestazioni e, chiaramente, va in direzione contraria degli sforzi che la SUN sta compiendo per rendere Java un'alternativa credibile per la programmazione di sistemi embedded.
Componenti del PERC
In primo luogo il PERC fornisce una sua libreria standard per aggevolare lo sviluppo di sistemi RT. Parti principali di tale libreria sono:
Real-time API
Include meccanismi per consentire l'analisi e la misura dei tempi richiesti per l'esecuzione di particolari segmenti di codice e della memoria richiesta per i vari oggetti. Fornisce inoltre un'astrazione per l'accesso agli oggetti persistenti in memoria flash o tamponata. Le applicazioni real time sono composte da diverse attività ognuna comprendente uno o più task RT <sup>(1)</sup>. A sua volta ogni task comprende componenti essenziali ed opzionali, a seconda della definizione data dal programmatore.
Configuration
Prima di poter essere eseguita, una attività deve prima essere presentata al sistema. Il real-time executive chiama a turno il metodo configure() di ogni attività per determinare il fabbisogno di risorse di tutti i task componenti l'attività RT, in termini di quantità minime e desiderate.
Negoziation
Ottenute le informazioni necessarie dal metodo configure(), il sistema tenta di soddisfare le richieste di risorse. Esso propone ad ogni attività RT un budget di risorse invocando il metodo negotiate(). Il budget proposto dal sistema può essere inferiore a quello richiesto dall'attività che, a questo punto, ha la scelta tra accettare o rifiutare il budget proposto. In caso di rifiuto, il real-time executive può decidere di non caricare l'attività o, puttosto, di esigere risorse precedentemente allocate per altre attività, sempre tramite la negoziazione. Se il sistema riesce a raccattare nuove risorse, propone un nuovo budget all'attività in attesa, continuando così la negoziazione.
Come già detto, il PERC introduce alcune estensioni alla sintassi standard del linguaggio:
atomic statement
Il PERC fornisce un meccanismo di sincronizzazione dei task RT alternativo ai monitor di Java, introducendo la nuova keywod atomic. Il codice racchiuso in un blocco atomic è eseguito senza interruzioni (preemption) o fino al completamento o per niente. L'implementazione dei blocchi atomici può variare dalla semplice disabilitazione degli interrupts a meccanismi raffinbati d'analisi, per i sistemi RT spinti. In questo caso il sistema verifica che il tempo di CPU rimanente nella fetta di tempo corrente del task sia sufficiente al completamento del blocco atomic.
timed statement
Altra keyword aggiuntiva del PERC è timed che dichiara il limite massimo di tempo entro il quale debe essere eseguito un blocco di codice. In caso di sforamento del tetto stabilito, il sistema lancia una eccezione di timeout. Ecco un esempio tratto dalla documentazione del PERC<sup>(2)</sup>:
x = computeApproximation();
i = 0;
timed (10 ms) {
for ( ; ; ) {
z = refineApproximation(x);
atomic {
x = z;
i++;
}
}
}
Architettura e sistema di sviluppo
Per gentile concessione di K. Nielsen, NewMonics Inc. I componenti principali del sistema di siviluppo del linguaggio PERC sono i seguenti: p2jpp, un preprocessore che converte il codice sorgente PERC in normale codice Java compilabile con javac. In particolare p2jpp traduce i costrutti aggiuntivi timed ed atomic simulandoli, entro certi limiti, con i normali costrutti Java. Il Percolator, invece, sostituisce javac compilando il codice PERC direttamente in Annotated JavaByte code, completamente compatibile con il Jcode normale ma con informazioni aggiuntive sulla richiesta di risorse registrate direttamente nel file di classe. Si noti la completa compatibilta dell codice prodotto dal Percolator con le normali JVM, pur rinunciando all'esecuzione real-time. L'ultimo componente del PERC e la PVM (PERC VM), prodotta in proprio dalla NewMonics, ditta fondata da Nielsen, senza partire dalla licenza originale SUN. Sono interessanti le annotazioni riportate nella presentazione del prodotto rispetto l'esecuzione del codice PERC sulle normali JVM. Principalmente vengono perse le seguenti caratteristiche:
- La JVM non capisce gli attributi speciali inseriti nei file di classe dal Percolator e non è quindi in grado di utilizzarle per ottimizzare l'esecuzione del codice.
- Ogni controllo di temporizzazione viene perso.
- Is unable to perform schedulability analysis so it cannot guarantee real-time tasks that they will execute within their real-time constraints.
- La memoria viene allocata su base globale e nessun controllo può essere fatto sui budget richiesti dalle varie attività.
- Il grabage collector, nella maggior parte delle JVM, lavorerà in modalita stop-and-wait e non in modo incrementale.
Nondimeno il codice PERC mantiene alcune caratteristiche utili anche se eseguito su JVM classiche:
- I programmatori hanno a disposizione una notazione standard per inserire nel codice i vincoli di tempo e risorse, aggevolando la manutenzione del codice nel lungo periodo.
- Anche in un ambiente d'esecuzione classico, le librerie del PERC sono in grado di rilevare un decadimento delle performance real-time e ritornare l'informazione in modo che l'applicazione possa eseguire azioni correttive e di bilanciamento.
picoPERC
Il PERC, con la sua piena compatibilità con Java, è sicuramente pensato per sistemi di dimensioni medio-grandi. La NewMonic sta elaborando lo standard picoPERC per piccoli sistemi embedded seguendo una strada minimalista più vicina a quella di altri produttori. In particolare il garbage-collector viene sostituito con funzioni di disallocazione esplicita della memoria. Il picoPERC sarà commercializzato entro l'anno.
Conclusioni
Tirando le somme, il PERC dimostra che oggi esiste almeno un'alternativa per sviluppare sistemi strettamente real-time in Java.
Rimangono tuttavia una serie di questioni, principalmente legate alle performances, che sono strutturali del linguaggio e non potranno essere risolte con compilatori più o meno dinamici. Ad esempio il meccanismo di chiamata ai metodi, a causa dell'implementazione dell'ereditarietà e del polimorfismo, è decisamente più lento dell'invocazione di una funzione C. Gli attuali garbage-collector, RT o meno, degradano sensibilmente le performances generali rispetto all'uso esplicito di malloc/free. Anche la memoria minima richiesta tende a essere maggiore dei minimi irrisori necessari per far girare programmi embedded scritti in C o assembler.
In conclusione, i sistemi real-time scritti in PERC (o comunque in Java) avranno bisogno di processori mediamente più veloci e di maggiori risorse. Questo ci porta a valutare l'introduzione di Java nel campo dei piccoli dispositivi (quelli per i piccoli elettrodomestici, ad esempio) un cammino in salita. Al contrario, i vantaggi in termini di economia di progetto e di manutenzione, nonchè di sicurezza di funzionamento, che si potranno ottenere adottando Java, magari in varianti come il PERC, su sistemi medio-grandi saranno tali che possiamo fin d'ora prevedere una rapida diffuzione del linguaggio nell'industria magari fino a soppiantare di fatto alcuni dei sistemi tradizionali.
Risorse
1. Spinelli, P. Quattro chiacchiere su Java ed i sistemi embedded. Mokabyte, Aprile 1998. 2. <http://www.newmonics.com/WebRoot/technologies/PERC/escw.html> 3. Nilsen, K. Progress in Hardware-Assisted Real-Time Garbage Collection. in Lecture Notes in Computer Science \#986. 1995. Kinross, Scotland: Springer-Verlag. 4. Nilsen, K., Reliable Real-Time Garbage Collection of C++. Computing Systems, 1994. 7(4): p. 467-504. 5. Nilsen, K., Real-Time Java (v. 1.1). 1996, Iowa State University, Ames, Iowa. 6. Nilsen, K., Issues in the Design and Implementation of Real-Time Java. 1995, Iowa State University: Ames, Iowa. 7. Carli, M, Garbage Collector in Java. Mokabyte, Marzo 1998.
Una mini-ricerca sull'allocazione tipizzata
Mi sono trovato più volte a dovere affrontare, tentando di dare ai programmi una veste seppur lascamente real-time, il problema del surplus di oggetti allocati, in particolare di quelli che si possono definire oggetti consumabili, caratterizzati da un'alta frequenza di creazione e da un ciclo di vita brevissimo: quel tanto che basta a trasmettere un'informazione da un thread ad un altro (magari remoto) o a fungere da wrapper per dati base da passare come argomenti a metodi che si aspettano un tipo reference. Il peggior effetto di questo abuso dell'allocazione è il degrado delle prestazioni con l'avanzare, nel tempo, della frammentazione della memoria che richiede frequenti stop-and-wait del programma per consentire al GC di ricreare uno stato gestibile della heap<sup>(7)</sup>.
In questi casi, ove possibile, ho cercato di ammortizzare il numero di allocazioni effettive (quelle fatte con new e poi abbondanate al tenero abbraccio del garbage collector). Una tecnica che a volte riscuote qualche risultato consiste nel riservare, staticamente o dinamicamente, un pool di oggetti preallocati appartenenti alla stessa classe. L'applicazione di tale tecnica è sottoposta a severi vincoli, esistendo situazioni di totale inapplicabilità ed altre dove essa può compromettere la sicurezza del sistema. Inoltre è neccesario, caso per caso, valutare l'overcharge di usare routine ad alto livello di allocazione e disallocazione nei confronti della rapidissima new (rapidissima, certo, a patto di trovare memoria contigua immediatamente disponibile). Altro fattore da considerare è che la ritenzione di memoria usata solo parzialmente può incidere sull'economia generale, specialmente per programmi di grosse dimensioni.
Fatte tutte queste precisazioni, i dati forniti dalle piccole prove riportate in questo articolo rivelano effetti interessanti sull'ottimizzazione dell'uso del tempo e della memoria e, a puro scopo didattico, suggeriscono come le tecniche di allocazione tipizzata, a cui si è fatto cenno nell'aricolo sul PERC, implementate a livello di JVM, possano effettivamente impattare sulla predicibilità delle prestazioni del garbage collector.
Partiamo con il definire una classe astratta per gli oggetti passibili di allocazione tipizzata:
package PJSoft.TypedPool;
public abstract class Allocable{
Allocable next=null;
protected Allocable(){}
}
In questo caso ho preferito utilizzare una lista collegata piuttosto che degli array di reference che potrebbero fornire prestazioni superiori. Questo per semplicità di implementazione e per poter focalizzare queste poche righe d'esposizione sul nucleo della tecnica piuttosto che su lunghi dettagli implementativi. L'uso di una classe astratta e di un costruttore protected è già di per sé un vincolo introdotto che impedisce l'uso di oggetti non appositamente pensati per questo tipo di utilizzo.
Vediamo ora la classe creata per gestire il pool di oggetti preallocati appartenenti ad una stessa classe derivata a Allocable:
package PJSoft.TypedPool;
import java.util.\*;
public final class TypedPool{
private volatile Allocable first=null;
private Class c;
private int lMax=50;
private int lCnt=0;
TypedPool(Class c, int lMax){
> this.c=c;
> this.lMax=lMax;
}
public synchronized Allocable obtainObject(){
Allocable a=null;
if(first==null){
try{
a =(Allocable)c.newInstance();
}catch(Exception e){
e.printStackTrace();
System.exit(1);
}
}else{
a = first;
first = first.next;
a.next = null;
lCnt--;
}
return a;
}
public synchronized void releaseObject(Allocable obj) throws TypedPoolCastException {
if(!(obj==null \|\| c.isInstance(obj))) throw(new TypedPoolCastException ());
if(lCnt\>=lMax) return;
obj.next = first;
first = obj;
lCnt++;
}
void setMax(int lMax){this.lMax = lMax;}
public int getCount(){return lCnt;}
}
Uno dei limiti dell'uso di una lista collegata, oltre alla maggiore occupazione di memoria, è la difficoltà di rilasciare la memoria eventualmente allocata oltre certi limiti (ad esempio per un picco di eventi) quando questa si riveli superflua per il fabbisogno medio del sistema. In effetti aggiungere un contatore e percorrere parte della lista per rilasciare gli oggetti uno ad uno potrebbe essere molto penalizzante. In questo caso ho scelto di inserire un limite lMax, impostabile per ogni singolo pool, oltre il quale gli oggetti restituiti tramite releaseObject non vengono reinseriti nella lista.
Ora che abbiamo visto la classe pool, è più semplice inquadrare alcuni dei limiti della tecnica descritta:
1. E' necessaria la piena collaborazione tra tutti i thread che entrano in possesso di un riferimento ad un oggetto di tipo Allocable onde assicurare che nessuno di essi sia ancora in uso al momento della restituzione dell'oggetto stesso al suo TypedPool. In questo senso si torna alla situazione del buon vecchio linguaggio C. 2. Le classi derivate da Allocable non possono implementare l'interfaccia Serializable in quanto, come visto in precedenza, la serializzazione tiene in conto solo i riferimenti e non i contenuti degli oggetti. Quindi, un oggetto ripescato più volte dal pool, pur contendendo nuovi dati, non viene inserito in uno stream più di una volta.
A controllare che le classi per cui si desidera creare un pool corrispondano a certe caratteristiche, viene fornita la classe TypedPoolFactory:
package PJSoft.TypedPool;
import java.util.\*;
public final class TypedPoolFactory {
private static Hashtable pools=new Hashtable();
private TypedPoolFactory(){}
public static synchronized TypedPool obtainPool(Class c, int lMax) throws TypedPoolCastException {
Class sc=c.getSuperclass();
while(sc!=null && !sc.getName().equals("PJSoft.TypedPool.Allocable")){
sc=sc.getSuperclass();
}
if(sc==null) throw(new TypedPoolCastException ());
Class is\[\] = c.getInterfaces();
for(int i=0;i\<is.length;i++){
if(is\[i\].getName().equals("Serializable")) throw(new TypedPoolCastException ());
}
TypedPool pool=(TypedPool)pools.get(c.getName());
if(pool==null){
pool=new TypedPool(c, lMax);
pools.put(c.getName(), pool);
}else{
pool.setMax(lMax);
}
return pool;
}
public static synchronized void deletePool(Class c){
try{pools.remove(c.getName());}catch(Exception e){}
}
}
Ecco un programma di test che ci permette di paragonare l'andamento nel tempo delle perfomances, in termini di numero di oggetti allocati a parità di tempo, nelle seguenti situazioni:
- 10 thread produttori ed uno consumatore utilizzanti new
- 10 thread produttori ed uno consumatore utilizzanti un TypedPool
- 5 thread produttori ed uno consumatore utilizzanti new più 5 thread produttori ed uno consumatore utilizzanti un TypedPool
Il programma, dal nome fantasioso Test, accetta i seguenti parametri: numero di task (per ognuno dei due tipi), grana di campionamento dei dati da rilevare, numero di iterazioni, flag sul tipo di allocazione (NEW, POOL, BOTH) ed infine un flag sull'attivazione di un thread contatore.
La possibilità di azionare un thread counter che si limita ad incrementare una variabile e ad autosospendersi immediatamente con Thread.yield(), serve a dare un ulteriore vista neutra sulla ripartizione globale del carico del sistema.
Tutti i thread girano a priorità normale su un pentium 200 con WinNT Server, 64 Mb di ram e JDK 1.2 Beta2.
Vediamo i risultati:
| Numero di oggetti allocati | 10 thread utilizzanti new | 10 thread utilizzanti un TypedPool | 5 thread utilizzanti new e <br> 5 thread utilizzanti un TypedPool | | --- | --- | --- | --- | | 10 sec. X 10 iterazioni | [0] new: 12300 <br> [1] new: 24700 <br> [2] new: 37050 <br> [3] new: 49400 <br> [4] new: 61800 <br> [5] new: 74250 <br> [6] new: 86700 <br> [7] new: 99100 <br> [8] new: 111450 <br> [9] new: 123800 <br> Total: [123801] | [0] Pool: 21100 <br> [1] Pool: 42250 <br> [2] Pool: 58200 <br> [3] Pool: 74100 <br> [4] Pool: 90000 <br> [5] Pool: 105900 <br> [6] Pool: 121800 <br> [7] Pool: 137700 <br> [8] Pool: 153600 <br> [9] Pool: 169500 <br> Total: [169501] <br> Elements in the pool: 51 | [0] new: 6850 Pool: 6950 <br> [1] new: 13800 Pool: 13950 <br> [2] new: 20750 Pool: 20850 <br> [3] new: 27600 Pool: 27700 <br> [4] new: 34450 Pool: 34550 <br> [5] new: 41300 Pool: 41400 <br> [6] new: 48200 Pool: 48300 <br> [7] new: 55050 Pool: 55150 <br> [8] new: 61900 Pool: 62000 <br> [9] new: 68750 Pool: 68900 <br> New: [68801] - Pool 1: [68901] - Total: [137702] <br> Elements in the pool: 51 | | 10 sec. X 10 iterazioni <br> con counter | [0] new: 12450 <br> [1] new: 24600 <br> [2] new: 36850 <br> [3] new: 49100 <br> [4] new: 61400 <br> [5] new: 73700 <br> [6] new: 86000 <br> [7] new: 98300 <br> [8] new: 110600 <br> [9] new: 122900 <br> Counter=37497 <br> Total 0: [123001] | [0] Pool: 15899 <br> [1] Pool: 31499 <br> [2] Pool: 47149 <br> [3] Pool: 62749 <br> [4] Pool: 78349 <br> [5] Pool: 93949 <br> [6] Pool: 109549 <br> [7] Pool: 125149 <br> [8] Pool: 146649 <br> [9] Pool: 162249 <br> Counter=1763945 <br> Total 1: [165900] <br> Elements in the pool: 51 | [0] new: 6750 Pool: 6800 <br> [1] new: 13600 Pool: 13700 <br> [2] new: 20550 Pool: 20600 <br> [3] new: 27400 Pool: 27450 <br> [4] new: 34250 Pool: 34300 <br> [5] new: 41150 Pool: 41250 <br> [6] new: 48150 Pool: 48200 <br> [7] new: 55100 Pool: 55200 <br> [8] new: 62100 Pool: 62200 <br> [9] new: 69050 Pool: 69150 <br> Counter=1385 <br> New: [69251] - Pool: [69301] - Total [138552] <br> Elements in the pool: 51 |
Difficile trarre conclusioni certe, ma alcune tendenze sono interessanti:
- Il TypedPool, girando da solo, ha prestazioni superiori al new
- L'uso combinato dei due metodi non favorisce le prestazioni dei thread che utilizzano il TypedPool rispetto a quelli che usano new. Comunque le prestazioni totali del programma sono maggiori che usando il solo new.
- Guardando le esecuzioni con il thread contatore, si nota come i TypedPool, usati da soli, sembrano lasciare maggiori risorse al sistema.
In alcuni casi, piuttosto che soluzioni arzigogolate come un TypedPool, sono sufficienti modifiche di piccola entità per abbattere l'impatto del garbage collector sulle performance di un programma. Ad esempio ho visto migliorare in modo ravvisabile "ad occhio nudo" le prestazioni di un thread che creava continuamente oggetti serializzabili per spedirli ad un sistema remoto tramite un ObjectOutputStream. La modifica è consistita nell'allocare un unico oggetto di una classe (non serializzabile) contenente un metodo apposito per spedire i propri fields tramite un normale DataOutputStream e nel modificare il contenuto dei campi piuttosto che allocare continuamente nuove istanze della classe. Provare per credere...
Concludendo: se i nostri programmi hanno qualche vincolo di tempo, è preferibile non crogiolarsi nell'illusione della memoria autogestita dal sistema, cercando di ottimizzare gli oggetti consumabili in modo da minimizzare l'impegno del garbage collector.
Piergiuseppe Spinelli svolge attività di analista/programmatore dal 1980. Si è occupato di training e di sviluppo di sistemi, particolarmente nel campo della supervisione di processo. Ha lavorato per aziende dei gruppi Saint Gobaing, Angelini, Procter&Gamble, Alcatel/Telettra, SIV e per vari enti pubblici e privati. E contattabile all'indirizzo <spinellip@sgol.it> o al sito www.GeoCities.com/Eureka/Enterprises/9607.
