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 il rendimento della memoria di Chrome e vedi quanto segue:

Uno snapshot della sequenza temporale dei ricordi

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

Nella visualizzazione del grafico della memoria, questo andamento a forma di sega è molto indicativo 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 è senza costi, 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. Un impulso GC può verificarsi in qualsiasi momento durante l'esecuzione del codice, bloccando l'esecuzione fino al completamento. La durata di questo impulso è generalmente sconosciuta; l'esecuzione richiederà un po' di tempo, a seconda di come il programma utilizza la memoria in un determinato momento.

Le applicazioni ad alte prestazioni si basano su limiti di prestazioni coerenti per garantire un'esperienza fluida agli 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 rendimento.

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 smettere di allocare memoria.

Questa procedura sposterà il grafico della memoria da :

Uno snapshot della sequenza temporale dei ricordi

a questo:

JavaScript per la 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 primo obiettivo dobbiamo fare un po' di lavoro per il secondo, 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é esiste un insieme eterogeneo di tipi di oggetti gestiti da un'applicazione, l'utilizzo corretto dei pool di oggetti richiede un pool per tipo con un tasso di rotazione 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'avvio 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.

Poi, nella versione di produzione dell'app, puoi impostare la fase di inizializzazione in modo da precompilare tutti i pool di oggetti con 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 vantaggiosi. Tuttavia, come sottolineato dal collega Renato Mangini, ci sono alcuni svantaggi.

Conclusione

Uno dei motivi per cui JavaScript è ideale per il web è che è un linguaggio veloce, divertente e facile da imparare. 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 dell'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

Sul web sono disponibili molte implementazioni di pool di oggetti, quindi non ti annoierò con 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