Gestione efficace della memoria su scala Gmail

John McCutchan
John McCutchan
Loreena Lee
Loreena Lee

Introduzione

Sebbene JavaScript utilizzi la raccolta dei rifiuti per la gestione automatica della memoria, non sostituisce una gestione efficace della memoria nelle applicazioni. Le applicazioni JavaScript soffrono degli stessi problemi relativi alla memoria delle applicazioni native, come perdite di memoria e bloat, ma devono anche gestire le interruzioni della raccolta dei rifiuti. Le applicazioni su larga scala come Gmail riscontrano gli stessi problemi delle applicazioni più piccole. Continua a leggere per scoprire in che modo il team di Gmail ha utilizzato Chrome DevTools per identificare, isolare e risolvere i problemi di memoria.

Sessione Google I/O 2013

Abbiamo presentato questo materiale alla conferenza Google I/O 2013. Guarda il video di seguito:

Gmail, abbiamo un problema…

Il team di Gmail si trovava ad affrontare un grave problema. Si sentivano sempre più spesso storie di schede di Gmail che consumavano più gigabyte di memoria su laptop e computer con risorse limitate, spesso con il risultato di arrestare l'intero browser. Storie di CPU bloccate al 100%, app non rispondenti e schede di Chrome tristi ("È morto, Jim"). Il team non sapeva nemmeno come iniziare a diagnosticare il problema, per non parlare di risolverlo. Non avevano idea di quanto fosse diffuso il problema e gli strumenti disponibili non erano scalabili per applicazioni di grandi dimensioni. Il team si è unito ai team di Chrome e insieme hanno sviluppato nuove tecniche per la gestione dei problemi di memoria, migliorato gli strumenti esistenti e abilitato la raccolta dei dati sulla memoria sul campo. Prima di passare agli strumenti, però, vediamo le nozioni di base sulla gestione della memoria di JavaScript.

Nozioni di base sulla gestione della memoria

Prima di poter gestire efficacemente la memoria in JavaScript, devi comprendere le nozioni di base. Questa sezione illustra i tipi primitivi, il grafo degli oggetti e fornisce definizioni per l'aumento inutilmente elevato della memoria in generale e per una perdita di memoria in JavaScript. La memoria in JavaScript può essere concettualizzata come un grafo e per questo motivo la teoria dei grafici svolge un ruolo nella gestione della memoria di JavaScript e nell'Heap Profiler.

Tipi primitivi

JavaScript ha tre tipi primitivi:

  1. Numero (ad es. 4, 3,14159)
  2. Booleano (true o false)
  3. Stringa ("Hello World")

Questi tipi primitivi non possono fare riferimento ad altri valori. Nel grafo degli oggetti, questi valori sono sempre nodi foglia o terminali, il che significa che non hanno mai un bordo in uscita.

Esiste un solo tipo di contenitore: l'oggetto. In JavaScript, l'oggetto è un array associativo. Un oggetto non vuoto è un nodo interno con archi in uscita verso altri valori (nodi).

Che dire degli array?

Un array in JavaScript è in realtà un oggetto con chiavi numeriche. Si tratta di una semplificazione, perché i runtime JavaScript ottimizzeranno gli oggetti simili ad array e li rappresenteranno come array sotto il cofano.

Terminologia

  1. Valore: un'istanza di un tipo primitivo, Object, Array e così via.
  2. Variabile: un nome che fa riferimento a un valore.
  3. Proprietà: un nome in un oggetto che fa riferimento a un valore.

Grafico di oggetti

Tutti i valori in JavaScript fanno parte del grafo di oggetti. Il grafico inizia con le radici, ad esempio l'oggetto window. La gestione del ciclo di vita delle radici GC non è sotto il tuo controllo, in quanto vengono create dal browser e distrutte quando la pagina viene scaricata. Le variabili globali sono in realtà proprietà della finestra.

Grafo di oggetti

Quando un valore diventa spazzatura?

Un valore diventa spazzatura quando non esiste un percorso da una radice al valore. In altre parole, partendo dalle radici e cercando in modo esaustivo tutte le proprietà e le variabili dell'oggetto attive nell'frame dello stack, non è possibile raggiungere un valore, che diventa spazzatura.

Grafico spazzatura

Che cos'è una perdita di memoria in JavaScript?

Una perdita di memoria in JavaScript si verifica più comunemente quando sono presenti nodi DOM non raggiungibili dall'albero DOM della pagina, ma a cui viene fatto riferimento da un oggetto JavaScript. Sebbene i browser moderni stiano rendendo sempre più difficile creare inavvertitamente fughe di dati, è comunque più facile di quanto si pensi. Supponiamo di aggiungere un elemento all'albero DOM come segue:

email.message = document.createElement("div");
displayList.appendChild(email.message);

In un secondo momento, rimuovi l'elemento dall'elenco di visualizzazione:

displayList.removeAllChildren();

Finché email esiste, l'elemento DOM a cui fa riferimento il messaggio non verrà rimosso, anche se ora è scollegato dall'albero DOM della pagina.

Che cos'è il bloat?

La pagina è gonfia quando utilizzi più memoria del necessario per una velocità ottimale della pagina. In modo indiretto, anche le perdite di memoria causano un aumento delle dimensioni, ma non è intenzionale. Una cache dell'applicazione che non ha limiti di dimensioni è una fonte comune di spreco di memoria. Inoltre, la pagina può essere gonfiata dai dati dell'host, ad esempio i dati dei pixel caricati dalle immagini.

Che cos'è la garbage collection?

La garbage collection è il modo in cui viene recuperata la memoria in JavaScript. È il browser a decidere quando ciò accade. Durante una raccolta, l'esecuzione di tutti gli script nella pagina viene sospesa mentre i valori in tempo reale vengono rilevati da un attraversamento del grafo degli oggetti a partire dalle radici del GC. Tutti i valori che non sono raggiungibili sono classificati come spazzatura. La memoria per i valori spazzatura viene recuperata dal gestore della memoria.

Garbage collector V8 in dettaglio

Per comprendere meglio come avviene la raccolta dei rifiuti, diamo un'occhiata dettagliata al garbage collector V8. V8 utilizza un raccoglitore generazionale. La memoria è divisa in due generazioni: i giovani e gli anziani. L'allocazione e la raccolta all'interno della generazione giovane sono rapide e frequenti. L'allocazione e la raccolta all'interno della vecchia generazione sono più lente e meno frequenti.

Collezionista generazionale

V8 utilizza un raccoglitore di due generazioni. L'età di un valore è definita come il numero di byte allocati da quando è stato allocato. In pratica, l'età di un valore viene spesso approssimata dal numero di raccolte di nuova generazione a cui è sopravvissuto. Una volta che un valore è sufficientemente vecchio, viene inserito nella vecchia generazione.

In pratica, i valori appena allocati non durano a lungo. Uno studio sui programmi Smalltalk ha dimostrato che solo il 7% dei valori sopravvive dopo una raccolta di nuova generazione. Studi simili su runtime diversi hanno rilevato che, in media, tra il 90% e il 70% dei valori appena allocati non viene mai inserito nella vecchia generazione.

Young Generation

L'heap della generazione giovane in V8 è suddiviso in due spazi, denominati da e a. La memoria viene allocata dallo spazio a. L'allocazione è molto rapida, fino a quando lo spazio to non è pieno, a quel punto viene attivata una raccolta di nuova generazione. La raccolta di nuova generazione scambia prima lo spazio da e lo spazio a, lo spazio a precedente (ora spazio da) viene sottoposto a scansione e tutti i valori attivi vengono copiati nello spazio a o nel livello di permanenza nella vecchia generazione. Una raccolta di nuova generazione tipica richiederà circa 10 millisecondi (ms).

Intuitivamente, devi capire che ogni allocazione effettuata dalla tua applicazione ti avvicina all'esaurimento dello spazio e all'interruzione della GC. Sviluppatori di giochi, tenete presente che per garantire un tempo di frame di 16 ms (richiesto per raggiungere 60 frame al secondo), la vostra applicazione non deve effettuare allocazioni, perché una singola raccolta di nuova generazione occuperà la maggior parte del tempo di frame.

Heap di nuova generazione

Generazione precedente

L'heap di vecchia generazione in V8 utilizza un algoritmo mark-compact per la raccolta. Le allocazioni della vecchia generazione si verificano ogni volta che un valore viene assegnato dalla generazione giovane alla generazione vecchia. Ogni volta che viene eseguita una raccolta di vecchia generazione, viene eseguita anche una raccolta di nuova generazione. L'applicazione verrà messa in pausa per alcuni secondi. In pratica, questo è accettabile perché le collezioni di vecchia generazione sono rare.

Riepilogo del GC V8

La gestione automatica della memoria con la raccolta dei rifiuti è ottima per la produttività degli sviluppatori, ma ogni volta che assegni un valore, ti avvicini sempre di più a una pausa della raccolta dei rifiuti. Le interruzioni della raccolta dei rifiuti possono rovinare l'esperienza dell'utente introducendo il jitter. Ora che sai come JavaScript gestisce la memoria, puoi fare le scelte giuste per la tua applicazione.

Correggere Gmail

Nell'ultimo anno, Chrome DevTools ha visto l'introduzione di numerose funzionalità e correzioni di bug, che lo hanno reso più potente che mai. Inoltre, il browser stesso ha apportato una modifica fondamentale all'API performance.memory che consente a Gmail e a qualsiasi altra applicazione di raccogliere statistiche sulla memoria dal campo. Grazie a questi fantastici strumenti, ciò che una volta sembrava un'impresa impossibile è presto diventato un'emozionante caccia ai colpevoli.

Strumenti e tecniche

Dati sul campo e API performance.memory

A partire da Chrome 22, l'API performance.memory è abilitata per impostazione predefinita. Per applicazioni di lunga durata come Gmail, i dati di utenti reali sono inestimabili. Queste informazioni ci consentono di distinguere gli utenti esperti, che trascorrono 8-16 ore al giorno su Gmail e ricevono centinaia di messaggi al giorno, dagli utenti più comuni che trascorrono pochi minuti al giorno su Gmail e ricevono una dozzina di messaggi a settimana.

Questa API restituisce tre dati:

  1. jsHeapSizeLimit: la quantità di memoria (in byte) a cui è limitata l'heap di JavaScript.
  2. totalJSHeapSize: la quantità di memoria (in byte) allocata dall'heap di JavaScript, incluso lo spazio libero.
  3. usedJSHeapSize: la quantità di memoria (in byte) attualmente in uso.

Una cosa da tenere presente è che l'API restituisce i valori di memoria per l'intero processo di Chrome. Anche se non è la modalità predefinita, in determinate circostanze Chrome potrebbe aprire più schede nello stesso processo di rendering. Ciò significa che i valori restituiti da performance.memory potrebbero contenere l'impronta di memoria di altre schede del browser oltre a quella contenente la tua app.

Misurare la memoria su larga scala

Gmail ha strumentato il proprio codice JavaScript per utilizzare l'API performance.memory al fine di raccogliere informazioni sulla memoria circa ogni 30 minuti. Poiché molti utenti di Gmail lasciano l'app attiva per giorni, il team è stato in grado di monitorare la crescita della memoria nel tempo, nonché le statistiche complessive sull'impronta di memoria. Pochi giorni dopo aver strumentato Gmail per raccogliere informazioni sulla memoria da un campione casuale di utenti, il team disponeva di dati sufficienti per capire quanto fossero diffusi i problemi di memoria tra gli utenti medi. Hanno impostato un riferimento e utilizzato lo stream di dati in entrata per monitorare l'avanzamento verso l'obiettivo di ridurre il consumo di memoria. Alla fine, questi dati verranno utilizzati anche per rilevare eventuali regressioni della memoria.

Oltre che per scopi di monitoraggio, le misurazioni sul campo forniscono anche un'approfondita conoscenza della correlazione tra impronta di memoria e prestazioni dell'applicazione. Contrariamente alla credenza popolare che "più memoria comporta prestazioni migliori", il team di Gmail ha scoperto che maggiore è l'impronta di memoria, più lunghe sono le latenze per le azioni comuni di Gmail. Grazie a questa rivelazione, il team era più motivato che mai a ridurre il consumo di memoria.

Misurare la memoria su larga scala

Identificare un problema di memoria con la cronologia di DevTools

Il primo passaggio per risolvere qualsiasi problema di prestazioni è dimostrare che il problema esiste, creare un test riproducibile e misurare il problema come base di riferimento. Senza un programma riproducibile, non puoi misurare il problema in modo affidabile. Senza una misurazione di riferimento, non sai quanto hai migliorato il rendimento.

Il riquadro Spostamenti di DevTools è ideale per dimostrare che il problema esiste. Fornisce una panoramica completa del tempo trascorso durante il caricamento e l'interazione con la tua pagina o app web. Tutti gli eventi, dal caricamento delle risorse all'analisi del codice JavaScript, al calcolo degli stili, alle interruzioni della raccolta dei rifiuti e alla nuova colorazione, vengono tracciati su una sequenza temporale. Ai fini dell'analisi dei problemi di memoria, il riquadro Spostamenti dispone anche di una modalità Memoria che monitora la memoria totale allocata, il numero di nodi DOM, il numero di oggetti finestra e il numero di ascoltatori di eventi allocati.

Dimostrare che esiste un problema

Inizia identificando una sequenza di azioni che sospetti stiano causando una perdita di memoria. Avvia la registrazione della sequenza temporale ed esegui la sequenza di azioni. Utilizza il pulsante del cestino in basso per forzare una raccolta completa dei rifiuti. Se, dopo alcune iterazioni, visualizzi un grafico a forma di dente di sega, significa che stai allocando molti oggetti di breve durata. Tuttavia, se la sequenza di azioni non dovrebbe comportare la memorizzazione di memoria e il conteggio dei nodi DOM non torna al valore di riferimento iniziale, hai buoni motivi per sospettare che si tratti di una perdita.

Grafico a forma di dente di sega

Una volta confermato che il problema esiste, puoi ricevere assistenza per identificarne l'origine dallo strumento Heap Profiler di DevTools.

Trovare perdite di memoria con lo strumento Profiler heap di DevTools

Il riquadro Profiler fornisce sia un profiler della CPU sia un profiler dell'heap. Il profiling dell'heap funziona acquisendo uno snapshot del grafico degli oggetti. Prima di acquisire uno snapshot, le generazioni giovani e vecchie vengono raccolte. In altre parole, vedrai solo i valori attivi al momento dello scatto dell'istantanea.

Il profilo dell'heap contiene troppe funzionalità per essere coperto in modo sufficiente in questo articolo, ma puoi trovare una documentazione dettagliata sul sito per sviluppatori di Chrome. Qui ci concentreremo sul profiler di allocazione heap.

Utilizzo del profiler di allocazione heap

Il profiler di allocazione dello heap combina le informazioni dettagliate degli snapshot del profiler dello heap con l'aggiornamento e il monitoraggio incrementali del riquadro Spostamenti. Apri il riquadro Profili, avvia un profilo Registra allocazioni heap, esegui una sequenza di azioni e poi interrompi la registrazione per l'analisi. Il profiler dell'allocazione acquisisce istantanee dell'heap periodicamente durante la registrazione (fino a ogni 50 ms) e un'istantanea finale alla fine della registrazione.

Profiler dell'allocazione heap

Le barre in alto indicano quando vengono trovati nuovi oggetti nell'heap. L'altezza di ogni barra corrisponde alle dimensioni degli oggetti allocati di recente e il colore delle barre indica se questi oggetti sono ancora attivi nello snapshot dell'heap finale: le barre blu indicano gli oggetti che sono ancora attivi alla fine della sequenza temporale, le barre grigie indicano gli oggetti che sono stati allocati durante la sequenza temporale, ma che sono stati successivamente sottoposti a garbage collection.

Nell'esempio precedente, un'azione è stata eseguita 10 volte. Il programma di esempio memorizza nella cache cinque oggetti, quindi le ultime cinque barre blu sono previste. Tuttavia, la barra blu più a sinistra indica un potenziale problema. Puoi quindi utilizzare i cursori nella sequenza temporale sopra per aumentare lo zoom su quel determinato istantanea e vedere gli oggetti allocati di recente in quel punto. Se fai clic su un oggetto specifico nell'heap, nella parte inferiore dello snapshot dell'heap viene visualizzato il relativo albero di conservazione. L'esame del percorso di conservazione dell'oggetto dovrebbe fornirti informazioni sufficienti per capire perché l'oggetto non è stato raccolto e puoi apportare le modifiche necessarie al codice per rimuovere il riferimento non necessario.

Risolvere la crisi di memoria di Gmail

Utilizzando gli strumenti e le tecniche discussi sopra, il team di Gmail è stato in grado di identificare alcune categorie di bug: cache illimitate, array di callback in crescita infinita in attesa di un evento che non si verifica mai e ascoltatori di eventi che mantengono involontariamente i propri target. Grazie alla correzione di questi problemi, l'utilizzo complessivo della memoria di Gmail è stato notevolmente ridotto. Gli utenti del 99% hanno utilizzato l'80% di memoria in meno rispetto a prima e il consumo di memoria degli utenti medi è diminuito di quasi il 50%.

Utilizzo della memoria di Gmail

Poiché Gmail utilizzava meno memoria, la latenza della pausa GC è stata ridotta, migliorando l'esperienza utente complessiva.

Inoltre, grazie alla raccolta di statistiche sull'utilizzo della memoria da parte del team di Gmail, è stato possibile scoprire regressioni della raccolta dei rifiuti in Chrome. Nello specifico, sono stati scoperti due bug di frammentazione quando i dati sulla memoria di Gmail hanno iniziato a mostrare un aumento significativo del divario tra la memoria totale allocata e la memoria in uso.

Invito all'azione

Poniti queste domande:

  1. Quanta memoria utilizza la mia app? È possibile che tu stia utilizzando troppa memoria, il che, contrariamente alla credenza popolare, ha un impatto negativo netto sul rendimento complessivo dell'applicazione. È difficile sapere esattamente qual è il numero giusto, ma assicurati di verificare che la memorizzazione nella cache aggiuntiva utilizzata dalla tua pagina abbia un impatto misurabile sul rendimento.
  2. La mia pagina è priva di fughe di dati? Se la tua pagina presenta perdite di memoria, ciò può influire non solo sul rendimento della pagina, ma anche su altre schede. Utilizza il tracker degli oggetti per restringere la ricerca di eventuali perdite.
  3. Con quale frequenza viene eseguita la raccolta dei rifiuti della mia pagina? Puoi vedere qualsiasi interruzione del GC utilizzando il riquadro Spostamenti in Strumenti per sviluppatori di Chrome. Se la tua pagina esegue spesso il GC, è probabile che tu stia eseguendo allocazioni troppo di frequente, consumando la memoria della generazione giovane.

Conclusione

Abbiamo iniziato in un periodo di crisi. Sono state trattate le nozioni di base della gestione della memoria in JavaScript e in particolare in V8. Hai imparato a utilizzare gli strumenti, inclusa la nuova funzionalità di monitoraggio degli oggetti disponibile nelle ultime build di Chrome. Grazie a queste informazioni, il team di Gmail ha risolto il problema di utilizzo della memoria e ha registrato un miglioramento delle prestazioni. Puoi fare lo stesso con le tue app web.