E’ possibile che un tablet possa sostituire efficacemente un PC?
Qualche tempo fa mi sono imbattuto in questo post che contiene un sacco di informazioni interessanti sui linguaggi di programmazione dinamici e sul perché non siano adatti alla programmazione di applicazioni per dispositivi mobili. Ne consiglio a tutti la lettura. Ma dato che il post è lungo, ne farò qui un sommario aggiungendo i miei commenti.
Il garbage collector
I linguaggi di programmazione possono essere categorizzati in molti modi. Uno dei modi è quello di distinguere i linguaggi managed da quelli unmanaged. I primi utilizzano, tra le altre cose, il meccanismo di garbage collection (o GC) per gestire automaticamente la memoria dinamica utilizzata dai nostri programmi, mentre i secondi lasciano il compito di gestire la memoria essenzialmente al programmatore. Tra i linguaggi managed ci sono per lo più linguaggi dinamici come Python, Ruby, JavaScript, ma anche linguaggi compilati come Java e Haskell. Il tipico linguaggio unmanaged è il C/C++. Oggi, moltissimi linguaggi sono managed, e la ragione sarà più evidente nel proseguimento del post.
La GC è stata inventata da una vecchia conoscenza di questo blog: da John McCarthy, si proprio lui, l’inventore del linguaggio LISP. L’idea è quella di liberare il programmatore dal difficile compito di dover gestire personalmente la memoria dinamica. In un linguaggio non managed, come il C ad esempio, ogni volta che faccio una p = malloc(nbytes) ottengo un puntatore a una nuova zona di memoria della dimensione richiesta che posso usare per memorizzare i miei dati; corrispondentemente, quando quella zona di memoria non mi serve più dovrò fare una free(p) per liberare la memoria e renderla di nuovo disponibile. Se mi scordo di fare la free(), e magari cancello il puntatore, la memoria resterà occupata, almeno fino a quando il programma resterà in esecuzione. Se il programma rimane in esecuzione per lungo tempo, è bene assicurarsi che ad ogni malloc() corrisponda sempre una free(), altrimenti la memoria occupata dal programma potrebbe crescere in maniera incontrollata.
Pensate ad esempio ad un server che accetta richieste di connessione; questo programma eseguirà per un tempo indeterminato, giorni, mesi, anni. Supponiamo che ad ogni richiesta venga allocata della memoria, ma al termine del completamente del servizio questa memoria non viene deallocata. Ad ogni richiesta, le dimensioni della memoria del programma continueranno a crescere mandando prima o poi in crash il sistema. Inoltre, la memoria si riempie di spazzatura, ovvero dati ormai inutili. Un tale tipo di errore viene chiamato memory leak, perché sembra che la memoria “goccioli via” come da un secchio bucato. Nel passato, il numero di errori che avevano a che fare con una cattiva gestione della memoria da parte del programma era piuttosto elevato, per cui si pensò di porre rimedio sottraendo il compito di gestire la memoria al programmatore.
I linguaggi managed permettono al programmatore di allocare memoria per i propri dati; evitano però che il programmatore debba liberare la memoria quando non è più necessaria. In pratica, permettono la malloc(), ma evitano di farci fare la free(). Un algoritmo, chiamato garbage collector periodicamente analizza la memoria del nostro programma per vedere se qualche zona di memoria precedentemente allocata può essere liberata. In pratica, raccoglie la spazzatura per noi. Per far questo, ad ogni zona di memoria viene associato un contatore dei riferimenti (reference counter) che conta quanti puntatori attivi riferiscono quella particolare zona di memoria. Ogni volta che la zona viene riferita da una nuova variabile puntatore, il contatore viene incrementato; ogni volta che un puntatore che riferisce quella zona viene cancellato, il contatore viene decrementato. Quando il contatore raggiunge il valore zero, vuol dire che nessun’altra parte del programma riferisce quella zona, che quindi può essere liberata. Non viene però liberata immediatamente (a causa di una serie di casi particolari, non è sempre così facile accorgersi del fatto che una zona di memoria può essere liberata!). In particolare, il GC esegue periodicamente due fasi: nella prima, chiamata mark, si marcano le zone da liberare; nella seconda, chiamata sweep, le zone marcate vengono effettivamente liberate.
La GC è stata sempre oggetto del contendere tra due diversi modi di vedere la programmazione: da una parte quelli che dicono che la GC è la panacea di tutti i mali; dall’altra quelli che dicono che la GC è inutilmente pesante e sostanzialmente inutile. Chi ha ragione? Il post che ho citato all’inizio ci da un punto di vista secondo me molto chiaro e rilevante.
VM
Per implementare la GC ci vuole una Virtual Machine. In pratica, bisogna essere in grado di tradurre un assegnamento tra puntatori in una operazione più complicata che coinvolge i reference counters. Inoltre, le variabili puntatori assumono uno status tutto particolare e differente dalle variabili int o float, perché il GC deve essere in grado di riconoscerle e seguire la “pista” alla ricerca di zone di memoria da liberare. Naturalmente, una VM significa overhead in più di traduzione. E’ vero che ci sono i Just in Time Compilers, che generano codice macchina a partire dal byte code; è anche vero che la VM si può evitare del tutto, come ad esempio fa Haskell con un run-time corposo. Quello che è importate sottolineare però è che per quanto si possa ottimizzare lo strato di VM o di run-time, resta il fatto che l’algoritmo di GC va implementato, e per quanto si possa fare in maniera efficiente, resta sempre un algoritmo piuttosto pesante.
Problemi con il GC
Io vedo due ordini di problemi con il GC. Il primo è strettamente tecnico: il GC ha bisogno di analizzare la memoria del nostro programma alla ricerca di zone di memoria da poter liberare. Mentre fa questo lavoro, la VM quasi sempre di blocca in attesa che il GC abbia finito. Questo perché il GC “legge” e “modifica” le strutture dati del gestore di memoria, e queste strutture dati sono costantemente utilizzate dalla VM sia per allocare nuova memoria, sia per aggiornare i reference counters. Non è sempre possibile permettere che il GC vada in parallelo con il resto del programma, per cui potrebbe accadere che la vostra applicazione abbia un piccolo “freeze” (cioè si blocchi completamente) per un po’ di tempo a causa del GC.
Quanto dura questo “freeze” dipende da un sacco di fattori, il più importante dei quali è l’uso che il programma fa della memoria. Se il programma alloca e libera continuamente tanti oggetti di piccola dimensione, il GC potrebbe prendere tantissimo tempo per eseguire, e il freze potrebbe durare anche qualche secondo. Un altro problema tecnico è capire quando il GC viene eseguito per liberare la memoria. All’inizio del post ho scritto che il GC viene eseguito periodicamente, ma con quale periodo? beh, dipende anche dall’utilizzo della memoria: se c’è poca memoria libera nel sistema, il GC verrà eseguito più frequentemente, nel tentativo di liberare memoria, e questo incrementerà l’overhead, cioè il numero e la durata dei “freeze”.
In ogni caso, quel che è peggio è che quasi nessun linguaggio managed permette al programmatore di controllare quando e quanto spesso il GC va in esecuzione. Quindi il programmatore ha poco controllo.
E veniamo con questo al secondo ordine di problemi del GC. Dato che la gestione della memoria è completamente demandata al sistema e nascosta al programmatore, il programmatore poco esperto tende a usare tanta memoria, creando e distruggendo oggetti inutilmente. Per esempio, in programmi Java scritti da programmatori poco esperti è comune leggere codice come il seguente:
String s = new String("Questa è una stringa");
String c = s.toUpperCase();
questa linea di codice crea un oggetto di tipo stringa per copia da una costante di tipo stringa. Quindi trasforma tutti i caratteri in maiuscolo.
Quello che il programmatore non sa, o dimentica spesso, è che la classe stringa è immutabile, ovvero non si può modificare un oggetto di classe stringa. Ad esempio, ogni volta che volete rimuovere un carattere da una String Java, viene creata un nuovo oggetto di tipo stringa che contiene tutti i caratteri di quello originale meno quello da rimuovere. Per cui, nella riga sopra ci sono 3 oggetti: la stringa originale, quella puntata da s che non può essere modificata, e quella puntata da c. Quella puntata da s è quindi un inutile duplicato, anzi più che inutile, dannoso, perché prima o poi il GC dovrà occuparsi anche di lui! Un modo efficiente di scrivere sarebbe semplicemente:
String s = "Questa è una stringa";
String c = s.toUpperCase();
In questo caso ci sono solo due oggetti, l’originale e quello puntato da c. Di esempi così se ne potrebbero fare molti. E’ così facile creare nuovi oggetti che il programmatore inesperto lo fa senza quasi accorgersene, incrementando notevolmente il lavoro del GC e quindi la lentezza del programma.
Quello che io considero un problema, però, è anche la forza dei linguaggi managed. Poiché non c’è da occuparsi della gestione della memoria, scrivere programmi funzionanti (anche se non molto efficienti) è più semplice, quindi aumenta la produttività del programmatore medio, si riduce il time-to-market, il costo di sviluppo, ecc. E finché la potenza e la memoria disponibile aumentavano, non c’erano problemi: più memoria avete sul vostro PC, meglio funzioneranno programmi scritti con linguaggi managed.
Come detto efficacemente da Miguel de Icaza,
This is a pretty accurate statement on the difference of the mainstream VMs for managed languages (.NET, Java and Javascript). Designers of managed languages have chosen the path of safety over performance for their designs.
Alcuni numeri
Nel post il buon Drew Crawford riporta alcune statistiche che non conoscevo, prese da questo paper. Riporto qui le frasi più importanti, come fa Drew:
In particular, when garbage collection has five times as much memory as required, its runtime performance matches or slightly exceeds that of explicit memory management. However, garbage collection’s performance degrades substantially when it must use smaller heaps. With three times as much memory, it runs 17% slower on average, and with twice as much memory, it runs 70% slower. Garbage collection also is more susceptible to paging when physical memory is scarce. In such conditions, all of the garbage collectors we examine here suffer order-of-magnitude performance penalties relative to explicit memory management. […]
These graphs show that, for reasonable ranges of available memory (but not enough to hold the entire application), both explicit memory managers substantially outperform all of the garbage collectors. For instance, pseudoJBB running with 63MB of available memory and the Lea allocator completes in 25 seconds. With the same amount of available memory and using GenMS, it takes more than ten times longer to complete (255 seconds). We see similar trends across the benchmark suite. The most pronounced case is 213 javac: at 36MB with the Lea allocator, total execution time is 14 seconds, while with GenMS, total execution time is 211 seconds, over a 15-fold increase.
La conclusione quindi è che per stare sicuri di avere performance decenti con il GC, bisogna avere almeno 3-4 volte la memoria minima strettamente necessaria (cioè quella richiesta da un programma che usa allocazione esplicita della memoria). Su un PC prendetevi almeno 4Gb di RAM (meglio 8Gb) e andate tranquilli. Su un dispositivo mobile però non è così semplice.
Il problema delle webapp
Una webapp è una applicazione web, che di solito usa il cosidetto AJAX, ovvero una combinazione di programmi client e server, per fornire funzionalità dinamiche complesse. Per quanto riguarda la parte client, si usa JavaScript (o più propriamente ECMAScript), un linguaggio ormai popolarissimo proprio in ambito web. Si tratta di un linguaggio dinamico, managed, che usa il browser come VM. E naturalmente, usa un GC per gestire la memoria. Essendo un linguaggio vero e proprio, è possibile costruire applicazioni anche molto complesse, che però eseguiranno nel vostro browser.
Una web app particolarmente complicata, ad esempio, è Google Docs, che cerca di replicare un subset delle funzionalità di una suite office sul browser. Qualche anno fa, quando Google Docs fu proposto, ci fu una certa euforia fra i primi utilizzatori e fra gli stessi sviluppatori: era convinzione diffusa che si potesse interamente sostituire una suite come Microsoft Office con qualcosa come Google Docs basata interamente sul web. Se usate Google Docs sul vostro PC, e se questo è abbastanza carrozzato, non ci sono grandi problemi: non è velocissimo, ma è ancora usabile. Se invece qualcuno tentasse di usare Google Docs sul suo Tablet o sul cellulare, beh, i tempi di risposta andrebbero a farsi benedire. Ecco cosa dice personale di Google a proposito:
Complex web apps–the kind that Google specializes in–are struggling against the platform and working with a language that cannot be tooled and has inherent performance problems.
A dire la verità, anche web app molto più semplici soffrono notevolmente: per esempio provate ad accedere alla pagina di Dropbox tramite browser su tablet: dovrete attendere diversi secondi dopo ogni click. In effetti, molto meglio scaricarsi l’app di Dropbox e tentare di fare le proprie operazioni da lì (ma anche quella non è un fulmine).
Il problema è duplice. Il primo problema è di lentezza del processore. Una cosa è avere un bell’Intel i7 sul proprio desktop o portatile, una cosa completamente diversa è di avere un ARM progettato e realizzato con il risparmio energetico in mente. Inoltre, JavaScript è interpretato (o JIT-compilato), e le performance sono nettamente inferiori per questo motivo; Drew calcola che un programma JavaScript è da 5 a 10 volte più lento su un dispositivo mobile rispetto allo stesso programma su PC. Quello che richiede un secondo sul PC può richiedere 10 secondi sul mobile. Leggetevi attentamente la prima parte del post per una lunga analisi delle differenze di velocità.
A questo aggiungete il fatto che JavaScript usa il GC, e che la memoria di un dispositivo mobile è severamente limitata e avete il quadro completo della situazione.
Cosa succederà ora?
Eh, ad avere la palla di cristallo.
Ovviamente, credo non sia possibile continuare sulla strada di sviluppare Webapp sempre più complesse con l’idea che possano completamente sostituire programmi sviluppati nativimente per la piattaforma hardware. I programmi .exe, insomma, non spariranno tanto presto, anzi.
Per quanto riguarda Android, la strada scelta da Google di basarsi su Java è sembrata essere vincente all’inizio, perché ha permesso di sviluppare una grande quantità di app in breve tempo grazie alla maggiore produttività e sicurezza di un linguaggio managed come Java. Sul lungo termine però, il GC sta ponendo più problemi che soluzioni perché, come descrive Drew, i programmatori si vedono costretti ad aggirare il GC con trucchi e trucchetti per evitare la degradazione delle performance.
Si tornerà ad utilizzare il C++ anche sui dispositivi mobili? Credo piuttosto che l’hardware arriverà in soccorso del software ancora una volta… ma chi può saperlo?