Introducción
Supongamos que recibes un correo electrónico en el que se indica que tu juego o app web tiene un rendimiento deficiente después de un tiempo determinado. Revisas el código, pero no ves nada que se destaque, hasta que abres las herramientas de rendimiento de memoria de Chrome y ves lo siguiente:
Uno de tus compañeros de trabajo 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 dientes de sierra es muy revelador sobre un problema de rendimiento potencialmente crítico. A medida que aumente el uso de memoria, verás que el área del gráfico también aumenta en la captura de la línea de tiempo. Cuando el gráfico baja de repente, es un caso en el que se ejecutó el recolector de basura y se limpiaron los objetos de memoria a los que se hace referencia.
En un gráfico como este, puedes ver que se producen muchos eventos de recolección de elementos no utilizados, lo que puede perjudicar el rendimiento de tus apps web. En este artículo, se explica cómo controlar el uso de memoria para reducir el impacto en el rendimiento.
Recolección de elementos no utilizados y costos de 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 recolección 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 anula la referencia, sino en un momento posterior, cuando la heurística del GC decide que sería beneficioso hacerlo. Este proceso de decisión requiere que el GC ejecute un análisis estadístico de los objetos activos e inactivos, lo que lleva un bloque de tiempo.
La recolección de elementos no utilizados suele presentarse 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 un GC recupera memoria no es gratuito, ya que suele reducir el rendimiento disponible al tomar 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, ya que puede ocurrir un pulso de GC 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, ya que tardará un tiempo en ejecutarse, según cómo tu programa use la memoria en un momento determinado.
Las aplicaciones de alto rendimiento dependen de límites de rendimiento coherentes para garantizar una experiencia fluida para los usuarios. Los sistemas de recolección de elementos no utilizados pueden interrumpir este objetivo, ya que pueden ejecutarse en momentos aleatorios durante períodos aleatorios, lo que consume el tiempo disponible que la aplicación necesita para cumplir con sus objetivos de rendimiento.
Reduce la saturación de la memoria y las tasas de recolección de elementos no utilizados
Como se indicó, 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 toma de tu aplicación consiste en eliminar tantos casos de creación y liberación excesivas de objetos como puedas. Este proceso de crear y liberar 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 tarda en ejecutarse. Esto significa que debes quitar o reducir la cantidad de objetos creados y destruidos; en efecto, debes dejar de asignar memoria.
Este proceso moverá tu gráfico de memoria de esto :
por este:
En este modelo, puedes ver que el gráfico ya no tiene un patrón similar a un diente de sierra, sino que crece mucho al principio y, luego, aumenta lentamente con el tiempo. Si tienes problemas de rendimiento debido a la saturación de la memoria, este es el tipo de gráfico que querrás crear.
Hacia un JavaScript con memoria estática
Static Memory JavaScript 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 alcanzar este objetivo con unos pocos pasos sencillos:
- Instrumenta tu aplicación para determinar cuál es la cantidad máxima de objetos de memoria activos necesarios (por tipo) para un rango de situaciones de uso.
- Vuelve a implementar tu código para asignar previamente 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, la agrupación 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 montón de memoria del sistema, reciclas uno de los objetos no utilizados del grupo. Una vez que el código externo termina de usar el objeto, en lugar de liberarlo a la memoria principal, se devuelve al grupo. Como el objeto nunca se desreferencia (es decir, se borra) del código, no se recolectará como elemento no utilizado. 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 alta deserción durante el tiempo de ejecución de la 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, se alcanzará un nivel de estabilidad en cuanto a la necesidad de asignar objetos nuevos. Después de ejecutar tu aplicación varias veces, deberías poder tener una idea clara de cuál es este límite superior y preasignar esa cantidad de objetos al inicio de la aplicación.
Asignación previa de objetos
Implementar la agrupación de objetos en tu proyecto te dará un máximo teórico para la cantidad de objetos requeridos durante el tiempo de ejecución de tu aplicación. Una vez que ejecutes tu sitio en varias situaciones de prueba, podrás tener 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 precompletar todos los grupos de objetos con una cantidad especificada. Esta acción enviará toda la inicialización de objetos al frente de tu 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);
}
La cantidad 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 un espacio en memoria más pequeño para los usuarios no avanzados.
No es una solución mágica
Existe una clasificación completa de apps en las que los patrones de crecimiento de memoria estática pueden ser una ventaja. Sin embargo, como señala Renato Mangini, colega de DevRel de Chrome, existen algunas desventajas.
Conclusión
Una de las razones por las que JavaScript es ideal para la Web es que se trata de un lenguaje rápido, divertido y fácil para comenzar. Esto se debe principalmente a su baja barrera para las restricciones de sintaxis y a que maneja los problemas de memoria por ti. Puedes programar y dejar que se encargue del trabajo sucio. Sin embargo, en el caso de las aplicaciones web de alto rendimiento, como los juegos HTML5, el GC suele consumir la velocidad de fotogramas que se necesita de forma crítica, 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 realizar tareas más importantes.
Código fuente
Hay muchas implementaciones de grupos de objetos en la Web, por lo que no te aburriré con otra más. En cambio, te dirigiré a estos, cada uno de los cuales tiene matices de implementación específicos, lo que es importante, considerando que cada uso de la aplicación puede tener necesidades de implementación específicas.
- Grupo de objetos de Gamecore.js
- Beej’s Object Pools
- Grupo de objetos muy simple de Emehrkay
- Grupo de objetos enfocado en juegos de Steven Lambert
- Configuración de objectPool de RenderEngine