JavaScript de memoria estática con grupos de objetos

Introducción

Recibes un correo electrónico en el que se indica que tu juego o aplicación web tiene un rendimiento bajo después de un período determinado. Analizas tu código y no ves nada que se destaque, hasta que abres las herramientas de rendimiento de la memoria de Chrome y ves lo siguiente:

Una instantánea de tu cronograma de memoria

Uno de tus compañeros se ríe porque se da cuenta de que tienes un problema de rendimiento relacionado con la memoria.

En la vista del gráfico de memoria, este patrón de sierra es muy revelador sobre un problema de rendimiento potencialmente crítico. A medida que aumenta el uso de memoria, verás que el área del gráfico también aumenta en la captura de cronograma. Cuando el gráfico baja de repente, se trata de una instancia en la que se ejecutó el recolector de elementos no utilizados y se limpiaron los objetos de memoria a los que se hace referencia.

Qué significan los dientes de sierra

En un gráfico como este, puedes ver que se producen muchos eventos de recolección de elementos no utilizados, lo que puede ser perjudicial para el rendimiento de tus apps web. En este artículo, se explica cómo tomar el control del uso de la memoria y reducir el impacto en el rendimiento.

Costos de recolección de elementos no utilizados y rendimiento

El modelo de memoria de JavaScript se basa en una tecnología conocida como recolector de elementos no utilizados. En muchos lenguajes, el programador es directamente responsable de asignar y liberar memoria del montón de memoria del sistema. Sin embargo, un sistema de recolector de elementos no utilizados administra esta tarea en nombre del programador, lo que significa que los objetos no se liberan directamente de la memoria cuando el programador los desreferencia, sino más adelante, cuando las heurísticas del GC deciden que sería beneficioso hacerlo. Este proceso de decisión requiere que el GC ejecute algún análisis estadístico en objetos activos y no activos, lo que lleva un período de tiempo para realizarse.

La recolección de basura suele describirse como lo opuesto a la administración manual de la memoria, que requiere que el programador especifique qué objetos desasignar y devolver al sistema de memoria.

El proceso en el que una GC recupera la memoria no es gratuito, por lo general, reduce el rendimiento disponible, ya que se toma un bloque de tiempo para realizar su trabajo. Además, el sistema toma la decisión de cuándo ejecutarse. No tienes control sobre esta acción. Un pulso de GC puede ocurrir en cualquier momento durante la ejecución del código, lo que bloqueará la ejecución del código hasta que se complete. Por lo general, no conoces la duración de este pulso, que tardará un tiempo en ejecutarse, según cómo el programa use la memoria en un momento determinado.

Las aplicaciones de alto rendimiento se basan en límites de rendimiento coherentes para garantizar una experiencia fluida para los usuarios. Los sistemas de recolección de elementos no utilizados pueden cortocircuitar este objetivo, ya que pueden ejecutarse en momentos aleatorios durante duraciones aleatorias, lo que reduce el tiempo disponible que la aplicación necesita para cumplir con sus objetivos de rendimiento.

Reduce la rotación de la memoria y los impuestos de recolección de elementos no utilizados

Como se señaló, se producirá un pulso de GC una vez que un conjunto de heurísticas determine que hay suficientes objetos inactivos para que un pulso sea beneficioso. Por lo tanto, la clave para reducir la cantidad de tiempo que el recolector de elementos no utilizados le quita a tu aplicación reside en eliminar tantos casos de creación y liberación excesivas de objetos como sea posible. Este proceso de creación o liberación de objetos con frecuencia se denomina "saturación de la memoria". Si puedes reducir la saturación de la memoria durante el ciclo de vida de tu aplicación, también reducirás la cantidad de tiempo que la GC toma de tu ejecución. Esto significa que debes quitar o reducir la cantidad de objetos creados y destruidos, es decir, debes dejar de asignar memoria.

Este proceso moverá tu gráfico de memoria de la siguiente manera :

Una instantánea de tu cronograma de memoria

a esto:

JavaScript de memoria estática

En este modelo, puedes ver que el gráfico ya no tiene un patrón de serrenilla, sino que crece mucho al principio y, luego, aumenta lentamente con el tiempo. Si tienes problemas de rendimiento debido a la rotación de la memoria, este es el tipo de gráfico que querrás crear.

Paso a JavaScript con memoria estática

JavaScript de memoria estática es una técnica que implica asignar previamente, al inicio de tu app, toda la memoria que se necesitará durante su ciclo de vida y administrar esa memoria durante la ejecución a medida que los objetos ya no sean necesarios. Podemos abordar este objetivo en unos pocos pasos sencillos:

  1. Instrumenta tu aplicación para determinar cuál es la cantidad máxima de objetos de memoria activa requeridos (por tipo) para un rango de situaciones de uso.
  2. Vuelve a implementar tu código para prealocar esa cantidad máxima y, luego, recuperarlos o liberarlos de forma manual en lugar de ir a la memoria principal.

En realidad, para lograr el punto 1, debemos hacer un poco del punto 2, así que comencemos por ahí.

Grupo de objetos

En términos simples, el agrupamiento de objetos es el proceso de retener un conjunto de objetos sin usar que comparten un tipo. Cuando necesitas un objeto nuevo para tu código, en lugar de asignar uno nuevo desde el heap de memoria del sistema, reciclas uno de los objetos no utilizados del grupo. Una vez que el código externo termina con el objeto, en lugar de liberarlo en la memoria principal, se devuelve al grupo. Como el objeto nunca se derefiere (también conocido como borrado) del código, no se realizará la recolección de elementos no utilizados. El uso de grupos de objetos devuelve el control de la memoria al programador, lo que reduce la influencia del recolector de elementos no utilizados en el rendimiento.

Dado que hay un conjunto heterogéneo de tipos de objetos que mantiene una aplicación, el uso adecuado de los grupos de objetos requiere que tengas un grupo por tipo que experimente una deserción alta durante el tiempo de ejecución de tu aplicación.

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

En la gran mayoría de las aplicaciones, con el tiempo, llegarás a un punto en el que deberás asignar objetos nuevos. A lo largo de varias ejecuciones de tu aplicación, deberías poder obtener una idea clara de cuál es este límite superior y puedes prealocar esa cantidad de objetos al comienzo de tu aplicación.

Asignación previa de objetos

La implementación del grupo de objetos en tu proyecto te dará un máximo teórico para la cantidad de objetos necesarios durante el tiempo de ejecución de tu aplicación. Una vez que ejecutes tu sitio en varias situaciones de prueba, podrás obtener una buena idea de los tipos de requisitos de memoria que se necesitarán, catalogar esos datos en algún lugar y analizarlos para comprender cuáles son los límites superiores de los requisitos de memoria para tu aplicación.

Luego, en la versión de envío de tu app, puedes configurar la fase de inicialización para que precomplete todos los grupos de objetos hasta una cantidad especificada. Esta acción enviará toda la inicialización del objeto al principio de la app y reducirá la cantidad de asignaciones que se producen de forma dinámica durante su ejecución.

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

El importe que elijas tiene mucho que ver con el comportamiento de tu aplicación. A veces, el máximo teórico no es la mejor opción. Por ejemplo, elegir el máximo promedio puede darte una huella de memoria más pequeña para los usuarios que no son expertos.

Lejos de ser una solución mágica

Hay una clasificación completa de apps en las que los patrones de crecimiento de la memoria estática pueden ser una ventaja. Sin embargo, como señala el colega de Chrome DevRel Renato Mangini, existen algunas desventajas.

Conclusión

Una de las razones por las que JavaScript es ideal para la Web es que es un lenguaje rápido, divertido y fácil de comenzar a usar. Esto se debe principalmente a su baja barrera para las restricciones de sintaxis y su manejo de los problemas de memoria en tu nombre. Puedes programar y dejar que se encargue del trabajo pesado. Sin embargo, en el caso de las aplicaciones web de alto rendimiento, como los juegos HTML5, la GC suele consumir la velocidad de fotogramas necesaria, lo que reduce la experiencia del usuario final. Con una instrumentación cuidadosa y la adopción de grupos de objetos, puedes reducir esta carga en la velocidad de fotogramas y recuperar ese tiempo para hacer cosas más interesantes.

Código fuente

Hay muchas implementaciones de grupos de objetos en la Web, así que no te aburriré con otra. En su lugar, te dirigiré a estos, cada uno de los cuales tiene matices de implementación específicos, lo que es importante, teniendo en cuenta que cada uso de la aplicación puede tener necesidades de implementación específicas.

Referencias