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 app 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 colegas 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 la 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, 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 rendimiento y recolección de elementos no utilizados

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 e inactivos, lo que lleva un bloque de tiempo para realizarse.

La recolección de basura suele describirse como lo opuesto a la administración manual de 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 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, ya que se puede producir un pulso de GC en cualquier momento durante la ejecución del código, lo que bloqueará la ejecución hasta que se complete. La duración de este pulso suele ser desconocida para ti; tardará un poco en ejecutarse, dependiendo de cómo tu programa use la memoria en un momento dado.

Las aplicaciones de alto rendimiento se basan en límites de rendimiento coherentes para garantizar una experiencia fluida para los usuarios. Los sistemas de recolector de elementos no utilizados pueden hacer un cortocircuito en este objetivo, ya que se pueden ejecutar en momentos aleatorios durante períodos aleatorios, lo que reduce el tiempo disponible que la aplicación necesita para cumplir 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 excesiva 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; en efecto, 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.

Migración hacia JavaScript de 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 vida útil y administrarla durante la ejecución a medida que ya no se necesitan los objetos. Podemos abordar este objetivo en unos pocos pasos sencillos:

  1. Instrumenta tu aplicación para determinar la cantidad máxima de objetos de memoria activos necesarios (por tipo) para una variedad de situaciones de uso.
  2. Vuelve a implementar tu código para asignar previamente esa cantidad máxima y, luego, recupérala/libera de forma manual en lugar de ir a la memoria principal.

En realidad, lograr el punto 1 requiere que hagamos 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 la pila 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 basura. El uso de grupos de objetos permite que el programador tenga el control de la memoria, 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

Para la gran mayoría de las aplicaciones, con el tiempo llegarás a algún nivel en términos de la necesidad de 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 completar previamente todos los grupos de objetos hasta una cantidad específica. 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

Existe 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

Uno de los motivos por los que JavaScript es ideal para la Web es que es un lenguaje rápido, divertido y fácil de 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, el GC suele afectar la velocidad de fotogramas que es esencialmente necesaria, lo que reduce la experiencia del usuario final. Con un poco de instrumentación y adopción de grupos de objetos cuidadosos, puedes reducir esta carga en la velocidad de fotogramas y recuperar ese tiempo para cosas más increíbles.

Código fuente

Hay muchas implementaciones de grupos de objetos en la Web, por lo 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