Qué Debemos Recordar Sobre la Pérdida de Memoria (Memory Leaks)

Introducción: Pérdidas de Memoria en Java y .NET? Y el Garbage Collector? Funca mal? 

Memory Leak Se  suponía que esta generación de lenguajes dentro del rango conocido como código administrado (managed code) iba a terminar con el dolor de cabeza que habíamos padecido los que -sea en Pascal como en C o C++ o cualquier otro lenguaje que permitía referenciar memoria a través de punteros (pointers)- tuvimos alguna vez que lidiar con manejo dinámico de memoria (esto es, pedirle memoria al sistema operativo y devolvérsela luego)

Sin embargo me decido a escribir este post como respuesta a varias preguntas que he venido recibiendo de amigos que trabajan, sea con Java o con .NET, y que están padeciendo un problema que ellos pensaban que desde el vamos iba a estar superado

Partamos por la definición: una pérdida de memoria (en inglés aparece como memory leak o fuga de memoria) es una situación que se produce cuando la aplicación no devuelve al sistema operativo franjas de memoria que ya tampoco va a usar. Generalmente con el agravante de que siendo que los datos en esa memoria son ya residuales, de necesitar espacio para nuevos datos, la aplicación va a pedir más memoria al sistema operativo al no considerar que tiene total o parcialmente espacio para alojar esta información

Las pérdidas de memoria normalmente son procedurales. Es decir, la aplicación en la forma en que está programada, pierde memoria en forma sistemática de manera tal que nueva memoria solicitada al sistema operativo es también pasible de ser total o parcialmente perdida. De esta manera, dependiendo de la memoria original disponible, de la ocupada por procesos concurrentes, etc, siempre me alucino con la idea de que en algún momento el sistema operativo me saque una caja de diálogo como la siguiente

Sin memoria

La caja de diálogo de recién es una broma, claro. En cambio, lo que sí los sistemas operativos suelen hacer es asignar quantums de memoria a las diferentes aplicaciones, y aquellas que pasan cierto umbral de memoria requerida en un cierto tiempo son pasibles de ser penalizados por el sistema operativo, que puede comenzar por relegarlos a swappear ("intercambiar", del inglés swapping) su memoria entre almacenamientos primario y secundario, siguiendo por bajarles la prioridad de ejecución -para no pagar el mismo sistema operativo la penalización por swapping, ya que ese tiempo de intercambio se considera tiempo de ejecución del sistema operativo, no de la aplicación- hasta finalmente decirle a la aplicación su merecido "no va másssssss" como en la ruleta

Esto último, los que programaron en Java lo recibieron en la forma de un java.lang.OutOfMemoryError, parecido al System.OutOfMemoryException de .NET -quiero destacar la riqueza expresiva de Java, que reconoce a los errores (java.lang.Error) como una categoría separada de las excepciones (java.lang.Exception); una excepción es eventualmente atajable (catch) en cambio un error implica un fallo fatal y la ejecución finaliza allí. Tal distinción no tiene paralelo en .NET-

Ahora bien, las plataformas Java y .NET son entornos de ejecución administrados que cuentan con un recolector de basura (garbage collector). La idea del mismo -las implementaciones difieren algo- es detectar direcciones de memoria inalcanzables (por ejemplo, una variable local que se creo dentro de un bucle cuya ejecución ya quedó atrás; la variable es local al bucle y por consiguiente ya no hay forma de alcanzarla, ni siquiera definiendo otra variable con el mismo nombre o, si acaso, volviendo a ejecutar el bucle). A grosso modo, el recolector devolvería al sistema operativo esa memoria que venía siendo referenciada por variables que se quedaron fuera de scope aunque, en la práctica, a quien se la devuelve es a su propio montículo (heap) de memoria por si más adelante la fuera a necesitar. A su vez, sí, el montículo de la aplicación puede crecer o decrecer (pidiendo y liberando memoria al sistema operativo) pero en otros intervalos que los de la frecuencia del recolector

Con esto dicho, el programador entonces no se entera de cómo la memoria es adquirida o devuelta por lo que si la aplicación toma ese recurso y luego no lo devuelve, todas las miradas se dirigen al entorno administrado (Java VIrtual Machine o JVM en la plataforma Java, Common Language Runtime o CLR en el caso de .NET). Y sin embargo, no: rebobinemos un poco la cinta hacia el párrafo anterior. Allí decía "la idea del recolector de basura es detectar direcciones de memoria inalcanzables"

Ahí está el quid de la cuestión. En la medida en que instancias de objetos creadas sean alcanzables. Si los métodos que crearon estas instancias han devuelto referencias a las mismas, y a su vez dichas referencias fueron almacenadas como propiedades de otros objetos que son alcanzables, por transitividad las instancias creadas son también alcanzables. Ergo, si nuestras aplicaciones empiezan a dragar memoria en forma descontrolada, debo ser el portavoz de la mala noticia de que no vamos a poder irnos silbando bajito tan fácil Acá debo defender al gremio de los grandes distribuidores de plataforma: no es una falencia de la JVM o del CLR el no detectar que esta memoria de la que hablamos se debía haber liberado. Estos entornos de ejecución operan en una forma deterministica: es alcanzable, no se libera; no es alcanzable, se libera. Sin vueltas. El resto depende de nos, desarrolladores de software

Escribo esto y me viene a la memoria la típica reacción que he visto en desarrolladores cuando sienten que la aplicación no libera memoria en la proporción que esperarían: forzar la llamada al recolector de basura. Esto al menos en la plataforma .NET no va a sino empeorar las cosas, como voy a mostrar a continuación

 

Cómo Trabaja el Recolector de Basura

La memoria de una aplicación .NET está organizada en varios segmentos. Uno de ellos, particularmente, es el llamado "segmento de memoria administrada" que es aquella que es reservada y liberada directamente por el CLR (en contraposición a otros segmentos, como el de "memoria primitiva" o "no administrada" que es el segmento de la memoria que el mismo CLR reserva para sí -la que él necesita para ejecutar nuestra y otras aplicaciones- y también aquella memoria que nuestra aplicación reserva en forma directa, por ejemplo en C++ o C#, mediante llamadas explícitas al sistema operativo). Pero volvamos al que nos interesa, el segmento de memoria administrada:

En el mismo, a su vez, el montículo se organiza en generaciones. Esto es, por diseño, el CLR asume que si un objeto sobrevivió un determinado período de tiempo, entonces es probable que sobreviva por algún tiempo más. De esta manera, cualquier nuevo objeto que es creado se dice que pertenece a la generación 0, cuyo tiempo de vida se presume limitado. Entonces en un dado momento el CLR se va a encontrar con que el montículo no tiene memoria suficiente contigua para el siguiente comando new. Como consecuencia el hilo del recolector de basura se va a despertar y va a buscar en la generación 0 aquellos objetos que ya no sean alcanzables por la aplicación (variables fuera de scope, etc, lo que te contaba antes). La memoria ocupada por esos objetos no alcanzables es desasignada y todo el segmento de esta generación es compactado. Pero los sobrevivientes son inmediatamente movidos al segmento de la generación 1, por esto que te decía que el CLR presume que si zafaron es porque van a vivir un tiempo más. La generación 1 es también recolectada, pero sólo si tratar de liberar memoria en la generación 0 no produjo lo suficiente. Aún así, cuando haya recolección en la generación 1, la memoria de objetos no alcanzables será -nuevamente- desasignada y los que sobrevivan se asumirá que pueden llegar a durar un tiempo más por lo que serán movidos a la generación 2. Podríamos decir "y así sucesivamente" pero no: no hay más generaciones que la 0, la 1 y la 2. La generación 2 tiene la frecuencia de recolección más baja (sólo si recolectar en las generaciones previas no consiguió lo suficiente) y para los objetos que zafan, en esta generación, su memoria es compactada

Si nuestra aplicación está perdiendo memoria como consecuencia de no soltar todas las referencias a objetos que ya no van a usarse, forzar la llamada al recolector no va a sino agudizar el problema: por un lado, no va a recuperar la memoria de objetos indebidamente alcanzables por lo que la falencia original persistirá; pero aparte va a acortar cambios generacionales innecesaria e improductivamente, con impacto -claro- en el rendmiento general. A esto se le ha dado el curioso nombre de "crisis de los 40" (midlife crisis) o como la canción de Jethro Tull, "demasiado viejo para el rock, demasiado joven para morir". El tema es que la llamada prematura hace que objetos que en cualquier otra circunstancia no hubieran salido de la generación 0, acá terminan en la 1 o la 2 ya que la alta frecuencia de intervención del recolector los soprende todavía "alcanzables" (por ende, por lo que contaba antes, pasan a la generación siguiente). Mover objetos entre generaciones es caro: esos ciclos de CPU los podrían aprovechar hilos efectivos de la aplicación. No debemos olvidar acá que no es sólo cuestión de copiar bytes entre segmentos sino también compactar a los mismos. Todo eso es CPU sólo para una tarea administrativa

Llamar al recolector en forma explícita mediante GC.Collect() va a correr en el mismo hilo de ejecución en que se estaba ejecutando el método que contiene esta llamada. Por consiguiente, la instrucción siguiente al pedido de recolección no va a ser ejecutada hasta que la recolección finalice

 

 

Y hay algo aún peor que la "crisis de los 40", en lo que a forzar recolección en escenarios de pérdida se trata: en la medida en que no haya mucho para recolectar, el recolector igual se tuvo que barrer todos los objetos para conocer cuántas referencias alcanzables por dominios de aplicación tenían

El algoritmo no es trivial: no es cuestión de tener una tabla con referencias porque puede pasar que tengamos una serie de objetos que se referencian circularmente, pero que ese circuito no sea alcanzable; sin embargo cada elemento del grafo tiene al menos alguna referencia. Detectar tales circuitos es parte del cuento (y todo es, nuevamente, CPU)

 

 

Veamos todo esto en la práctica. Acá tengo la siguiente aplicación: por un lado tenemos una clase que vamos a invocar desde el resto de la aplicación (llamemosla, por ende, Invocado). Invocado se define como sigue

    class Invocado
    {
// contador de invocaciones a lo largo de la vida de la instancia
int cantLlamadas = 0; public string[] Invocar() { string[] arregloARetornar = new string[1000]; // se actualiza el contador de invocaciones ++cantLlamadas; for (int i = 0; i < arregloARetornar.Length; i++) { arregloARetornar[i] = "Iteración "+ cantLlamadas+ "; Elemento "+ i; } return arregloARetornar; } }

Basicamente, tiene un método, Invocado.Invocar(), que crea un arreglo básico de mil cadenas de caracteres y las inicializa a todas con un texto unívoco (conteniendo la invocación general al método y el número de string creado). Este arreglo con todos sus elementos, es devuelto al llamador

Paremos la película acá: el arreglo creado, con todos sus elementos, es devuelto al llamador

Lo que el llamador recibe, en verdad, es una referencia al arreglo. Si el llamador, luego de recibirla y procesarla, se deshace de esa referencia, tenemos la plena seguridad de que ese arreglo con todos sus elementos va a quedar disponible para que el recolector de basura lo levante (y, como conté anteriormente, eso va a pasar en la medida en que el recolector pase por la generación en que este objeto ya no referenciado se encuentre: si liberó memoria suficiente en generaciones anteriores, este objeto amigo va a sobrevivir)

 

La clase que sigue es la que va a llamar a la que vimos recién. Por consiguiente la voy a llamar Invocador. La misma tiene un método, Invocador.Comenzar(), que va a gatillar la creación de una instancia de Invocado, cuyo método Invocado.Invocar() va a ser llamado cien veces

    class Invocador
    { 
        List<String[]> listaResultados = new List<string[]>();

        public void Comenzar()
        { 
            Invocado invocado = new Invocado();
string[] resultados; for (int i = 0; i < 100; i++) { #if RECOLECTANDO GC.Collect(); #endif resultados = invocado.Invocar(); // El arreglo de strings (con todas sus referencias), se enlista // (de modo que no serán elegibles para recolección) listaResultados.Add(resultados); // Recorremos el arreglo resultante haciendo "algo" con sus // elementos (en este caso, mostrarlos por consola) for (int j = 0; j < resultados.Length; j++) { Console.WriteLine(resultados[j]); } } } }

Lo que es interesante de apreciar en este ejercicio es que Invocador habilita compilación condicional según que queramos forzar recolección de basura. El símbolo condicional es RECOLECTANDO

Si te fijás en el código, más arriba, por un "presunto" error de diseño las llamadas a Invocado.Invocar() se van acumulando en listaResultados. Cada elemento de listaResultados es un vector de 1000 string por lo cual, mientras listaResultados sea accesible, sus 100 string[] son accesibles y, por cada uno de estos 100 string[], sus 1000 string son accesibles. En otras palabras, como listaResultados va a ser alcanzable durante casi toda la ejecución, sumemos las referencias a 100 string[] (vamos 101 referencias) y 100.000 (cien mil) string. Total: 100.101 (cien mil ciento un) referencias visibles simultáneamente para cuando la aplicación termine

 

Pues bien, en lo que sigue vamos a comparar dos corridas: ambas perdiendo memoria pero una de ellas dejando que la recolección se dé espontaneamente en tanto que la otra la va a forzar en cada iteración. Para ello me valí de una facilidad de Windows que es el tradicional Monitor de Rendimiento (Performance Monitor). Si no estás familiarizado con el mismo, dejame contarte que es una herramienta súper útil para ver qué pasa con la salud de tu aplicación. Vale 0 (cero, o huevo como dirían en Chile). Es parte del sistema operativo y no sólo tiene varios grupos de parámetros a monitorear sino que además te permite monitorear equipos remotos! Como si fuera poco, vos mismo podés definir tus propios paquetes de parámetros monitoreables. Pero veamos ahora qué pasó con nuestra aplicación, comparando recolección por defecto versus recolección forzada:

 

# Bytes in all Heaps

# Bytes in all Heaps

Tamaño del montículo de memoria administrada a lo largo de la ejecución (# Bytes in all Heaps)
A la izquierda, sin forzar recolección. A la derecha, forzando recolección (hacé click en las imágenes para verlas mejor)

A la izquiera tenemos el perfil de ejecución que no fuerza la recolección. Curiosamente, y esto no es que pase siempre sino en este particular caso donde la aplicación pierde memoria, forzar la recolección no hace sino que la aplicación consuma más memoria. Re loco, no? Puede llegar a llevar a la errónea conclusión de que forzar la llamada al recolector hace que siempre una aplicación consuma más pero tampoco es que sea así: esta aplicación, lo sabemos, pierde memoria en la medida en que mantiene "vivas" referencias a objetos no utilizados. Ergo, llamar al recolector para que se haga cargo de una situación en la que poco puede intervenir es como llamar al pintor para que nos pinte una pared que se arruinó por la humedad, sin hacer ningún tratamiento a este último problema

Llamar al recolector explícitamente no es gratis: si vos lo llamás, claro está, el tipo va a venir y su hilo va a competir por la CPU contra el resto de los hilos del proceso (y, a su vez, el proceso de esta aplicación respecto de sus pares). En el gráfico que viene vas a ver cómo esto se refleja

 

% Time in GC

% Time in GC

Porcentaje de uso de CPU que se lleva el recolector de basura por sobre el total del CLR (% Time in GC)
A la izquierda, sin forzar recolección. A la derecha, forzando recolección (hacé click en las imágenes para verlas mejor)

Si te fijás en el gráfico de la derecha, es decir forzando recolección, vas a ver que el share que el recolector se come en la pizza es incrementalmente mayor (el eje horizontal es el tiempo). La explicación de esa participación incremental es que el pobre recolector se tiene que recorrer un número incremental de elementos (aquellos que hacia el final van a totalizar 100.101) sólo para darse cuenta que no hay nada que hacer con ellos porque son alcanzables

Si miramos, en cambio, qué pasa sin forzar recolección, la intervención del recolector se mantiene moderadamente baja, excepto un peek que se produce casi al final. Y esto se puede ver mejor si contamos, para cada generación, cuántas recolecciones efectivas se llegaron a hacer en las mismas

 

# Gen x Collections

# Gen x Collections

Cantidad acumulativa de recolecciones en las tres generaciones (# Gen x Collections)
A la izquierda, sin forzar recolección. A la derecha, forzando recolección (hacé click en las imágenes para verlas mejor)

En la ejecución normal tenemos en total 22 recolecciones de la generación 0, 4 recolecciones de la generación 1 y 2 recolecciones de la generación 2. Forzando recolección de memoria, tenemos 100 recolecciones en las generaciones 0, 1 y 2. Acá está la famosa crisis de los 40 de la que te hablé anteriormente (producto de andar jorobando con recolecciones que hacen que cualquier sobreviviente pase a la generación siguiente, con el consecuente overhead de cambio generacional). Para percibirlo en mejor detalle, te propongo que analicemos los tamaños de los segmentos a lo largo de la ejecución

 

Gen 0 heap size

Gen 0 heap size

Tamaño del montículo de la Generación 0 (Gen 0 heap size)
A la izquierda, sin forzar recolección. A la derecha, forzando recolección (hacé click en las imágenes para verlas mejor)

En el caso de la izquierda, sin forzar recolección, tenemos alrededor de 5,5 megas en la generación 0. Esto te da cierta pauta de cuánto llega a medir el montículo de esa generación (un poco más de 6M en este caso). Cuando el montículo creció algo más allá, se quedó sin espacio y el recolector enderezó las cosas mandando sobrevivientes a la generación 1 (y eso, como te contaba antes, pasó 22 veces)

En cambio, forzando recolección, la generación 0 se mantuvo dentro del 1,5M lo cual -ahora que vimos que el montículo tenía espacio para seguir creciendo arriba de los 6M- nos da la pauta de que lo hemos estado saneando cuando apenas llevaba una cuarta parte de su capacidad ocupada

Veamos, para entender esto mejor, cómo anduvieron las otras generaciones

 

Gen 1 heap size

Gen 1 heap size

Tamaño del montículo de la Generación 1 (Gen 1 heap size)
A la izquierda, sin forzar recolección. A la derecha, forzando recolección (hacé click en las imágenes para verlas mejor)
 

He aquí, señoras y señores, la crisis de los 40 reflejada: en la ejecución normal, el tamaño de ese montículo tuvo un gran ascenso hasta que devino su abrupta caída (creció hasta los 4,5M aproximadamente). En esa caída es donde el recolector mandó a la generación 2 a los sobrevivientes. En cambio, en el caso de la recolección forzada, se puede notar que la generación 1 siempre se mantuvo dentro de los 211,5K!! En otras palabras, los objetos entran e inmediatamente se van. Todo ese overhead, claro, se paga con CPU

 

Gen 2 heap size

Gen 2 heap size

Tamaño del montículo de la Generación 2 (Gen 2 heap size)
A la izquierda, sin forzar recolección. A la derecha, forzando recolección (hacé click en las imágenes para verlas mejor)

Acá aparece la madre del borrego: la generación 2, en la ejecución normal, viene piola dentro del 1,37M en la medida en que la generación 0 va creciendo, hasta que los sobrantes van pasando a la 1. No te olvides que mientras no se fuerce recolección, el recolector barre una generación primero y, de no haber conseguido memoria suficiente, pasa entonces a la generación siguiente (y así sucesivamente). Por ello la generación 2 pega un gran salto ya en la segunda mitad de la ejecución, cuando es evidente el arrastre de memoria perdida que viene sobreviviendo de las agotadas generaciones anteriores

En el caso forzado, este segmento viene creciendo lozanamente mientras los otros dos se mantienen por debajo de sus valores de ocupación plena. Al forzar la intervención del recolector de basura no hacemos más que mover bytes entre segmentos, para una ejecución que finalmente terminó consumiendo más. Qué tul?

 

En fin. Hay otros contadores más de memoria que no utilicé en este caso. Para una descripción total de los mismos te recomiendo que visites el documento "Contadores de Rendimiento de Memoria" en MSDN

Configurar el Monitor de Rendimiento es realmente fácil. En la figura de abajo te muestro un detalle al elegir para mi propia compu (<Local computer>, porque también podía haber elegido alguna de la red), algunos parámetros medibles de la categoría .NET CLR Memory, filtrando por la instancia del proyecto que tenía abierto en Visual Studio (MemoryLeakProducer.vshost)

Configurando el Monitor de Rendimiento

Agregando contadores en el Monitor de Rendimiendo para un proyecto Visual Studio 2008

Entonces, ahora que eximimos de culpa a la máquina virtual por las pérdidas de memoria y logramos volver a echarnos la culpa -como siempre- a nosotros mismos, veamos a nivel arquitectónico cómo debería usarse y desecharse la memoria

 

Ciclo de Vida de los Objetos

En esta era nuestra de la arquitectura de soluciones (solutions architecture), del desacoplamiento (decoupling) y de la separación de intereses (separation of concerns o SoC), es muy posible que el proceso de creación de objetos (junto con la inicialización de sus propiedades, que a su vez puede implicar el crear otros objetos) sea delegado a métodos fabricantes. Estrategias hoy para encapsular esos detalles hay varias, pero por mencionar algunas: fábricas concreta y abstracta e inyección de dependencias (yo mismo escribí hace dos años atrás un artículo sobre Inversión de Control que comentaba cómo fue evolucionando el desacoplamiento en la instanciación y el uso de componentes)

A simple vista, puede haber un impulso a considerar que la responsabilidad de desasignar objetos les cabe a quienes los crean (es decir, yendo a lo concreto, a las fábricas, inyectores de dependencias, etc). Pero a no equivocarse: como dice el refrán, la culpa no es del chancho sino de quien le da de comer. Si una determina clase C, en algún método M, manda a crear un objeto O a una fábrica F, empieza a verse como claro que es C es la que decide que el objeto sea creado (la fábrica F no lo fabricó espontaneamente sino respondiendo a C en su método M). Podríamos decir entonces que le va cabiendo más a C que a F, desprenderse del objeto

A la vez, C pudo haber mandado a F a crear el objeto para ponerlo al alcance de un invocador I (aquel que llamó al método M de C). Si en definitiva el objeto se lo va a quedar I, ya no es tan claro que C deba encargarse de desasignarlo (y mucho menos a F!! smile_omg)

En realidad, dado el siguiente escenario donde un invocador I llama a un método M de una clase C, en donde a su vez se va a pedir a una fábrica F que crée a un objeto O que va a ser retornado a C puede pasar que C haga con él lo siguiente:

  1. Lo guarda en una propiedad P de sí mismo (de C). En ese caso, que sea C quién decida más tarde sobre su destino
  2. Lo devuelve a un invocador I. Vayámonos olvidando de C y F respecto de ese objeto O. La cosa pasa por I o a su vez por quienes dieron intervención a I
  3. Lo asigna a una propiedad P de un argumento A recibido en la llamada a M. Similar al caso anterior: pasa ahora a ser A responsable de deshacerse en algún momento de la referencia (eventualmente cuando la misma A quede desreferenciada)
  4. Lo mantiene en una variable V local al método M. El objeto O va a ser recolectable cuando V pierda visibilidad (a más tardar, al finalizar M)
  5. Lo pasa como argumento a un método M’ de una clase C’. En tal caso, de guardar C’ una referencia a O, deberá encargarse luego de perderla

Como se puede ver, el problema del ciclo de vida de los objetos es bastante más complejo que la simplicidad de decir "que el que rompa, después arregle" smile_teeth. A cambio te propongo un enfoque de más alto nivel (por así decir, arquitectónico) para lidiar con este problema. La propuesta es segmentar las instancias de clases según su alcance. A saber:

  • Nivel de Aplicación. Aquellas instancias que, aunque no necesariamente se créen al levantar el proceso de la aplicación (te estoy hablando a nivel de sistema operativo), una vez que surgen, son necesarias hasta que la aplicación sea desmontada
  • Nivel de Sesión. Similar pero usando como timeframe (marco de tiempo) desde que un usuario se logonea hasta que el mismo sale (o su sesión expira). En los tiempos actuales de Arquitecturas Orientadas a Servicio (Service-Oriented Architecture o SOA), el sujeto de la sesión puede ser un usuario o bien un proceso remoto. Normalmente las instancias a nivel de sesión tienen un condicionante (constraint) de privacidad entre sesiones, de modo que sólo puedan ser accedidas por la sesión a la que pertenecen. Esto refuerza la idea de disponer de dichas instancias una vez que la sesión caduca
  • Nivel de Caso de Uso. Dentro de una sesión, a su vez, el usuario o proceso puede iniciar más de un proceso de negocio (comprar pasajes, contratar servicios, ver un resumen de movimientos, etc). Actualmente por cuestiones de experiencia de usuario (user experience o UX) es muy factible que las pantallas que componen el caso de uso se compongan de pasos (en modalidad wizard o asistente) donde el usuario va cargando parte de los datos, y los restantes -en pasos posteriores- van a ser dependientes de los primeros. Es frecuente en estos casos que instanciemos objetos sólo dentro de la vida del caso de uso: sea que el usuario finalice exitosamente o cancelando, los mismos ya no serán menester
  • Nivel de interacción (o actividad, en el caso de workflows). Todavía hilando algo más fino el nivel anterior, especialmente en aplicaciones web o al ejecutar actividades de workflows, existen objetos que se instancian sólo para ejecutar esa particular circunstancia. La vida de estos objetos suele ser muy corta y dependiendo de la carga de trabajo de los servidores que los ejecutan, a veces se suelen reusar para evitar el overhead de crear, liberar instancias mediante la inclusión de un pool de objetos. No obstante, apelar a este tipo de soluciones debería hacerse sólo luego de determinar que el CLR lleva demasiado tiempo creando y liberando objetos

Un caso extra, no comentado, son los workflows de ejecución prolongada (procesos cuyo lapso total de ejecución podría medirse en horas o días). Normalmente el estado de estos flujos que quedan pendientes de continuación son persistidos para poder ser retomados más tarde. Esa persistencia incluye instancias que van a ser requeridas al retomar (veamos al workflow algo similar al nivel de caso de uso). En la medida en que son persistidas, por ende, pueden ser liberadas

En la medida en que entandamos estos niveles, podemos organizar nuestras instancias para procurar que sean desasignadas según que el nivel al que pertenezcan caduque. A quién le cabe desasignar instancias? En cada nivel hay un objeto que lleva el control del mismo. Por ejemplo, en el nivel de sesión será un objeto que cumpla el rol de "administrador de sesiones" (que es quien acusa recibo de un usuario que se desloguea o que una sesión expiró). A ese objeto no le cabe directamente la desasignación de todos los objetos de ese nivel, pero sí el gatillarla

Algo muy piola, para cualquier nivel pero lo ejemplifico con el nivel de caso de uso, es que si todos los objetos de este nivel son alcanzables pura y exclusivamente desde un objeto general (creado para la ejecución del caso de uso), al deshacernos de este objeto general (dejar de referenciarlo) ya no habrá más forma de alcanzar al resto de las instancias del nivel. Si te fijás, a nivel de sesión, esta estrategia es la que usa ASP.NET con su objeto Session. Bueno, así es justamente como ASP.NET procura que no le queden instancias colgadas dentro del rango de una sesión si la sesión en que se crearon expiró

 

En una segunda derivada de este análisis, el control de instancias mediante la alineación a de las mismas al nivel correspondiente según su ciclo de vida como objeto se complementa con el control referencial. Tanto en Java como en .NET, una variable o propiedad de una clase declaradas de determinado tipo T no primitivo (es decir, no entero, flotante, booleano, etc) no guardan el contenido de ese tipo, sino una referencia (sí, exacto: un puntero a memoria) a otro objeto del tipo en cuestión

En nuestro ejemplo de arriba, la clase Invocador tiene un miembro llamado listaResultados de tipo List<string>. Pues bien, listaResultados no contiene tal lista sino que listaResultados es un puntero a otro objeto en memoria que sí contendra esa data. Las ventajas del código administrado (managed code) es que nosotros ni debemos concientizarnos de que son todos punteros: el entorno de ejecución administrado se encarga de todo eso

La realidad es que si declarásemos una variable v del mismo tipo que listaResultados y le asignásemos listaResultados, lo que se asigna es el puntero. Como consecuencia, no van a quedar dos copias en memoria sino que cualquier alteración al objeto de tipo List<string> refenciado por listaResultados, va a ser percibible accediento al objeto desde v (y viceversa). En otras palabras, listaResultados y v hacen referencia al mismo objeto

Si en un momento dado listaResultados desaparece (por ejemplo, porque su objeto contenedor Invocador fue levantado por el recolector de basura, el objeto de tipo  List<string> apuntado por listaResultados aún no es pasible de ser recolectado porque… al menos la variable v (si no otros punteros también) todavía lo están referenciando  

Control de referencias o control referencial, entonces, complementa al control de instancias en la medida que asegura que si un objeto O pertenece a determinado nivel N, cualquier referencia al objeto (sea una propiedad en una clase, sea una variable de programa, sea un argumento de un método aún en ejecución, o cualquier otro puntero) no va a durar más allá de ese nivel. De hacerlo, de trascender al nivel al que pertenece, el objeto O también va a trascender ese nivel y el recolector de basura no va a poder llevárselo en la medida en que aún esté siendo referenciado por otras variables u objetos alcanzables

 

 

 

Volviendo al caso de la aplicación que pierde memoria, veamos estos conceptos de control de instancias y control referencial en la práctica

Nuestra aplicación estaba innecesariamente, al enlistar resultados en listaResultados, otogando a los mismos "nivel de aplicación" ya que listaResultados es un miembro de la clase Invocador que se crea al iniciarse la ejecución y se desasigna al finalizar. Por ende, primera medida en este ejemplo, chau listaResultados

    class Invocador
    { 
        public void Comenzar()
        { 
            Invocado invocado = new Invocado();
string[] resultados; for (int i = 0; i < 100; i++) { resultados = invocado.Invocar(); // Recorremos el arreglo resultante haciendo "algo" con sus // elementos (en este caso, mostrarlos por consola) for (int j = 0; j < resultados.Length; j++) { Console.WriteLine(resultados[j]); } } } }

Optimización I – Aplicando Control de Instancias, la serie de resultados deja de tener nivel de aplicación

 

Ahora vamos a repasar resultados de la ejecución con esta primera optimización pero aún antes de seguir tenemos otra disponible: si te fijás, en el último loop (aquel que despliega por consola los resultados), una vez que ejecutamos una iteración j, el elemento de dicha iteración (resultados[j]) ya no va a ser accedido en el resto de este ejemplo. Aplicando control referencial, entonces, qué pasa si lo desasignamos "in situ"? Tan sólo se necesita asignarle null a dicha entrada para que deje de referencias al string como lo venía haciendo. Con esto, aunque éste es un ejemplo trivial, le estamos dando a cada entrada de resultados un nivel similar al de interacción (en el sentido de una vida resumida a la máxima brevedad posible)

Entonces, con esta nueva optimización, nos queda

    class Invocador
    { 
        public void Comenzar()
        { 
            Invocado invocado = new Invocado();
string[] resultados; for (int i = 0; i < 100; i++) { resultados = invocado.Invocar(); // Recorremos el arreglo resultante haciendo "algo" con sus // elementos (en este caso, mostrarlos por consola) for (int j = 0; j < resultados.Length; j++) { Console.WriteLine(resultados[j]); resultados[j] = null;
} } } }

Optimización II – Como consecuencia de un Control Referencial, cada entrada en resultados es desasignada cuando ya no se va a usar

 

Vamos a comparar las dos ejecuciones:

 

# Bytes in all Heaps

# Bytes in all Heaps

Tamaño del montículo de memoria administrada a lo largo de la ejecución (# Bytes in all Heaps)
A la izquierda, sólo aplicando Control de Instancias. A la derecha, Control de Instancias y además Referencial (hacé click en las imágenes para verlas mejor)

En primer lugar, tuve que cambiar el factor de escala para poder mostrar los gráficos. Si en las corridas del principio, perdiendo memoria, estaba hablando de un promedio de memoria reservada alrededor de los 2M (y un máximo en torno a los 9M), acá hablamos de un promedio inferior a los 200K (y un máximo debajo del mega)

 

% Time in GC

% Time in GC

Porcentaje de uso de CPU que se lleva el recolector de basura por sobre el total del CLR (% Time in GC)
A la izquierda, sólo aplicando Control de Instancias. A la derecha, Control de Instancias y además Referencial (hacé click en las imágenes para verlas mejor)

Un segundo beneficio que nos llevamos, al evitar pérdida de memoria mediante la correcta alineación de instancias de objetos a un nivel acorde a su ciclo de vida es que el recolector de basura no sólo va a hacer mejor su tarea de liberar memoria: la va a hacer más rápido!! La razón? Al encontrar instancias que sí deben liberarse, nuestro amigo va a evitar volver a toparse con ellas una y otra vez en sus futuras intervenciones. Nada de pasar a generación 1 o 2: arrivederci (como debe ser)

Vale la pena destacar que en el caso II (a la derecha), con la máxima optimización, el recolector intervino la mitad del tiempo que aplicando sólo la optimización I (izquierda)

 

# Gen x Collections

# Gen x Collections

Cantidad acumulativa de recolecciones en las tres generaciones (# Gen x Collections)
A la izquierda, sólo aplicando Control de Instancias. A la derecha, Control de Instancias y además Referencial (hacé click en las imágenes para verlas mejor)

Lo primero que quiero hacerte notar es que en ambos casos no llegó a haber recolección en la generación 2. En otras palabras, incluso la optimización I alcanzó para que las intervenciones del recolector sean efectivas en las generaciones bajas. Las recolecciones de la optimización I solamente se repartieron así: 21 en la generación 0 y 6 en la 1. Aplicando además la optimización II, en cambio, hubo 27 recolecciones en la generación 0 y 3 en la 1 (más allá de que el número de recolecciones es mayor, la duración de las mismas fue eficientemente bajo -como te contaba antes, la mitad que lo que demoró el caso que sólo aplicó optimización I-)

 

Gen x heap size

Gen x heap size

Tamaño de los montículos de la tres geeneraciones (Gen x heap size)
A la izquierda, sólo aplicando Control de Instancias. A la derecha, Control de Instancias y además Referencial (hacé click en las imágenes para verlas mejor)

Decidí optimizar aquí en un sólo gráfico las tres generaciones. A la izquierda (aplicando sólo optimización I) vemos que la generación 0 se mantiene alrededor de los 2,5M (con tres evidentes peek donde supera los 4M). Esto es menos de la mitad del tamaño que alcanzó en el esquema con pérdida de memoria. Pero la cosa es todavía mejor aplicando además la optimización II (a la derecha): entonces la generación 0 se mantuvo alrededor del mega. Con pequeños saltos hasta 1,5M. Sweet! heart

La generación 1 se mantuvo oscilando alrededor de los 240K para la optimización I y, menos aún, 135K si añadimos aparte la optimización II (en las gráficas, es una muy chata línea celeste que se ve casi al 0 del diagrama)

La generación 2, en ambos casos, se amesetó en los 118K y ya no bajó de allí por el hecho de que nunca llegó a haber recolección en la misma (en los diagramas es imperceptible). Pensá que en el ejemplo inicial, con pérdida de memoria, estuvo un buen rato dentro del 1,37M hasta que se disparaba a los 6M y más

 

Por sobre todo, creo haber dejado con estas dos optimizaciones, más que claro que enfrentar el problema de "la gotera" de memoria es definitivamente más efectivo que forzar la supérflua intervención del recolector de basura. Pasemos ahora a ver cómo se enfrenta el problema de "la gotera"

 

Detectando Leaks: el CLRProfiler

El ejemplo que hemos usado en esta aplicación es trivial y muy ad hoc para mostrar rápidamente cómo tocando algo cambia copernicanamente la situación. Pero en las aplicaciones que a vos y a mí nos tocan mantener, la cantidad de clases son infernales, y aún muchás más sus instancias de objetos por lo que más allá de los indicios que a priori puedas tener… la verdad es que podés estar días buscando la causa. Si tu aplicación está en producción hablar de "días" en lugar de "horas" para resolver un problema es un lujo que no te vas a poder dar

Para esto existe una herramienta que te permite perfilar el comportamiento del CLR durante la ejecución (memoria consumida, tiempo de vida según tipos de datos, etc). Me refiero al CLRProfiler. Esta facilidad, como el Monitor de Rendimiento, también está disponible en forma gratuita aunque en este caso no viene incluído como parte del sistema operativo sino que se descarga de la web de Microsoft (la URL es http://www.microsoft.com/downloads/details.aspx?FamilyID=a362781c-3870-43be-8926-862b40aa0cd0&DisplayLang=en)

Para el ejemplo de esta aplicación, perfilé la ejecución de la versión inicial, con pérdida de memoria, recibiendo estos resultados:

 

Histograma por edad

Un histograma según la "edad" de los objetos me muestra que un considerable número de megas ocupados durante lapsos que superan los 20, 30 y más segundos pertenece a objetos de la clase string (en rojo en el histograma)

Que más del 90% de los objetos creados correspondan a string ya nos puede dar indicios de por donde empezar el control de instancias. Que, aparte, varios de estos tengan una vida tan prolongada, nos da la pauta de que podríamos tener que aplicar optimización referencial

Entonces nos preguntamos, y estos string dónde es que se originan? Revisemos el grafo de asignación de memoria que nos tira CLRProfiler:

 

Grafo de alocación

Y acá siguen apareciendo elefantes: el método Invocado.Invocar() se lleva un 75.27%, esto es, las tres cuartas partes de la allocación total de memoria. Como vimos antes, eso no quiere decir que él deba allocar menos sino que es acá donde se origina el grueso. Pero luego nos resta ver a nosotros dónde se debería desasignar esa memoria. Por ahora, completemos lo que vinimos a buscar: en este caso, que indirectamente, Invocado.Invocar() termina generando la creación de string por un total equivalente al 42% de la memoria total. Estos string que, como veíamos, duraban en algunos casos arriba de los 30, 40 segundos

 

Entonces, con estos elementos, sí nos mandamos a la aplicación a revisar quiénes están llamando a Invocado.Invocar() y qué hacen con lo que este método devuelve. Para dejar constancia, luego de aplicar las dos optimizaciones que te mostré antes, basándome en la regla de los niveles según ciclo de vida, volví a perfilar la aplicación y el histograma me mostró lo siguiente

 

Histograma por edad

La mayoría de los string ahora muere en menos de 10 segundos (antes era todo lo contrario). Se ve?

Moraleja de este post: si una aplicación consume mucha memoria, no es cuestión de forzarla a que instancie menos objetos que los necesarios sino de asegurarse que estos sean recolectados cuando ya no se necesiten

 

 

Algo así como acordarse de sacar siempre la basura a las 8 de la noche Wink (o de la tarde, si estais en España, jo’er!). Estamos aplicando acá Pensamiento Lateral (Lateral Thinking) 

 

Bueno, me voy despidiendo acá pero te dejo algunas referencias interesantes para seguir explorando:

Esta entrada fue publicada en Software Architecture. Guarda el enlace permanente.

2 respuestas a Qué Debemos Recordar Sobre la Pérdida de Memoria (Memory Leaks)

  1. Juan Pablo dijo:

    Excelente post, un trabajo bien presentado hasto yo creo que lo entiendo😉

  2. Diego dijo:

    Gracias, Jotapé!!
     
    Oye, si tú pudiste entenderlo… No me lo explicarías a mí? Todavía le estoy dando vueltas   :’-(

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión / Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión / Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión / Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión / Cambiar )

Conectando a %s