Introducción
Si bien JavaScript emplea la recolección de elementos no utilizados para la administración automática de la memoria, no reemplaza la administración eficaz de la memoria en las aplicaciones. Las aplicaciones de JavaScript sufren los mismos problemas relacionados con la memoria que las aplicaciones nativas, como fugas de memoria y aumento de tamaño, pero también deben lidiar con las pausas de recolección de elementos no utilizados. Las aplicaciones a gran escala, como Gmail, tienen los mismos problemas que enfrentan tus aplicaciones más pequeñas. Sigue leyendo para saber cómo el equipo de Gmail usó Chrome DevTools para identificar, aislar y corregir sus problemas de memoria.
Sesión de Google I/O 2013
Presentamos este material en Google I/O 2013. Mira el siguiente video:
Gmail, tenemos un problema…
El equipo de Gmail se enfrentaba a un problema grave. Cada vez más a menudo, se escuchaban anécdotas sobre pestañas de Gmail que consumían varios gigabytes de memoria en laptops y computadoras de escritorio con recursos limitados, y, a menudo, la conclusión era que se bloqueaba todo el navegador. Historias de CPUs fijadas al 100%, apps que no responden y pestañas tristes de Chrome ("Se murió, Jim"). El equipo no sabía ni por dónde empezar a diagnosticar el problema, y mucho menos a solucionarlo. No tenían idea de qué tan extendido estaba el problema y las herramientas disponibles no se escalaban a aplicaciones grandes. El equipo se unió a los equipos de Chrome y, juntos, desarrollaron nuevas técnicas para clasificar los problemas de memoria, mejorar las herramientas existentes y habilitar la recopilación de datos de memoria desde el campo. Pero, antes de llegar a las herramientas, repasemos los conceptos básicos de la administración de memoria de JavaScript.
Conceptos básicos de la administración de la memoria
Antes de poder administrar de manera eficaz la memoria en JavaScript, debes comprender los conceptos básicos. En esta sección, se abordarán los tipos primitivos, el gráfico de objetos y se proporcionarán definiciones para el aumento excesivo de la memoria en general y una fuga de memoria en JavaScript. La memoria en JavaScript se puede conceptualizar como un gráfico y, debido a esto, la teoría de grafos juega un papel en la administración de memoria de JavaScript y en el Generador de perfiles de montón.
Tipos básicos
JavaScript tiene tres tipos primitivos:
- Número (p.ej., 4, 3.14159)
- Boolean (true or false)
- Cadena ("Hello World")
Estos tipos primitivos no pueden hacer referencia a ningún otro valor. En el gráfico de objetos, estos valores siempre son nodos finales o de hoja, lo que significa que nunca tienen un borde saliente.
Solo hay un tipo de contenedor: el objeto. En JavaScript, el objeto es un array asociativo. Un objeto no vacío es un nodo interno con bordes salientes a otros valores (nodos).
¿Qué ocurre con los arrays?
Un array en JavaScript es, en realidad, un objeto que tiene claves numéricas. Esta es una simplificación, ya que los tiempos de ejecución de JavaScript optimizarán los objetos similares a arrays y los representarán como arrays.
Terminología
- Valor: Es una instancia de un tipo primitivo, un objeto, un array, etcétera.
- Variable: Es un nombre que hace referencia a un valor.
- Propiedad: Es un nombre en un objeto que hace referencia a un valor.
Gráfico de objetos
Todos los valores de JavaScript forman parte del grafo de objetos. El gráfico comienza con raíces, por ejemplo, el objeto de ventana. No puedes controlar la vida útil de las raíces de GC, ya que el navegador las crea y destruye cuando se descarga la página. Las variables globales son, en realidad, propiedades de la ventana.
¿Cuándo un valor se convierte en basura?
Un valor se convierte en basura cuando no hay una ruta de acceso desde una raíz al valor. En otras palabras, si se comienza desde las raíces y se busca de forma exhaustiva todas las propiedades y variables del objeto que están activas en el marco de pila, no se puede alcanzar un valor, ya que se convirtió en basura.
¿Qué es una fuga de memoria en JavaScript?
Por lo general, una fuga de memoria en JavaScript ocurre cuando hay nodos DOM a los que no se puede acceder desde el árbol del DOM de la página, pero a los que aún hace referencia un objeto JavaScript. Si bien los navegadores modernos dificultan cada vez más la creación de filtraciones por error, sigue siendo más fácil de lo que se cree. Supongamos que agregas un elemento al árbol del DOM de la siguiente manera:
email.message = document.createElement("div");
displayList.appendChild(email.message);
Luego, quitas el elemento de la lista de visualización:
displayList.removeAllChildren();
Mientras exista email
, no se quitará el elemento DOM al que hace referencia el mensaje, aunque ahora esté separado del árbol del DOM de la página.
¿Qué es el aumento de tamaño?
Tu página está sobrecargada cuando usas más memoria de la necesaria para lograr una velocidad óptima. De manera indirecta, las fugas de memoria también causan un aumento de tamaño, pero no es por diseño. Una caché de aplicación que no tiene ningún límite de tamaño es una fuente común de aumento de memoria. Además, los datos del host pueden aumentar el tamaño de tu página, por ejemplo, los datos de píxeles cargados desde imágenes.
¿Qué es la recolección de elementos no utilizados?
La recolección de elementos no utilizados es la forma en que se recupera la memoria en JavaScript. El navegador determina el momento en que esto sucede. Durante una recolección, se suspende toda la ejecución de secuencias de comandos en tu página mientras se descubren los valores en vivo mediante un recorrido del gráfico de objetos que comienza en las raíces de GC. Todos los valores que no son accesibles se clasifican como basura. El administrador de memoria recupera la memoria para los valores de basura.
Recolección de elementos no utilizados de V8 en detalle
Para comprender mejor cómo se produce la recolección de elementos no utilizados, analicemos en detalle el recolector de elementos no utilizados de V8. V8 usa un colector generacional. La memoria se divide en dos generaciones: la joven y la antigua. La asignación y la recolección dentro de la generación joven son rápidas y frecuentes. La asignación y la recopilación dentro de la generación anterior son más lentas y menos frecuentes.
Generacional
V8 usa un colector de dos generaciones. La antigüedad de un valor se define como la cantidad de bytes asignados desde que se asignó. En la práctica, la edad de un valor suele aproximarse por la cantidad de colecciones de generación joven a las que sobrevivió. Después de que un valor es lo suficientemente antiguo, se asigna a la generación anterior.
En la práctica, los valores asignados recientemente no duran mucho. Un estudio de los programas de Smalltalk mostró que solo el 7% de los valores sobreviven después de una colección de generación joven. En estudios similares en diferentes entornos de ejecución, se descubrió que, en promedio, entre el 90% y el 70% de los valores asignados recientemente nunca se asignan a la generación anterior.
Generación joven
El montón de generación joven en V8 se divide en dos espacios, llamados "from" y "to". La memoria se asigna desde el espacio de destino. La asignación es muy rápida, hasta que el espacio está lleno, momento en el que se activa una colección de generación joven. La colección de la generación joven primero intercambia los espacios de origen y destino, se analiza el espacio de destino anterior (ahora el espacio de origen) y todos los valores activos se copian en el espacio de destino o se asignan a la generación anterior. Una colección típica de la generación joven tardará alrededor de 10 milisegundos (ms).
De manera intuitiva, debes comprender que cada asignación que realiza tu aplicación te acerca a agotar el espacio y a incurrir en una pausa de GC. Desarrolladores de juegos, tomen nota: Para garantizar un tiempo de fotogramas de 16 ms (obligatorio para lograr 60 fotogramas por segundo), tu aplicación no debe realizar ninguna asignación, ya que una sola colección de generación joven consumirá la mayor parte del tiempo de fotogramas.
Generación anterior
El montón de generación anterior en V8 usa un algoritmo de marca compacta para la recopilación. Las asignaciones de la generación antigua se producen cada vez que se asigna un valor de la generación joven a la generación antigua. Cada vez que se produce una colección de generación anterior, también se realiza una colección de generación nueva. Tu solicitud se pausará en cuestión de segundos. En la práctica, esto es aceptable porque las colecciones de generación anterior son poco frecuentes.
Resumen de GC de V8
La administración automática de la memoria con la recolección de elementos no utilizados es excelente para la productividad de los desarrolladores, pero cada vez que asignas un valor, te acercas más a una pausa de recolección de elementos no utilizados. Las pausas de la recolección de elementos no utilizados pueden arruinar la sensación de tu aplicación, ya que introducen interrupciones. Ahora que comprendes cómo JavaScript administra la memoria, puedes tomar las decisiones correctas para tu aplicación.
Cómo corregir Gmail
Durante el último año, se agregaron varias funciones y correcciones de errores a las Herramientas para desarrolladores de Chrome, lo que las hace más potentes que nunca. Además, el navegador realizó un cambio clave en la API de performance.memory, lo que permite que Gmail y cualquier otra aplicación recopilen estadísticas de memoria del campo. Con estas herramientas increíbles, lo que antes parecía una tarea imposible pronto se convirtió en un emocionante juego de rastreo de culpables.
Herramientas y técnicas
API de Field Data y performance.memory
A partir de Chrome 22, la API de performance.memory está habilitada de forma predeterminada. En el caso de las aplicaciones de larga duración, como Gmail, los datos de los usuarios reales son invaluables. Esta información nos permite distinguir entre los usuarios avanzados (aquellos que pasan de 8 a 16 horas al día en Gmail y reciben cientos de mensajes a diario) y los usuarios más promedio que pasan unos minutos al día en Gmail y reciben una docena de mensajes a la semana.
Esta API muestra tres datos:
- jsHeapSizeLimit: Es la cantidad de memoria (en bytes) a la que se limita el montón de JavaScript.
- totalJSHeapSize: Es la cantidad de memoria (en bytes) que asignó el montón de JavaScript, incluido el espacio libre.
- usedJSHeapSize: Es la cantidad de memoria (en bytes) que se usa actualmente.
Una cosa que debes tener en cuenta es que la API muestra valores de memoria para todo el proceso de Chrome. Aunque no es el modo predeterminado, en ciertas circunstancias, Chrome puede abrir varias pestañas en el mismo proceso de renderización. Esto significa que los valores que muestra performance.memory pueden contener el espacio en memoria de otras pestañas del navegador, además de la que contiene tu app.
Cómo medir la memoria a gran escala
Gmail instrumentó su código JavaScript para usar la API de performance.memory y recopilar información de memoria aproximadamente una vez cada 30 minutos. Debido a que muchos usuarios de Gmail dejan la app activa durante días, el equipo pudo hacer un seguimiento del crecimiento de la memoria con el tiempo, así como de las estadísticas generales de la huella de memoria. A los pocos días de instrumentar Gmail para recopilar información de la memoria de una muestra aleatoria de usuarios, el equipo tenía datos suficientes para comprender qué tan extendidos estaban los problemas de memoria entre los usuarios promedio. Establecieron un modelo de referencia y utilizaron el flujo de datos entrantes para hacer un seguimiento del progreso hacia el objetivo de reducir el consumo de memoria. Con el tiempo, estos datos también se usarían para detectar cualquier regresión de memoria.
Además de los fines de seguimiento, las mediciones de campo también proporcionan estadísticas valiosas sobre la correlación entre el espacio en memoria y el rendimiento de la aplicación. Al contrario de la creencia popular de que “más memoria genera un mejor rendimiento”, el equipo de Gmail descubrió que, cuanto mayor era el espacio en memoria, más largas eran las latencias para las acciones comunes de Gmail. Con esta revelación, se sintieron más motivados que nunca para controlar su consumo de memoria.
Cómo identificar un problema de memoria con la función Timeline de DevTools
El primer paso para resolver cualquier problema de rendimiento es probar que existe, crear una prueba reproducible y tomar una medición de referencia del problema. Sin un programa reproducible, no puedes medir el problema de forma confiable. Sin una medición de referencia, no sabes en cuánto mejoraste el rendimiento.
El panel de cronograma de DevTools es una opción ideal para demostrar que el problema existe. Proporciona una descripción general completa de dónde se gasta el tiempo cuando se carga y se interactúa con tu aplicación web o página. Todos los eventos, desde la carga de recursos hasta el análisis de JavaScript, el cálculo de estilos, las pausas de recolección de basura y la repintura, se trazan en una línea de tiempo. Para investigar los problemas de memoria, el panel de cronograma también tiene un modo de memoria que realiza un seguimiento de la memoria total asignada, la cantidad de nodos DOM, la cantidad de objetos de ventana y la cantidad de objetos de escucha de eventos asignados.
Cómo demostrar que existe un problema
Comienza por identificar una secuencia de acciones que sospechas que están provocando una fuga de memoria. Comienza a grabar el cronograma y realiza la secuencia de acciones. Usa el botón de la papelera en la parte inferior para forzar una recolección de elementos no utilizados completa. Si, después de algunas iteraciones, ves un gráfico con forma de serrenilla, significa que estás asignando muchos objetos de corta duración. Sin embargo, si no se espera que la secuencia de acciones genere memoria retenida y el recuento de nodos del DOM no vuelve al modelo de referencia desde el que comenzaste, tienes motivos para sospechar que hay una fuga.
Una vez que hayas confirmado que el problema existe, puedes obtener ayuda para identificar su origen en el generador de perfiles de montón de DevTools.
Cómo encontrar fugas de memoria con el generador de perfiles de montón de DevTools
El panel Generador de perfiles proporciona un generador de perfiles de CPU y uno de montón. La generación de perfiles de montón funciona tomando una instantánea del gráfico de objetos. Antes de que se tome una instantánea, se realiza la recolección de elementos no utilizados en las generaciones antiguas y nuevas. En otras palabras, solo verás los valores que estaban activos cuando se tomó la instantánea.
El generador de perfiles de montón tiene demasiadas funciones para cubrirlas en este artículo, pero puedes encontrar documentación detallada en el sitio de Chrome Developers. Aquí, nos enfocaremos en el generador de perfiles de asignación de montón.
Cómo usar el generador de perfiles de asignación de montón
El generador de perfiles de asignación de montón combina la información detallada de la instantánea del generador de perfiles de montón con la actualización y el seguimiento incrementales del panel de cronograma. Abre el panel Perfiles, inicia un perfil Record Heap Allocations, realiza una secuencia de acciones y, luego, detén la grabación para su análisis. El generador de perfiles de asignaciones toma capturas de pantalla de montón de forma periódica durante la grabación (¡cada 50 ms!) y una captura de pantalla final al final de la grabación.
Las barras de la parte superior indican cuándo se encuentran nuevos objetos en el montón. La altura de cada barra corresponde al tamaño de los objetos asignados recientemente, y el color de las barras indica si esos objetos aún están activos en la instantánea final del montón: las barras azules indican objetos que aún están activos al final del cronograma, y las barras grises indican objetos que se asignaron durante el cronograma, pero que se eliminaron desde entonces.
En el ejemplo anterior, se realizó una acción 10 veces. El programa de muestra almacena en caché cinco objetos, por lo que se esperan las últimas cinco barras azules. Sin embargo, la barra azul más a la izquierda indica un posible problema. Luego, puedes usar los controles deslizantes de la línea de tiempo que figura arriba para acercar ese captura en particular y ver los objetos que se asignaron recientemente en ese punto. Si haces clic en un objeto específico del montón, se mostrará su árbol de retención en la parte inferior de la captura de pantalla del montón. Si examinas la ruta de acceso de retención del objeto, obtendrás suficiente información para comprender la razón por la cual el objeto no fue recolectado y podrás efectuar los cambios requeridos en el código para quitar la referencia innecesaria.
Cómo resolver la crisis de memoria de Gmail
Con las herramientas y técnicas mencionadas anteriormente, el equipo de Gmail pudo identificar algunas categorías de errores: cachés ilimitadas, arrays de devoluciones de llamada que crecen infinitamente esperando que suceda algo que nunca ocurre y objetos de escucha de eventos que retienen sus objetivos de forma no intencional. Cuando se corrigieron estos problemas, se redujo drásticamente el uso general de la memoria de Gmail. Los usuarios del 99% usaron un 80% menos de memoria que antes, y el consumo de memoria de los usuarios del percentil medio disminuyó casi un 50%.
Debido a que Gmail usaba menos memoria, se redujo la latencia de la pausa de GC, lo que aumentó la experiencia general del usuario.
También es importante destacar que, con el equipo de Gmail recopilando estadísticas sobre el uso de la memoria, pudieron descubrir regresiones de recolección de basura en Chrome. Específicamente, se descubrieron dos errores de fragmentación cuando los datos de memoria de Gmail comenzaron a mostrar un aumento significativo en la brecha entre la memoria total asignada y la memoria activa.
Llamado a la acción
Hazte las siguientes preguntas:
- ¿Cuánta memoria usa mi app? Es posible que estés usando demasiada memoria, lo que, al contrario de lo que se cree, tiene un efecto negativo neto en el rendimiento general de la aplicación. Es difícil saber exactamente cuál es la cantidad correcta, pero asegúrate de verificar que el almacenamiento en caché adicional que usa tu página tenga un impacto mensurable en el rendimiento.
- ¿Mi página no tiene filtraciones? Si tu página tiene fugas de memoria, esto no solo puede afectar su rendimiento, sino también el de otras pestañas. Usa el objeto de seguimiento para reducir las fugas.
- ¿Con qué frecuencia se realiza la limpieza de mi página? Puedes ver cualquier pausa de GC con el panel de Rutas en las Herramientas para desarrolladores de Chrome. Si tu página realiza GC con frecuencia, es probable que estés asignando con demasiada frecuencia y que estés agotando la memoria de la generación joven.
Conclusión
Comenzamos en medio de una crisis. Se abordaron los conceptos básicos de la administración de memoria en JavaScript y V8 en particular. Aprendiste a usar las herramientas, incluida la nueva función de rastreador de objetos disponible en las compilaciones más recientes de Chrome. Con este conocimiento, el equipo de Gmail resolvió su problema de uso de memoria y mejoró el rendimiento. Puedes hacer lo mismo con tus apps web.