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 reemplaza la administración eficaz de la memoria en aplicaciones. Las aplicaciones de JavaScript sufren los mismos problemas relacionados con la memoria que las aplicaciones nativas, como fugas y aumentos de memoria, pero también deben lidiar con pausas en la recolección de elementos no utilizados. Las aplicaciones de gran escala, como Gmail, tienen los mismos problemas que las aplicaciones más pequeñas. Continúa 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 tenía un problema grave. Las anécdotas de pestañas de Gmail que consumen muchos gigabytes de memoria en computadoras portátiles y de escritorio con recursos limitados se escuchaban cada vez con más frecuencia, a menudo con la conclusión de que todo el navegador no funcionaba. Historias de CPU fijas al 100%, apps que no responden y pestañas tristes de Chrome ("Él está muerto, Jim"). El equipo no sabía cómo comenzar a diagnosticar el problema, y mucho menos solucionarlo. No tenían idea de lo generalizado que era el problema y las herramientas disponibles no escalaban verticalmente a aplicaciones grandes. El equipo agrupó fuerzas con los equipos de Chrome y desarrollaron nuevas técnicas para clasificar problemas de memoria, mejoraron las herramientas existentes y permitieron la recopilación de datos de memoria desde el campo. Pero antes de abordar las herramientas, analizaremos los conceptos básicos de la administración de memoria en JavaScript.

Conceptos básicos de la administración de memoria

Para poder administrar con eficacia la memoria en JavaScript, debes comprender los aspectos básicos. En esta sección, se abordarán los tipos primitivos, el gráfico de objetos y se proporcionarán definiciones de sobredimensionamiento de memoria en general y fuga de memoria en JavaScript. La memoria en JavaScript se puede conceptualizar como un gráfico y, debido a esta teoría del grafo, desempeña un papel importante 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:

  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 son siempre hoja o nodos finales, lo que significa que nunca tienen un borde saliente.

Hay un solo tipo de contenedor: el objeto. En JavaScript, el objeto es un array asociativo. Un objeto que no está vacío es un nodo interno con bordes salientes a otros valores (nodos).

¿Qué sucede con los arrays?

En JavaScript, un array es, en realidad, 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: Una instancia de un tipo primitivo, objeto, 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.

Grafo de objetos

Todos los valores en JavaScript forman parte del gráfico de objetos. El gráfico comienza con raíces, por ejemplo, el objeto window. No puedes controlar la vida útil de las raíces del GC, ya que las crea el navegador y las destruye cuando se descarga la página. En realidad, las variables globales son propiedades en una ventana.

Gráfico de objetos

¿Cuándo un valor se convierte en basura?

Un valor se convierte en elemento no utilizado cuando no hay una ruta desde una raíz al valor. En otras palabras, si comienzas desde las raíces y buscas exhaustivamente todas las propiedades y variables de los objetos que están activas en el marco de la pila, no se puede alcanzar un valor, se convirtió en un elemento no utilizado.

Gráfico de elementos no utilizados

¿Qué es una fuga de memoria en JavaScript?

Una fuga de memoria en JavaScript se produce con mayor frecuencia 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 un objeto JavaScript hace referencia de todos modos. Si bien los navegadores modernos hacen que sea cada vez más difícil crear fugas de manera accidental, es más fácil de lo que parece. Supongamos que agregas un elemento al árbol del DOM de la siguiente manera:

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

Luego, quitarás 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 Bloat?

Tu página se aumenta cuando usas más memoria de la necesaria para obtener una velocidad óptima. Indirectamente, las fugas de memoria también causan sobredimensionamiento, pero esto no se debe a su diseño. Una caché de aplicación que no está vinculada a ningún tamaño es una fuente común de sobredimensionamiento de memoria. Además, la página puede verse sobrecargada por el uso de datos del host, como los datos de píxeles que se cargan a partir de imágenes.

¿Qué es la recolección de elementos no utilizados?

La recolección de elementos no utilizados es la forma en que se reclama la memoria en JavaScript. El navegador decide cuándo sucede esto. Durante una recopilación, se suspende toda la ejecución de secuencias de comandos en tu página mientras se descubren los valores en tiempo real a través de un recorrido del gráfico de objetos que comienza en las raíces del GC. Todos los valores a los que no se puede acceder se clasifican como elementos no utilizados. El administrador de memoria recupera la memoria para valores no utilizados.

Detalle del recolector de elementos no utilizados V8

Para comprender mejor cómo se lleva a cabo la recolección de elementos no utilizados, veamos en detalle el recolector de elementos no utilizados V8. V8 usa un recopilador generacional. La memoria se divide en dos generaciones: la joven y la mayor. La asignación y recopilación en la generación joven es rápida y frecuente. La asignación y la recopilación dentro de la generación anterior son más lentas y menos frecuentes.

Recopilador generacional

V8 usa un recopilador 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 se suele aproximar a la cantidad de colecciones de la generación joven que sobrevivió. Una vez que un valor es lo suficientemente antiguo, pasa a ser una generación anterior.

En la práctica, los valores recién asignados no duran mucho. Un estudio de los programas Smalltalk mostró que solo el 7% de los valores sobreviven después de una colección de la generación joven. En estudios similares en entornos de ejecución, se descubrió que, en promedio, entre el 90% y el 70% de los valores recién asignados nunca se consideran en la generación anterior.

Generación joven

El montón de la generación Young en V8 se divide en dos espacios, llamados de y a. La memoria se asigna desde el espacio. La asignación es muy rápida, hasta que el espacio al está completo. En ese momento, se activa una colección de la generación joven. La colección de la generación joven primero intercambia el del y al espacio, lo antiguo al espacio (ahora el del espacio) se escanea y todos los valores en vivo se copian en el espacio o lo tienen en la generación antigua. Una colección típica de jóvenes generaciones dura 10 milisegundos (ms).

De forma intuitiva, debes comprender que cada asignación que realiza tu aplicación te acerca al agotamiento del espacio y a la pausa de la recolección de elementos no utilizados. Los desarrolladores de juegos deben tomar nota: para garantizar una latencia de fotogramas de 16 ms (necesaria para alcanzar los 60 fotogramas por segundo), la aplicación no debe realizar asignaciones, porque una sola colección de la generación joven consumirá la mayor parte de esa latencia.

Montón de la generación joven

Generación anterior

El montón de generación anterior en V8 usa un algoritmo de marca compacto para la recopilación. Las asignaciones de generaciones antiguas ocurren cada vez que un valor tiene validez desde la generación joven hasta la generación anterior. Cuando se realiza una colección de una generación antigua, también se realiza una colección de una generación joven. Tu aplicación se pausará por unos segundos. En la práctica, esto es aceptable porque las colecciones de generaciones antiguas son poco frecuentes.

Resumen de recolección de elementos no utilizados V8

La administración automática de la memoria con recolección de elementos no utilizados es excelente para la productividad de los desarrolladores, pero, cada vez que asignas un valor, te acercas aún más a una pausa de recolección de elementos no utilizados. Las pausas de la recolección de elementos no utilizados pueden arruinar el funcionamiento de tu aplicación al introducir bloqueos. Ahora que entiendes cómo JavaScript administra la memoria, puedes tomar las decisiones correctas para tu aplicación.

Cómo reparar Gmail

Durante el año pasado, se incorporaron numerosas funciones y correcciones de errores a las Herramientas para desarrolladores de Chrome, lo que las convirtió en una herramienta más potente que nunca. Además, el propio navegador realizó un cambio clave en la API de performance.memory. Esto permite que Gmail y cualquier otra aplicación recopilen estadísticas de la memoria a partir del campo. Con estas increíbles herramientas, lo que antes parecía una tarea imposible pronto se convirtió en un emocionante juego para rastrear culpables.

Herramientas y técnicas

Datos de campo y API de 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 usuarios reales son muy valiosos. Esta información nos permite distinguir entre power users (aquellos que usan Gmail entre 8 y 16 horas al día y reciben cientos de mensajes por día) de usuarios más promedio que pasan unos minutos al día en Gmail y reciben alrededor de una docena de mensajes por semana.

Esta API muestra tres datos:

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

Un aspecto que se debe tener en cuenta es que la API devuelve valores de memoria durante todo el proceso de Chrome. Aunque no es el modo predeterminado, en algunas circunstancias, es posible que Chrome abra varias pestañas en el mismo proceso del procesador. 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.

Medición de la memoria a gran escala

Gmail instrumentó su JavaScript para usar la API de performance.memory para recopilar información sobre la memoria aproximadamente una vez cada 30 minutos. Debido a que muchos usuarios de Gmail dejan la aplicación activa durante varios 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 de uso de memoria. A los pocos días de instrumentar Gmail para recolectar información de memoria de una muestra aleatoria de usuarios, el equipo contaba con datos suficientes para comprender cuán generalizados estaban los problemas de memoria entre los usuarios promedio. Establecieron un modelo de referencia y usaron la transmisión de datos entrantes para hacer un seguimiento del progreso hacia el objetivo de reducir el consumo de memoria. En un momento dado, estos datos también se usarían para detectar cualquier regresión de memoria.

Más allá de los propósitos de seguimiento, las mediciones de campo también proporcionan estadísticas agudas sobre la correlación entre el uso de memoria y el rendimiento de la aplicación. Contrario a la creencia popular de que "más memoria da como resultado un mejor rendimiento", el equipo de Gmail descubrió que cuanto más grande era la memoria, más largas eran las latencias para las acciones comunes de Gmail. Armados 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

Identifica un problema de memoria con el cronograma de Herramientas para desarrolladores

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 manera confiable. Sin una medición de referencia, no sabes en qué medida has mejorado el rendimiento.

El panel Timeline de Herramientas para desarrolladores es un candidato ideal para demostrar la existencia del problema. Brinda una descripción general completa del tiempo que dedicas a cargar tu app o página web o a interactuar 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 la nueva pintura se trazan en un cronograma. Para investigar los problemas de memoria, el panel Timeline también tiene un modo de memoria que realiza un seguimiento de la memoria asignada total, la cantidad de nodos del DOM, la cantidad de objetos de la ventana y la cantidad de objetos de escucha de eventos asignados.

Probar la existencia de un problema

Comienza por identificar una secuencia de acciones que sospeches que tienen fugas de memoria. Comienza a grabar la línea de tiempo y realiza la secuencia de acciones. Usa el botón de la papelera en la parte inferior para forzar una recolección completa de elementos no utilizados. Si, después de algunas iteraciones, ves un gráfico con forma de diente de sierra, estás asignando muchos objetos que vivieron en breve. Sin embargo, si no se espera que la secuencia de acciones genere memoria retenida y el recuento de nodos del DOM no descienda a la línea de base donde comenzaste, tienes un buen motivo para sospechar que hay una fuga.

Gráfico con forma de sierra

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

Cómo encontrar fugas de memoria con el Generador de perfiles de montón de Herramientas para desarrolladores

El panel Profiler proporciona un generador de perfiles de CPU y uno de montón. La creación de perfiles del montón funciona tomando una instantánea del gráfico de objetos. Antes de que se tome una instantánea, tanto las generaciones jóvenes como las mayores son recolectados de elementos no utilizados. En otras palabras, solo verás los valores que estaban activos cuando se tomó la instantánea.

El Generador de perfiles del montón tiene demasiadas funciones como para cubrirlo lo suficiente en este artículo. Sin embargo, puedes encontrar documentación detallada en el sitio para desarrolladores de Chrome. Aquí nos enfocaremos en el generador de perfiles de asignación de montón.

Usa 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 del resumen de montón con la actualización y el seguimiento incrementales del panel Timeline. Abre el panel Profiles, inicia un perfil Record Heap Allocations, realiza una secuencia de acciones y, luego, detén el registro para analizarlo. El generador de perfiles de asignación toma instantáneas del montón de manera periódica durante el registro (con una frecuencia de hasta 50 ms) y una instantánea final al final del registro.

Generador de perfiles de asignación del montón

Las barras de la parte superior indican el momento en que 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 los objetos que aún están activos al final de la línea de tiempo, las barras grises indican los objetos que se asignaron durante la línea de tiempo, pero que se recolectaron desde entonces.

En el ejemplo anterior, una acción se realizó 10 veces. El programa de muestra almacena en caché cinco objetos, por lo que se esperan las últimas cinco barras azules. Pero la barra azul que se encuentra más a la izquierda indica un posible problema. A continuación, puedes usar los controles deslizantes del cronograma que aparece arriba para acercar esa instantánea en particular y ver los objetos que se asignaron recientemente en ese punto. Al hacer 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. Examinar la ruta de retención al objeto debería brindarte suficiente información para comprender por qué no se recopiló el objeto, y podrás realizar los cambios de código necesarios para quitar la referencia innecesaria.

Resolución de la crisis de memoria en Gmail

Con el uso de las herramientas y técnicas mencionadas anteriormente, el equipo de Gmail pudo identificar algunas categorías de errores: cachés no delimitadas, conjuntos de devoluciones de llamadas de aumento infinito que esperan a que suceda algo que nunca ocurre en realidad y objetos de escucha de eventos retengan sus objetivos de manera accidental. Al solucionar 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 la mediana de los usuarios se redujo casi en un 50%.

Uso de memoria de Gmail

Como Gmail usaba menos memoria, se redujo la latencia de pausa de la recolección de elementos no utilizados, lo que aumentó la experiencia general del usuario.

Cabe destacar también que, gracias a que el equipo de Gmail recopilaba estadísticas sobre el uso de la memoria, 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 la memoria de Gmail comenzaron a mostrar un drástico aumento en la brecha entre la memoria total asignada y la memoria activa.

Llamado a la acción

Hazte estas preguntas:

  1. ¿Cuánta memoria está usando mi app? Es posible que estés usando demasiada memoria, lo que, contrariamente a la creencia popular, tiene un impacto neto negativo en el rendimiento general de la aplicación. Es difícil saber exactamente cuál es la cifra correcta, pero asegúrate de verificar que el almacenamiento en caché adicional que esté usando tu página tenga un impacto medible en el rendimiento.
  2. ¿Mi página no tiene filtraciones? Las fugas de memoria en tu página no solo pueden afectar su rendimiento, sino también otras pestañas. Utilizar el rastreador de objetos para ayudar a limitar las fugas
  3. ¿Con qué frecuencia se realiza la recolección de elementos no utilizados de mi página? Puedes ver si se detiene la recolección de elementos no utilizados en el panel de cronograma en las herramientas para desarrolladores de Chrome. Si tu página se GC con frecuencia, es posible que estés asignando con demasiada frecuencia y revirtiendo a través de la memoria de la generación joven.

Conclusión

Comenzamos en 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 seguimiento de objetos disponible en las compilaciones más recientes de Chrome. El equipo de Gmail, equipado con estos conocimientos, resolvió su problema de uso de la memoria y observó un mejor rendimiento. Puedes hacer lo mismo con tus aplicaciones web.