JavaScript con memoria statica con pool di oggetti

Colt McAnlis
Colt McAnlis

Introduzione

Ricevi un'email che ti informa che il tuo web-game/la tua web-app ha un cattivo rendimento dopo un determinato periodo di tempo, esamini il codice e non noti nulla di strano, finché non apri gli strumenti per le prestazioni della memoria di Chrome e vedi quanto segue:

Uno snapshot della sequenza temporale della memoria

Uno dei tuoi colleghi ride, perché si rende conto che hai un problema di prestazioni relativo alla memoria.

Nel grafico della memoria, questo schema a dente di sega racconta molto di un problema di prestazioni potenzialmente critico. Man mano che l'utilizzo della memoria aumenta, vedrai aumentare anche l'area del grafico nell'acquisizione della cronologia. Quando il grafico cala improvvisamente, si tratta di un'istanza in cui il Garbage Collector è stato eseguito e ha ripulito gli oggetti di memoria a cui si fa riferimento.

Che cosa significano i denti di sega

In un grafico come questo, puoi vedere che si verificano molti eventi di raccolta dei rifiuti, che possono essere dannosi per il rendimento delle tue app web. Questo articolo spiega come prendere il controllo dell'utilizzo della memoria, riducendo l'impatto sul rendimento.

Raccolta dei rifiuti e costi delle prestazioni

Il modello di memoria di JavaScript si basa su una tecnologia nota come garbage collector. In molti linguaggi, il programmatore è direttamente responsabile dell'allocazione e della liberazione della memoria dall'heap della memoria del sistema. Un sistema Garbage Collector, tuttavia, gestisce questa attività per conto del programmatore, il che significa che gli oggetti non vengono liberati direttamente dalla memoria quando il programmatore li dereferizza, ma in un secondo momento, quando l'euristica del GC decide che sarebbe utile farlo. Questo processo decisionale richiede che il GC esegua alcune analisi statistiche sugli oggetti attivi e inattivi, il che richiede un blocco di tempo per l'esecuzione.

La raccolta dei rifiuti è spesso rappresentata come l'opposto della gestione manuale della memoria, che richiede al programmatore di specificare quali oggetti deallocare e restituire al sistema di memoria

Il processo in cui un GC recupera la memoria non è gratuito, di solito riduce le prestazioni disponibili occupando un blocco di tempo per svolgere il proprio lavoro; inoltre, è il sistema stesso a decidere quando eseguirlo. Non hai alcun controllo su questa azione. È possibile che si verifichi un impulso GC in qualsiasi momento durante l'esecuzione del codice, che bloccherà l'esecuzione del codice fino al suo completamento. La durata di questo battito è generalmente sconosciuta; sarà necessario del tempo per l'esecuzione, a seconda di come il programma utilizza la memoria in qualsiasi momento.

Le applicazioni ad alte prestazioni si basano su limiti di prestazioni coerenti per garantire un'esperienza fluida per gli utenti. I sistemi di raccolta dei rifiuti possono cortocircuitare questo obiettivo, in quanto possono essere eseguiti in momenti e per durate casuali, limitando il tempo disponibile necessario all'applicazione per raggiungere i suoi obiettivi di prestazioni.

Riduci il turnover della memoria, riduci le imposte sulla raccolta dei rifiuti

Come indicato, un impulso GC si verifica quando un insieme di regole di euristica determina che esistono oggetti inattivi sufficienti per cui un impulso sarebbe utile. Di conseguenza, la chiave per ridurre il tempo impiegato dal Garbage Collector per la tua applicazione consiste nell'eliminare il maggior numero possibile di casi di creazione e rilascio eccessivi di oggetti. Questo processo di creazione/liberazione di oggetti viene spesso chiamato "turnover della memoria". Se riesci a ridurre il turnover della memoria durante il ciclo di vita dell'applicazione, riduci anche il tempo necessario per l'esecuzione del GC. Ciò significa che devi rimuovere/ridurre il numero di oggetti creati e distrutti, in pratica devi interrompere l'allocazione di memoria.

Questa procedura sposterà il grafico della memoria da:

Uno snapshot della sequenza temporale della memoria

a questo:

JavaScript in memoria statica

In questo modello, puoi vedere che il grafico non ha più un andamento a dente di sega, ma cresce molto all'inizio e poi aumenta lentamente nel tempo. Se riscontri problemi di prestazioni dovuti al ricambio della memoria, questo è il tipo di grafico che ti consigliamo di creare.

Passaggio a JavaScript con memoria statica

JavaScript con memoria statica è una tecnica che prevede la preallocazione, all'inizio dell'app, di tutta la memoria necessaria per tutta la sua durata e la gestione di questa memoria durante l'esecuzione quando gli oggetti non sono più necessari. Possiamo raggiungere questo obiettivo in pochi semplici passaggi:

  1. Strumenta l'applicazione per determinare il numero massimo di oggetti di memoria in tempo reale richiesti (per tipo) per una serie di scenari di utilizzo
  2. Reimplementa il codice per preallocare l'importo massimo e poi recuperalo/rilascialo manualmente anziché andare alla memoria principale.

In realtà, per raggiungere il punto 1 occorre fare un passo in più, quindi iniziamo da lì.

Pool di oggetti

In termini semplici, il pooling di oggetti è il processo di conservazione di un insieme di oggetti inutilizzati che condividono un tipo. Quando hai bisogno di un nuovo oggetto per il tuo codice, anziché allocarne uno nuovo dall'heap di memoria di sistema, puoi riciclare uno degli oggetti inutilizzati del pool. Una volta che il codice esterno ha terminato l'utilizzo dell'oggetto, anziché rilasciarlo nella memoria principale, viene restituito al pool. Poiché l'oggetto non viene mai dereferenziato (ovvero eliminato) dal codice, non verrà sottoposto a garbage collection. L'utilizzo dei pool di oggetti restituisce il controllo della memoria al programmatore, riducendo l'influenza del garbage collector sul rendimento.

Poiché un'applicazione gestisce un insieme eterogeneo di tipi di oggetti, per utilizzare in modo appropriato i pool di oggetti è necessario avere un pool per tipo con un tasso di abbandono elevato durante il runtime dell'applicazione.

var newEntity = gEntityObjectPool.allocate();
newEntity.pos = {x: 215, y: 88};

//..... do some stuff with the object that we need to do

gEntityObjectPool.free(newEntity); //free the object when we're done
newEntity = null; //free this object reference

Per la maggior parte delle applicazioni, alla fine raggiungerai un certo livello in termini di necessità di allocare nuovi oggetti. Dopo aver eseguito più volte l'applicazione, dovresti avere un'idea precisa di quale sia questo limite superiore e puoi preallocare questo numero di oggetti all'inizio dell'applicazione.

Preallocazione degli oggetti

L'implementazione del pooling di oggetti nel progetto ti fornirà un valore massimo teorico per il numero di oggetti richiesti durante il runtime dell'applicazione. Dopo aver sottoposto il tuo sito a vari scenari di test, puoi avere una buona idea dei tipi di requisiti di memoria necessari, catalogare questi dati da qualche parte e analizzarli per capire quali sono i limiti superiori dei requisiti di memoria per la tua applicazione.

Quindi, nella versione di spedizione dell'app, puoi impostare la fase di inizializzazione in modo da precompilare tutti i pool di oggetti su un importo specificato. In questo modo, l'inizializzazione di tutti gli oggetti verrà spostata all'inizio dell'app e verrà ridotta la quantità di allocazioni che si verificano dinamicamente durante l'esecuzione.

function init() {
  //preallocate all our pools. 
  //Note that we keep each pool homogeneous wrt object types
  gEntityObjectPool.preAllocate(256);
  gDomObjectPool.preAllocate(888);
}

L'importo scelto ha molto a che fare con il comportamento dell'applicazione; a volte il valore massimo teorico non è l'opzione migliore. Ad esempio, la scelta del valore massimo medio può ridurre l'impronta di memoria per gli utenti non esperti.

Lontano da una soluzione definitiva

Esiste un'intera classificazione di app in cui i modelli di crescita della memoria statica possono essere una soluzione efficace. Tuttavia, come sottolineato dal collega Renato Mangini, ci sono alcuni svantaggi.

Conclusione

Uno dei motivi per cui JavaScript è ideale per il web è che si tratta di un linguaggio veloce, divertente e facile da usare per iniziare. Ciò è dovuto principalmente alla bassa soglia di restrizioni della sintassi e alla gestione dei problemi di memoria per tuo conto. Puoi scrivere codice e lasciare che sia lui a occuparsi del lavoro sporco. Tuttavia, per le applicazioni web ad alte prestazioni, come i giochi HTML5, il GC può spesso ridurre la frequenza frame necessaria, riducendo l'esperienza per l'utente finale. Con un'attenta strumentazione e l'adozione di pool di oggetti, puoi ridurre questo carico sulla frequenza frame e recuperare il tempo per fare cose più interessanti.

Codice sorgente

Ci sono molte implementazioni di pool di oggetti che fluttuano sul web, quindi non vi annoierò ancora un'altra. Ti indirizzerò invece a queste, ognuna delle quali presenta sfumature di implementazione specifiche, il che è importante, considerando che ogni utilizzo dell'applicazione potrebbe avere esigenze di implementazione specifiche.

Riferimenti