JavaScript de memoria estática con grupos de objetos

Introducción

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

Una instantánea de la línea de tiempo de tu 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 de 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 crece en la captura de la línea de tiempo. Cuando el gráfico cae repentinamente, se trata de una instancia en la que se ejecutó el recolector de elementos no utilizados y limpió 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 están produciendo muchos eventos de recolección de elementos no utilizados, que pueden ser perjudiciales para el rendimiento de tus aplicaciones web. En este artículo, se explica cómo controlar el uso de memoria para 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 la heurística de la recolección de elementos no utilizados decide 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 requiere un bloque de tiempo para ejecutarse.

La recolección de elementos no utilizados suele representarse como lo opuesto a la administración manual de la memoria, que requiere que el programador especifique qué objetos desasignar y regresar al sistema de memoria.

El proceso en el que una GC recupera memoria no es libre. Por lo general, reduce tu rendimiento disponible al tomarse un bloque de tiempo para hacer su trabajo. Además, el propio sistema toma la decisión de cuándo ejecutarse. No tienes control sobre esta acción. Puede producirse 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; tardará un poco en ejecutarse, dependiendo de cómo tu programa esté usando 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 generar un cortocircuito en este objetivo, ya que pueden ejecutarse en momentos aleatorios durante períodos aleatorios, lo que disminuye el tiempo disponible que la aplicación necesita para cumplir con sus objetivos de rendimiento.

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

Como se indicó, un pulso de GC ocurrirá 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 el tiempo que el recolector de elementos no utilizados le toma a tu aplicación consiste en eliminar la mayor cantidad posible de casos de creación y liberación de objetos excesivos. El proceso de crear/liberar objetos con frecuencia se denomina “saturación de la memoria”. Si puedes reducir la pérdida de memoria durante el ciclo de vida de tu aplicación, también reduces el tiempo que le toma la GC a tu ejecución. Esto significa que debes quitar o reducir la cantidad de objetos creados y destruidos, y efectivamente debes dejar de asignar memoria.

Este proceso moverá el gráfico de la memoria de lo siguiente :

Una instantánea de la línea de tiempo de tu memoria

a esto:

JavaScript de memoria estática

En este modelo, puedes ver que el gráfico ya no tiene un patrón similar a una 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.

Avanza hacia JavaScript de memoria estática

El JavaScript de memoria estática es una técnica que implica preasignar, al comienzo de tu app, toda la memoria que se necesitará durante su vida útil y administrar esa memoria durante la ejecución a medida que ya no se necesiten objetos. Podemos abordar este objetivo con algunos pasos sencillos:

  1. Instrumenta tu aplicación para determinar cuál es la cantidad máxima de objetos de memoria en vivo 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, recuperarlos/liberarlos manualmente en lugar de ir a la memoria principal.

En realidad, para lograr el primer puesto, debemos hacer un segundo, 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, recicla uno de los objetos no utilizados del grupo. Una vez que el código externo se termina con el objeto, en lugar de liberarlo a la memoria principal, vuelve al grupo. Debido a que el objeto nunca se desvincula (o se borra) del código, no se recolectará como elemento no utilizado. El uso de grupos de objetos pone de vuelta el control de la memoria en manos del programador, lo que reduce la influencia del recolector de elementos no utilizados en el rendimiento.

Debido a que existe un conjunto heterogéneo de tipos de objetos que una aplicación mantiene, el uso adecuado de grupos de objetos requiere que tengas un grupo por tipo que experimente una deserción alta 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

Para la gran mayoría de las aplicaciones, con el tiempo se alcanzará un nivel más bajo en términos de la necesidad de asignar objetos nuevos. Con varias ejecuciones de tu aplicación, deberías poder comprender cuál es este límite superior y asignar previamente esa cantidad de objetos al inicio de la aplicación.

Preasignación de objetos

Implementar la reducción de objetos en tu proyecto te proporcionará 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 tener una buena idea de los tipos de requisitos de memoria que se necesitarán y catalogar esos datos en alguna parte y analizarlos para comprender cuáles son los límites máximos de memoria requeridos para tu aplicación.

Luego, en la versión de envío de tu app, puedes configurar la fase de inicialización para que complete previamente todos los grupos de objetos con una cantidad específica. Este acto aplicará toda la inicialización de objetos al frente de tu app y reducirá la cantidad de asignaciones que se produzcan 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, si eliges el máximo promedio, podrías reducir el uso de la memoria para los usuarios que no son power users.

Lejos de milagros

Hay toda una clasificación de apps en las que los patrones de crecimiento de memoria estáticos pueden ser beneficiosos. Sin embargo, como señala Renato Mangini de Chrome DevRel, hay algunas desventajas.

Conclusión

Una de las razones por las que JavaScript es ideal para la Web depende de que sea un lenguaje fácil, divertido y rápido para comenzar. Esto se debe principalmente a su baja barrera para las restricciones de sintaxis y al manejo de 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, la recolección de elementos no usados a menudo puede consumir la velocidad de fotogramas que resulta sumamente necesaria, lo que reduce la experiencia del usuario final. Con un poco de instrumentación y adopción cuidadosa de grupos de objetos, puedes reducir esta carga en tu velocidad de fotogramas y recuperar ese tiempo para cosas más increíbles.

Código fuente

Hay muchas implementaciones de grupos de objetos que flotan en la Web, así que no te aburriré con ninguna otra. En su lugar, te guiaré a ellas, cada una de las cuales tiene matices de implementación específicos, lo cual es importante, ya que cada uso de la aplicación puede tener necesidades de implementación específicas.

Referencias