De safari con Chrome: cazando leaks en Javascript

Ay, Javascript, Javascript. Que tiempos aquellos en que todo el uso que te daba era “fadeardivs, hacer un poco de AJAX o añadir algún que otro copo de nieve en fechas señaladas. La robustez de nuestra relación radicaba en las bajas expectativas mutuas. Yo no te veía como un lenguaje de programación, más bien como un juguete prescindible, y tú no me dabas ningún feedback útil, para qué molestarte si no iba a exprimirte el jugo. Pero los tiempos han cambiado, las modas se imponen y suceden, y lo de no tomarte en serio ya no va a ser posible. Y es que se acabó lo de ser la comparsa en una página web, esta es la era de la aplicación web, y las aplicaciones web, cada vez más grandes y complejas, son cosa tuya por derecho. Pero que duro está siendo, que duro…

She was a good dancer, but stepped on me all the time

En fin, basta de chorradas, y al tema. Para el frontend de Ducksboard hemos escrito una aplicación Javascript de un tamaño considerable. Digo aplicación, y no página o conjunto de páginas, porque tiene su importancia. En lo tocante al tema de este artículo, el hecho diferencial es que la aplicación se mantiene en memoria durante toda la sesión del usuario. O sea, que nunca se refresca la página actual o se cargan nuevas siguiendo enlaces. Toda la interacción se lleva a cabo sin abandonar nunca la (única) página, con su buen montón de objetos siguiendo sus ciclos de vida (cosas que se crean, se borran, se modifican, …) mientras el usuario tenga la app abierta, que pueden ser días o semanas. Así que, inevitablemente, toca hacer gestión de memoria de la de toda la vida, esto es… ¡evitar leaks!

Ya comenté hace unos meses que nuestra primera aproximación al frontend de Ducksboard fue totalmente desastrosa. Backbone, por suerte, nos ayudó a poner algo de orden en aquel embrollo. De hecho empezamos a usar Backbone relativamente pronto, tanto que durante meses aparecimos listados como ejemplo de uso en su homepage, allá por su versión 0.5. Backbone sumó mucho al proyecto, sí, pero ser un early adopter puede implicar (y suele hacerlo) comerte los problemas derivados de la inmadurez de un producto. En el caso de Backbone, en concreto, era relativamente fácil leakear memoria debido a la ausencia de mecanismos para gestionar el ciclo de vida de los objetos (en concreto de las vistas). Eso está resuelto en versiones recientes, y además hay mucho más know-how, pero cuando nosotros empezamos a usarlo simplemente la cosa estaba verde. Y nos mordió.

La situación es la siguiente. En Ducksboard (casi) todos los elementos que almacenamos en la base de datos tienen su correspondiente modelo de Backbone. Usuarios, dashboards, widgets, cuentas de servicios, etc. Todo elemento gráfico en la aplicación suele ser una vista de uno de esos modelos. En el caso de los widgets, por ejemplo, el widget en sí (los cuadraditos que puedes ver aquí) son una vista del modelo widget. La pantalla de configuración del widget, que es un modal, también es una vista. Y cada una de la subsecciones de ese modal (accesibles como tabs) es una vista a su vez. No es raro que además elementos individuales como inputs u otros sean vistas en sí mismos. En resumen, renderizamos cantidad de vistas de Backbone para construir la interfaz. En una sesión es sencillo que se rendericen cientos de ellas. Así que, si nuestro uso del viejo Backbone es proclive a perder memoria con cada nueva vista, ¿cuánto estamos leakeando en realidad?

Un modal de configuración de un widget. Son decenas de vistas anidadas. 

Antes de entrar en harina hay que entender esa tendencia de las vistas de Backbone a leakear. Las vistas generan los elementos de DOM que se incrustan en la página (a partir de alguna librería de templates normalmente), y registran callbacks para eventos de dichos elementos (como clicks, focuses y otros). Además es muy común que las vistas se suscriban a eventos de cambio de los modelos para actuar sobre el DOM y poder reflejarlos visualmente. De modo que las vistas están referenciadas en los callbacks de varios eventos (tanto de DOM como de modelos, y otros) en una glorificación del patrón observer de los buenos MVC. ¿Cómo se inician los dichosos leaks? Pues bien, aunque en las versiones actuales de Backbone ya está resuelto, en las antiguas no había un método que limpiase todas esas referencias a la vista capturadas en callbacks de eventos. De modo que aunque el elemento de DOM se borrase, y la vista dejase de usarse, los callbacks de los eventos seguían registrados, y con ellos la vista capturada, evitando su recolección por parte del garbage collector. And this is how you leak hard. Eso ahora lo tienen resuelto con una elegante inversión de control en el registro de eventos y un método de eliminación de la vista que hace limpieza. Pero eso es ahora, claro.

Batman wouldn’t be super without a toolbelt

Conocemos el problema, y podemos ir poniendo solución aplicando la fórmula de limpieza correcta de vistas para eliminar esas referencias. Pero, ¿cómo compruebo si los cambios están teniendo efecto o no? Para empezar, ¿estamos leakeando realmente o todo esto es una paja mental mía? Hay que medir. Y hay que medir con precisión, para poder confirmar si los pequeños cambios incrementales que vamos introduciendo cumplen su cometido. Las Dev Tools de Chrome incluyen un par de herramientas pensadas para estas ocasiones:

  • La Timeline muestra la evolución temporal del consumo de memoria del tab actual, así como del recuento de objetos y eventos del DOM existentes (también lista eventos del engine, los FPS de renderizado y otras lindezas, pero aquí no interesan). Además tiene un botoncito para forzar la ejecución del garbage collector.
  • El Profiler hace “fotos” del heap de Javascript, ofreciendo un listado extensivo de los objetos en memoria en el momento de tomar el snapshot. Para cada objeto listado en el informe puede seguirse su lista de referencias en forma de árbol (desde la ráiz del garbage collector hasta el objeto), de modo que puedes saber quién está conservando una referencia a ese objeto que querías ver recolectado.

Ambas herramientas son útiles, de hecho necesarias para entender qué diantre está pasando, pero no son sencillas de usar. La timeline no tiene mucha magia, va dibujando gráficas a medida que pasa el tiempo. Los números desconciertan a ratos (¡¿de dónde salen tantos miles de nodos en DOM?!) y el tab va medio raro con algún que otro cuelgue, pero en fin, no es “rocket science” que dicen aquellos. Pero el profiler… ahí sí hay para volverse loco. Los informes son enormes, con miles y miles de cosas ahí flotando, y en no pocas ocasiones los árboles de retención te harán flipar en colores y jurar en arameo. Paciencia, mucha paciencia. Si se mira fijamente durante el tiempo suficiente pues… pues sigue sin tener el menor sentido, pero es lo que hay xD. Aunque al final es de donde se sacan los datos jugosos.

Nuestra primera aproximación al uso de las Dev Tools fue francamente naif. Bendita ignorancia. La idea era cargar la aplicación, grabar el consumo de recursos con la timeline y comprobar con que acciones leakeábamos memoria. La primera en la frente fue averiguar que sin interactuar con la aplicación, simplemente dejándola a su rollo, el uso de memoria aumentaba igualmente. Pues mal vamos. Tratamos de ver por dónde se nos iban las fuerzas tirando del profiler y comparando dos snapshots tomados con varios minutos de diferencia. Imposible entender nada. La cantidad de objetos que deberían haber sido recolectados pero no lo habían sido era abrumadora. Y es que, tratar de perfilar toda la aplicación del tirón era poco menos que una locura. La clave para meter mano en el entuerto ha sido ir por partes: arreglando componentes de la aplicación por separado, no atacar el conjunto, demasiado grande.

As real as it gets

Así que vamos con un ejemplo de cómo he estudiado una funcionalidad concreta de la aplicación usando las herramientas. Vida real, ejemplos reales. Voy a estudiar exclusivamente el modal de configuración de un widget de Ducksboard, y nada más: el resto de aplicación la tengo al mínimo, nada creado, nada corriendo. Me he escrito una funcioncilla que abre el modal y lo cierra a toda velocidad las veces que le digas. Cuando acaba fuerza una recolección de basura (para poder forzar el garbage collector a mano hay que arrancar Chrome con –js-flags=”–expose-gc” y entonces tenemos disponible gc()). Veamos qué nos dice la timeline del consumo de resursos si hacemos esa apertura 500 veces.

Buf, esto es malo. Es malo porque aunque abra y cierre el modal, hay cantidad de nodos y de eventos que no se están borrando correctamente. El área azul claro de arriba es el consumo de memoria. La gráfica de líneas de abajo muestra nodos del DOM (en verde) y eventos registrados (en azul). ¿Veis los pequeños bajones de nodos de DOM, que corresponden con pequeños bajones del consumo de memoria? Eso es el garbage collector haciendo (o más bien tratanto de hacer) su trabajo. ¿Por qué es tan malo? Pues porque el consumo de memoria no deja de subir, hasta el infinito y más allá. Y no deja de hacerlo porque estamos leakeando nodos del DOM y eventos registrados. El garbage collector no es capaz de recolectar esos objetos, y se quedan ahí chupando memoria para siempre. ¿Para siempre de verdad? Probemos a ver qué pasa si en vez de abrir el modal 500 veces lo abro 3000, quizás Chrome tenga un tope de consumo de memoria y haga algo que no le he forzado a hacer todavía.

No, no caerá esa breva. El consumo de memoria sube y sube y sigue subiendo. Ya llevo 300 MB zampados y esto no baja ni a patadas. Mal, muy mal. Si tengo a un usuario usando la aplicación durante dos semanas en una pantalla de su oficina voy a dejar su PC sin memoria y la cosa acabará petando de un modo u otro, no sin antes degradar visiblemente su rendimiento. Manos a la obra pues. Vamos arreglando el tema de las vistas, recordemos:

  • usar la inversión de control de suscripción a eventos de Backbone (listenTo y stopListening, en vez de on y off)
  • llamar al método remove de cada vista cuando dejemos de usarlas (hace el stopListening correspondiente)
  • si una vista tiene sub-vistas, guardar un listado de todas ellas para poder llamar a sus correspondientes removes
  • evitar guardar referencias a vistas en los modelos, mejor crearlas, mostrarlas, borrarlas y olvidarlas cada vez

Con eso la cosa mejora de manera evidente, pero seguimos leakeando. Siguen existiendo referencias a nuestras vistas que las mantienen vivas en memoria. Pero, ¿quién las mantiene? Y ahí entra el profiler a escena, echando algo de luz sobre el asunto. Algo que me ha ayudado bastante a entender el output del profiler es marcar la opción Show objects’ hidden properties en la sección General de la ruedecita de settings (abajo a la derecha) del inspector de Chrome. Con eso los paths de retención acaban en objectos que de otro modo no era capaz de reconocer. Un buen artículo sobre cómo usar el profiler es este, y un buen consejo es tener desactivadas extensiones y no usar la consola mientras se hacen snapshots, porque también se van a listar en el informe. Mi manera de usar el profiler (y puede que no sea la correcta por lo laboriosa) consiste en:

  • abrir y cerrar mi famoso modal
  • solicitar un snapshot del heap
  • fijarme en los Detached DOM tree (elementos de DOM que ya no están incustrados en el DOM, pero siguen en memoria, esto es… ¡leakeados!)
  • clickando en los elementos contenidos en esas categorías, la ventana inferior nos muestra los famosos árboles de retención
  • y a tratar de entender qué pasa ahí…

Un par de ejemplos tras abrir y cerrar mi modal:

En el primero, una vista capturada por tenerla referenciada desde un modelo (en algún momento tener this.view = new View() en el modelo me pareció una buena idea, ahora ya no…). Esto se lee como “el objeto params que cuelga de window tiene una referencia a un array, que tiene una referencia a un objeto llamado slot, que a su vez tiene una a uno llamada widget, que a su vez tiene una a settings_view…” . Como resultado, hay un elemento de DOM (ese HTMLElement) que no puede ser recolectado, aunque ya no se esté mostrando en el DOM (está en un árbol de Detached DOM). Si mi modelo widget no tuviese esa referencia a settings_view, se hubiese liberado correctamente, así que ya sé cómo arreglarlo (nada de this.view).

El segundo es un ejemplo de los que más tiempo me han llevado arreglar, porque no los causa código mío, sino código de terceros, y eso ya es un show. En este caso Chosen (una librería para tener selects tuneados) está manteniendo una cadena de referencias que no permiten liberar un div. Con cada Chosen que creo y luego borro del DOM, se están leakeando elementos que él mismo ha creado, lo que es bastante desastroso. Os sorprendería saber la cantidad de librerías de las que usamos que he tenido que parchear de algún modo para que liberen correctamente los recursos. La mentalidad dominante es “¿por qué iba a preocuparme de liberar nada si al recargar la página se limpia todo?”, y ejemplos como el de Chosen los tengo con muchas y muchas otras. Es un auténtico coñazo, porque parchear código que no es tuyo es más complicado, pero es lo que toca si queremos que las cosas funcionen bien.

¿Y cómo de bien? Pues así de bien:

Esto es el mismo modal, abierto y cerrado las mismas 500 veces, pero tras los (muchos) fixes que he esbozado antes. Lo interesante es que ahora las cosas se comportan como queremos. El consumo de memoria (área horizontal y azul superior) tiene la clásica forma de sierra típica de un runtime con garbage collection. Cada vez que el collector entra en acción, se limpian los objetos ya “muertos” y la memoria usada baja (en este ejemplo el collector ataca cada 10 segundos). Como resultado el consumo máximo de memoria es fijo (no superamos los 47 MB). En cuanto a nodos del DOM, las cosas han mejorado drásticamente: todos los eventos creados con la apertura del modal se eliminan al cerrarlo (los picos azules), y los nodos van subiendo mientras el garbage collector descansa, pero vuelven a su número pre-modal en cuanto el collector reclama sus almas. Son esos triángulos verdes que llegan siempre a un mismo tope y luego vuelven a la base de golpe.

¡Todo pinta perfecto! ¿Que tal si volvemos a hacer la prueba de las 3000 aperturas? Si todo funciona bien, debería repetirse el mismo patrón que con 500:

Que belleza… Ha llevado su buen curro (de hecho ha sido un trabajo de chinos, desesperante no pocas veces), pero ahora sé que si un buen cristiano se lía a abrir 500, o 3000, o 30000 modals el consumo de memoria va a ser fijo y estable. Que sí, la memoria no es tan cara, pero tampoco es cuestión de zamparme toda la disponible para dibujar cuatro widgets…

What doesn’t kill you makes you smarter

A modo de resumen (que el post es largo y difuso, soy un maldito agente del caos), con la experiencia he aprendido un par de cosillas:

  • Los eventos de Javascript son geniales por su naturaleza asíncrona, pero multiplican las probabilidades de leaker objetos capturados en sus callbacks.
  • Al escoger una librería o  framework para ser el fundamento de tu aplicación, nunca está de más tener un conocimiento sólidos de sus más y sus menos, sobretodo de sus menos.
  • Con Backbone, en concreto, hay que asegurarse muy mucho de eliminar vistas como mandan los cánones, esto es, usando su método remove sí o sí.
  • Las Chrome Dev Tools son potentes, pero hay que dedicarles rato. Pueden llegar a ser frustrantes, pero la información necesaria está ahí. No conozco herramientas mejores para esta tarea en Javascript de browser hoy por hoy (agradeceré 1000 que alguien me ilumine en los comentarios :)
  • Tratar de perfilar una aplicación grande del tirón puede convertir la tarea en titánica y desanimar antes de empezar. Centrarse en pequeñas porciones de código lo hace más asequible, aunque sin duda consume más tiempo.
  • En no pocos casos la culpable será una librería de terceros que no tenga mecanismo de limpieza. Tocará mojarse y añadir lo que toque (y si se contribuye de vuelta al proyecto original, mejor que mejor).

Y hasta ahí lo que tenía que decir de Javascript y leaks por hoy. Para mis quejas constantes e infantiles sobre el lenguaje en sí, no dejes de seguirme en @aitorciki.

Advertisements

9 thoughts on “De safari con Chrome: cazando leaks en Javascript

  1. Excelente artículo as usual, lamento no poder “replicarte” algo al ser novato en javascript pero estos artículos escritos p’a tontos como yo nos ayuda mucho más a comprender los temas avanzados y complicados de JS

    Gracias!!!

    1. Pues no soy ningún experto en Javascript, ¡así que imagina lo que podrían contar los que sí lo son! Me alegra que te haya sido útil, era la idea.

  2. Muy buen articulo, Aitor! Tendre que ponerme a fondo con Chrome y intentar monitorizar diferentes elementos de nuestro proyecto, porque me huelo que tenemos los problemas aqui listados.

    Muchas gracias por compartir todo esto con nosotros. :-)

    Saludos!

    1. Ui, que mal me he explicado… créeme, es una experiencia desesperante y te hace envejecer del tirón xD.

      No hombre, me alegra que te haya parecido interesante Guillem ;)

  3. Hola Aitor,

    Vaya currada te has pegado, pero sin lugar a dudas ha merecido la pena, ahora tienes la memoria a raya :-)

    Nunca había probado el monitor de memoria de Chrome, al leer tu artículo me ha picado la curiosidad y he realizado una prueba en Opina. Es una app en la que también hay una sola página que nunca se recarga y todo gira en torno a eventos JS (en mi caso gestionados por ExtJS).

    El resultado tras unos minutos de uso aleatorio (abriendo y cerrando modales, navegando por las pestañas, creando preguntas, etc) ha sido este: http://i.imgur.com/WNH4Ukp.png

    Sinceramente, nunca nos hemos preocupado por el consumo de memoria durante el desarrollo, sin embargo para que no está mal. La memoria se libera correctamente en cada pasada del GC. Entiendo que, en gran medida, la magia aquí la pone ExtJS. El MVC que propone el framework te “empuja” ha no tener referencias a vistas en el modelo y él se encarga de avisar a los controladores cuando tienen que entrar en juego.

    Da gusto leer posts técnicos con rigurosidad y fundamento. A partir de ahora tendremos un ojo en nuestro consumo de memoria :-)
    Gracias por compartir todo este conocimiento!

    Saludos!

  4. Hola Aitor,

    Me ha encantado este post. Muestras que te has pegado una currada enorme para solucionar los memory leaks. Yo tengo alguna experiencia resolviendo esos problemas, pero en Java, para aplicaciones Swing, que también son unos agujeros negros en cuanto a consumo de memoria. Por eso mismo valoro es trabajo tan grande que has hecho con tu producto.

    Felicidades!

  5. Felicidades por este post tan bueno.
    He aprendido un par de cosillas que me han ido muy bien.
    Muchas gracias!

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s