Administración eficaz de la memoria a escala de Gmail

Loreena Lee
Loreena Lee

Introducción

Si bien JavaScript emplea la recolección de elementos no utilizados para la administración automática de la memoria, no sustituye la administración eficaz de la memoria en las aplicaciones. Las aplicaciones JavaScript sufren los mismos problemas relacionados con la memoria que las aplicaciones nativas, como las fugas de memoria y el aumento de tamaño, pero también deben lidiar con las pausas de la recolección de elementos no utilizados. Las aplicaciones a gran escala, como Gmail, tienen los mismos problemas que las aplicaciones más pequeñas. Sigue leyendo para descubrir cómo el equipo de Gmail usó las Herramientas para desarrolladores de Chrome para identificar, aislar y solucionar 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 se escuchaban con más frecuencia anécdotas de pestañas de Gmail que consumían varios gigabytes de memoria en laptops y computadoras de escritorio con recursos limitados, lo que solía provocar que se cerrara todo el navegador. Historias de CPUs que se quedan bloqueadas al 100%, apps que no responden y pestañas tristes de Chrome ("Está muerto, Jim"). El equipo no sabía cómo empezar a diagnosticar el problema, y mucho menos cómo solucionarlo. No tenían idea de qué tan extendido estaba el problema, y las herramientas disponibles no se escalaban a aplicaciones grandes. El equipo unió fuerzas con los equipos de Chrome y, juntos, desarrollaron nuevas técnicas para clasificar los problemas de memoria, mejoraron las herramientas existentes y habilitaron la recopilación de datos de memoria del campo. Pero, antes de llegar a las herramientas, veamos los conceptos básicos de la administración de la memoria de JavaScript.

Conceptos básicos de la administración de la memoria

Antes de poder administrar la memoria de manera eficaz en JavaScript, debes comprender los aspectos básicos. En esta sección, se abordarán los tipos primitivos y el gráfico de objetos, y se proporcionarán definiciones para el aumento de memoria en general y una fuga de memoria en JavaScript. La memoria en JavaScript se puede conceptualizar como un grafo y, por eso, la teoría de grafos desempeña un papel en la administración de la memoria de JavaScript y el generador de perfiles del montón.

Tipos básicos

JavaScript tiene tres tipos primitivos:

  1. Número (p.ej., 4, 3.14159)
  2. Boolean (true or false)
  3. 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 hoja o terminales, 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é sucede con los arrays?

En realidad, un array en JavaScript es un objeto que tiene claves numéricas. Esta es una simplificación, ya que los entornos de ejecución de JavaScript optimizarán los objetos similares a arrays y los representarán de forma interna como arrays.

Terminología

  1. Valor: Es una instancia de un tipo primitivo, un objeto, un array, etcétera.
  2. Variable: Es un nombre que hace referencia a un valor.
  3. 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 gráfico de objetos. El gráfico comienza con raíces, por ejemplo, el objeto window. No puedes administrar el ciclo de vida de las raíces de GC, ya que el navegador las crea y las destruye cuando se descarga la página. Las variables globales son, en realidad, propiedades de window.

Gráfico de objetos

¿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 hasta el valor. En otras palabras, si se comienza desde las raíces y se buscan de forma exhaustiva todas las propiedades y variables de objetos que están activas en el marco de pila, no se puede alcanzar un valor, por lo que se convierte en basura.

Gráfico de basura

¿Qué es una fuga de memoria en JavaScript?

Una fuga de memoria en JavaScript suele ocurrir cuando hay nodos del 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 de JavaScript. Si bien los navegadores modernos hacen que sea cada vez más difícil crear fugas de forma involuntaria, sigue siendo más fácil de lo que se podría pensar. Supongamos que agregas un elemento al árbol del DOM de la siguiente manera:

email.message = document.createElement("div");
displayList.appendChild(email.message);

Y, más adelante, quitas el elemento de la lista de visualización:

displayList.removeAllChildren();

Mientras exista email, no se quitará el elemento del DOM al que hace referencia message, aunque ahora esté separado del árbol del DOM de la página.

¿Qué es el exceso de datos?

Tu página está sobrecargada cuando usas más memoria de la necesaria para obtener una velocidad de página óptima. De forma indirecta, las fugas de memoria también causan sobrecarga, pero eso 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, tu página puede estar sobrecargada con datos del host, por ejemplo, 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 decide cuándo sucede esto. Durante una recolección, se suspende toda la ejecución de secuencias de comandos en tu página mientras se descubren los valores activos mediante un recorrido del gráfico de objetos que comienza en las raíces de GC. Todos los valores que no son alcanzables se clasifican como basura. El administrador de memoria recupera la memoria de los valores no utilizados.

Recolector de elementos no utilizados de V8 en detalle

Para comprender mejor cómo se produce la recolección de elementos no utilizados, veamos en detalle el recolector de elementos no utilizados de V8. V8 usa un recolector generacional. La memoria se divide en dos generaciones: la joven y la vieja. La asignación y la recolección dentro de la generación joven son rápidas y frecuentes. La asignación y la recolección dentro de la generación antigua son más lentas y menos frecuentes.

Recolector generacional

V8 usa un recolector 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 recolecciones de la generación joven a las que sobrevivió. Después de que un valor es lo suficientemente antiguo, se lo transfiere a la generación anterior.

En la práctica, los valores asignados recientemente no duran mucho. Un estudio de los programas de Smalltalk demostró que solo el 7% de los valores sobreviven después de una recolección de generación joven. Estudios similares en todos los entornos de ejecución descubrieron que, en promedio, entre el 90% y el 70% de los valores recién asignados nunca se conservan en la generación anterior.

Generación joven

El montón de la generación joven en V8 se divide en dos espacios, denominados de origen y de destino. La memoria se asigna desde el espacio de destino. La asignación es muy rápida hasta que el espacio de destino se llena, momento en el que se activa una recolección de la generación joven. La recolecció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 transfieren a la generación anterior. Una recolecció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 generar una pausa de GC. Desarrolladores de juegos, tomen nota: para garantizar una latencia de fotogramas de 16 ms (necesaria para lograr 60 fotogramas por segundo), su aplicación no debe realizar asignaciones, ya que una sola recolección de generación joven consumirá la mayor parte de la latencia de fotogramas.

Montón de generación joven

Generación anterior

El montón de generación anterior en V8 usa un algoritmo de marcado y compactación para la recolección. Las asignaciones de la generación anterior se producen cada vez que un valor se transfiere de la generación joven a la generación anterior. Cada vez que se produce una recolección de la generación antigua, también se realiza una recolección de la generación joven. La aplicación se detendrá durante unos segundos. En la práctica, esto es aceptable porque las recolecciones de la 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 experiencia de tu aplicación, ya que introducen bloqueos. Ahora que comprendes cómo JavaScript administra la memoria, puedes tomar las decisiones correctas para tu aplicación.

Cómo solucionar problemas de Gmail

Durante el último año, se incorporaron numerosas funciones y correcciones de errores en las Herramientas para desarrolladores de Chrome, lo que las hace más potentes que nunca. Además, el navegador en sí 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 desde el campo. Con estas increíbles herramientas, lo que antes parecía una tarea imposible pronto se convirtió en un emocionante juego de rastreo de culpables.

Herramientas y técnicas:

Datos de campo y la API de performance.memory

A partir de Chrome 22, la API de performance.memory está habilitada de forma predeterminada. Para las aplicaciones de larga duración, como Gmail, los datos de usuarios reales son invaluables. Esta información nos permite distinguir entre los usuarios avanzados (aquellos que pasan entre 8 y 16 horas al día en Gmail y reciben cientos de mensajes diarios) y los usuarios más comunes, que pasan unos minutos al día en Gmail y reciben alrededor de una docena de mensajes por semana.

Esta API devuelve tres datos:

  1. jsHeapSizeLimit: Es la cantidad de memoria (en bytes) a la que está limitado el montón de JavaScript.
  2. totalJSHeapSize: Es la cantidad de memoria (en bytes) que asignó el montón de JavaScript, incluido el espacio libre.
  3. usedJSHeapSize: Es la cantidad de memoria (en bytes) que se usa actualmente.

Ten en cuenta que la API devuelve 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.

Mide la memoria a gran escala

Gmail instrumentó su JavaScript para usar la API de performance.memory y recopilar información de la memoria aproximadamente una vez cada 30 minutos. Como muchos usuarios de Gmail dejan la app abierta durante días, el equipo pudo hacer un seguimiento del crecimiento de la memoria a lo largo del tiempo, así como de las estadísticas generales del espacio en memoria. A los pocos días de instrumentar Gmail para recopilar información de la memoria de una muestra aleatoria de usuarios, el equipo tuvo suficientes datos para comprender qué tan extendidos estaban los problemas de memoria entre los usuarios promedio. Establecieron un modelo de referencia y usaron 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.

Más allá de los fines de seguimiento, las mediciones de campo también proporcionan una idea clara de la correlación entre el espacio en memoria y el rendimiento de la aplicación. A diferencia 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.

Medición de la memoria a gran escala

Cómo identificar un problema de memoria con el cronograma de Herramientas para desarrolladores

El primer paso para resolver cualquier problema de rendimiento es demostrar 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 sabrás cuánto mejoraste el rendimiento.

El panel Timeline de DevTools es un candidato ideal para demostrar que el problema existe. Ofrece una descripción general completa de dónde se dedica el tiempo cuando se carga la app o página web, y se interactúa con ella. 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 elementos no utilizados y el repintado, se trazan en un cronograma. Para investigar problemas de memoria, el panel Timeline también tiene un modo Memory que hace un seguimiento de la memoria total asignada, la cantidad de nodos del DOM, la cantidad de objetos de ventana y la cantidad de objetos de escucha de eventos asignados.

Demostrar que existe un problema

Comienza por identificar una secuencia de acciones que sospeches que está filtrando memoria. Comienza a grabar el cronograma y realiza la secuencia de acciones. Usa el botón de papelera que se encuentra 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 diente de sierra, 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 con el que comenzaste, tienes un buen motivo para sospechar que hay una fuga.

Gráfico con forma de diente de sierra

Una vez que confirmes que el problema existe, puedes obtener ayuda para identificar su origen con el Generador de perfiles del montón de Herramientas para desarrolladores.

Cómo encontrar fugas de memoria con el generador de perfiles del montón de las Herramientas para desarrolladores

El panel Generador de perfiles proporciona un generador de perfiles de CPU y un generador de perfiles del montón. La generación de perfiles del montón funciona tomando una instantánea del gráfico de objetos. Antes de que se tome una instantánea, se recolecta la basura de las generaciones joven y antigua. En otras palabras, solo verás los valores que estaban activos cuando se tomó la instantánea.

Hay demasiadas funciones en el generador de perfiles del montón como para cubrirlas de forma suficiente en este artículo, pero puedes encontrar documentación detallada en el sitio de Chrome para desarrolladores. 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 las instantáneas del generador de perfiles de montón con la actualización y el seguimiento incrementales del panel Timeline. Abre el panel Profiles, inicia un perfil de asignaciones de montón, realiza una secuencia de acciones y, luego, detén la grabación para el análisis. El generador de perfiles de asignación toma instantáneas del montón de forma periódica durante la grabación (con una frecuencia de hasta 50 ms) y una instantánea final al final de la grabación.

Generador de perfiles de asignación de montón

Las barras de la parte superior indican cuándo se encuentran objetos nuevos 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 del montón final: las barras azules indican los objetos que aún están activos al final de la línea de tiempo, y las barras grises indican los objetos que se asignaron durante la línea de tiempo, pero que se recolectaron como elementos no utilizados.

En el ejemplo anterior, se realizó una acción 10 veces. El programa de ejemplo almacena en caché cinco objetos, por lo que se esperan las últimas cinco barras azules. Sin embargo, la barra azul que se encuentra más a la izquierda indica un problema potencial. Luego, puedes usar los controles deslizantes de la línea de tiempo de arriba para acercar esa instantánea 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 instantánea del montón. Examinar la ruta de retención al objeto debería darte suficiente información para comprender por qué no se recopiló el objeto, y puedes realizar los cambios necesarios en el código para quitar la referencia innecesaria.

Cómo resolver la crisis de memoria de Gmail

Con las herramientas y técnicas que se mencionaron anteriormente, el equipo de Gmail pudo identificar algunas categorías de errores: cachés sin límites, arrays de devoluciones de llamada que crecen de forma infinita mientras esperan que ocurra algo que nunca sucede y objetos de escucha de eventos que retienen sus objetivos de forma involuntaria. Cuando se corrigieron estos problemas, el uso de memoria general de Gmail se redujo de forma significativa. Los usuarios del percentil 99 usaron un 80% menos de memoria que antes, y el consumo de memoria de los usuarios promedio disminuyó casi un 50%.

Uso de memoria de Gmail

Como Gmail usaba menos memoria, se redujo la latencia de pausa de GC, lo que mejoró la experiencia general del usuario.

También cabe destacar que, con las estadísticas sobre el uso de memoria que recopiló el equipo de Gmail, se pudieron descubrir regresiones de recolección de elementos no utilizados dentro de Chrome. Específicamente, se descubrieron dos errores de fragmentación cuando los datos de memoria de Gmail comenzaron a mostrar un aumento drástico en la brecha entre la asignación de memoria total y la memoria activa.

Llamado a la acción

Hazte las siguientes preguntas:

  1. ¿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 impacto negativo neto en el rendimiento general de la aplicación. Es difícil saber exactamente cuál es el número correcto, pero asegúrate de verificar que cualquier almacenamiento en caché adicional que use tu página tenga un impacto medible en el rendimiento.
  2. ¿Mi página no tiene fugas? Si tu página tiene fugas de memoria, no solo puede afectar su rendimiento, sino también el de otras pestañas. Usa el seguimiento de objetos para ayudar a identificar las fugas.
  3. ¿Con qué frecuencia se realiza la GC en mi página? Puedes ver cualquier pausa de GC con el panel de cronograma en las herramientas para desarrolladores de Chrome. Si tu página realiza la GC con frecuencia, es probable que estés asignando memoria con demasiada frecuencia, lo que agota la memoria de la generación joven.

Conclusión

Comenzamos en medio de una crisis. Se abarcaron los aspectos básicos de la administración de la memoria en JavaScript y V8 en particular. Aprendiste a usar las herramientas, incluida la nueva función de seguimiento de objetos disponible en las compilaciones más recientes de Chrome. Con esta información, el equipo de Gmail resolvió el problema de uso de memoria y observó un mejor rendimiento. Puedes hacer lo mismo con tus apps web.