JavaScript con memoria statica e pool di oggetti

Colt McAnlis
Colt McAnlis

Introduzione

Ricevi un'email che ti informa che il tuo browser game o la tua app web non funziona bene dopo un certo periodo di tempo, esamini il codice, non vedi nulla di strano, finché non apri gli strumenti di Chrome per le prestazioni della memoria e vedi questo:

Un'istantanea dalla tua cronologia dei ricordi

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

Nella visualizzazione del grafico della memoria, questo pattern a dente di sega è molto indicativo di un problema di prestazioni potenzialmente critico. Man mano che la memoria utilizzata aumenta, vedrai crescere anche l'area del grafico nell'acquisizione della cronologia. Quando il grafico scende improvvisamente, significa che il Garbage Collector è stato eseguito e ha ripulito gli oggetti di memoria a cui fai riferimento.

Significato dei denti di sega

In un grafico come questo, puoi vedere che si verificano molti eventi di garbage collection, che possono essere dannosi per le prestazioni delle tue app web. Questo articolo spiega come controllare la memoria utilizzata, riducendo l'impatto sulle prestazioni.

Garbage collection 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 dallo heap di memoria del sistema. Un sistema di 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 ne annulla il riferimento, ma in un secondo momento, quando l'euristica del GC decide che sarebbe utile farlo. Questo processo decisionale richiede che il GC esegua un'analisi statistica sugli oggetti attivi e inattivi, che richiede un blocco di tempo per l'esecuzione.

La garbage collection è spesso descritta 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 con cui un GC recupera la memoria non è senza costi, di solito riduce le prestazioni disponibili impiegando un blocco di tempo per svolgere il suo lavoro; inoltre, il sistema stesso decide quando eseguirlo. Non hai alcun controllo su questa azione, un impulso di GC può verificarsi in qualsiasi momento durante l'esecuzione del codice, che verrà bloccata fino al completamento. La durata di questo impulso è generalmente sconosciuta; l'esecuzione richiederà un certo periodo 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 garbage collector possono compromettere questo obiettivo, in quanto possono essere eseguiti in momenti casuali per durate casuali, riducendo il tempo disponibile di cui l'applicazione ha bisogno per raggiungere i propri obiettivi di rendimento.

Riduci la saturazione della memoria e le imposte di garbage collection

Come già accennato, un impulso di GC si verifica quando un insieme di euristiche determina che sono presenti abbastanza oggetti inattivi da rendere vantaggioso un impulso. Pertanto, la chiave per ridurre la quantità di tempo che il Garbage Collector sottrae alla tua applicazione consiste nell'eliminare il maggior numero possibile di casi di creazione e rilascio eccessivi di oggetti. Questo processo di creazione/liberazione frequente di oggetti è chiamato "saturazione della memoria". Se riesci a ridurre la saturazione della memoria durante il ciclo di vita della tua applicazione, riduci anche il tempo impiegato dal GC per l'esecuzione. Ciò significa che devi rimuovere/ridurre il numero di oggetti creati ed eliminati, ovvero devi interrompere l'allocazione della memoria.

Questo processo sposterà il tuo grafico della memoria da questo :

Un'istantanea dalla tua cronologia dei ricordi

a questo:

Static Memory Javascript

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

Passaggio a JavaScript con memoria statica

Static Memory JavaScript è una tecnica che prevede la pre-allocazione, all'avvio dell'app, di tutta la memoria necessaria per 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 la tua applicazione per determinare il numero massimo di oggetti di memoria attivi richiesti (per tipo) per una serie di scenari di utilizzo
  2. Implementa nuovamente il codice per preallocare la quantità massima, quindi recupera/rilascia manualmente i dati anziché accedere alla memoria principale.

In realtà, per raggiungere il primo obiettivo dobbiamo fare un po' di lavoro sul secondo, quindi iniziamo da lì.

Pool di oggetti

In parole 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 dallo heap di memoria del sistema, ricicli uno degli oggetti inutilizzati del pool. Una volta che il codice esterno ha terminato di utilizzare l'oggetto, anziché rilasciarlo nella memoria principale, lo restituisce 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 sulle prestazioni.

Poiché un'applicazione gestisce un insieme eterogeneo di tipi di oggetti, l'utilizzo corretto dei pool di oggetti richiede un pool per ogni tipo che subisce un elevato tasso di abbandono 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 stragrande maggioranza delle applicazioni, prima o poi si raggiungerà un livello di stabilizzazione in termini di necessità di allocare nuovi oggetti. Dopo aver eseguito l'applicazione più volte, dovresti avere un'idea precisa di questo limite massimo e puoi preallocare quel numero di oggetti all'avvio dell'applicazione.

Pre-allocazione degli oggetti

L'implementazione del pooling di oggetti nel tuo progetto ti fornirà un numero massimo teorico di oggetti richiesti durante il runtime della tua applicazione. Dopo aver eseguito il sito in vari scenari di test, puoi farti un'idea dei tipi di requisiti di memoria necessari, catalogare i dati da qualche parte e analizzarli per capire quali sono i limiti massimi dei requisiti di memoria per la tua applicazione.

Poi, nella versione di spedizione 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à ridotto il numero di allocazioni che avvengono 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);
}

La quantità scelta ha molto a che fare con il comportamento della tua applicazione; a volte il massimo teorico non è l'opzione migliore. Ad esempio, la scelta del valore massimo medio potrebbe darti un footprint della memoria più piccolo per gli utenti non esperti.

Non è una soluzione magica

Esiste un'intera classificazione di app in cui i pattern di crescita della memoria statica possono essere un vantaggio. Tuttavia, come sottolinea il collega di Chrome DevRel Renato Mangini, esistono alcuni svantaggi.

Conclusione

Uno dei motivi per cui JavaScript è ideale per il web è che si tratta di un linguaggio veloce, divertente e facile da imparare. Ciò è dovuto principalmente alla sua bassa barriera alle restrizioni di sintassi e alla sua gestione dei problemi di memoria per tuo conto. Puoi scrivere il codice e lasciare che si occupi del lavoro sporco. Tuttavia, per le applicazioni web ad alte prestazioni, come i giochi HTML5, il GC può spesso consumare la frequenza fotogrammi di cui si ha un disperato bisogno, riducendo l'esperienza per l'utente finale. Con un'attenta strumentazione e l'adozione di pool di oggetti, puoi ridurre questo carico sulla frequenza fotogrammi e recuperare quel tempo per 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 ha sfumature di implementazione specifiche, il che è importante, considerando che ogni utilizzo dell'applicazione può avere esigenze di implementazione specifiche.

Riferimenti