Adiestramiento del Garbage Collector (GC) y contadores de rendimiento

Hace un tiempo posteé acerca del uso de liberación de memoria en el framework, post que podrás encontrar aquí, en donde mencionaba que el GC se auto adiestraba para funcionar eficientemente, y que por eso no es recomendable forzarlo a recolectar la memoria, sino que dejarlo a él que lo haga.


Hoy hablaremos de cómo se auto adiestra el GC para realizar recolecciones de memoria en una frecuencia medianamente determinada, con el fin de impactar lo menos posible el rendimiento de la aplicación.


Debemos tener en cuenta que el impacto de una recolección es importante y varía de forma directa de la generación sobre la cual se hace la recolección y de la modalidad de GC que esté configurada. Otro día hablaremos de las generaciones y de cómo se distribuyen en la memoria. Por ahora, consideremos que cada vez que se realiza una recolección de memoria, todos los threads que están ejecutando código manejado en la aplicación deben ser detenidos mientras dure el proceso, no así para los threads que ejecutan código no manejado, los cuales siguen trabajando. El bloqueo de threads no ocurre para todas las versiones del GC, sino que solamente para la versión Server y la modalidad no concurrente de la versión Workstation. La que no se ve afectada es la modalidad concurrente del GC en versión Workstation.


Como el costo de una recolección es importante, lo ideal es que no se hagan. Bueno, eso no es posible sin que la aplicación deje de funcionar, por lo tanto, hay que encontrar el punto intermedio. El encontrar esa periodicidad de limpieza es lo que se conoce como adiestramiento, es decir, como el GC se va adecuando a los requerimientos de memoria de tu aplicación, lo que se conoce en inglés como allocation pattern.


¿Cómo se auto enseña el GC?


Antes de llegar a eso, veamos cuales son los únicos motivos que fuerzan una recolección de memoria en una aplicación, al menos para el GC incluido en la versión 1.1 y 2.0 del framework. No estoy al tanto de cambios en las versiones 3.0 y 3.5 del framework, aunque tengo entendido que la 3.0 no tiene cambios sustanciales con respecto a la 2.0. La 3.5 es desconocida aún, para Abril de 2007.


Entonces, las recolecciones de memoria son gatilladas por cualquiera de estos tres eventos:



  1. Llamado a GC.Collect

  2. Falta de memoria en el sistema operativo

  3. Falta de memoria en la aplicación

Revisando cada punto:



  1. Según vimos en el post mencionado al comienzo del artículo, jamás debe llamarse a GC.Collect.

  2. Cuando el sistema operativo se está quedando sin memoria, le ordena a todas las aplicaciones .net que hagan recolecciones de todas las generaciones para liberar la mayor cantidad de memoria.

  3. Cuando digo falta de memoria en la aplicación, no me refiero a [OOM] sino que a al hecho de no haber espacio disponible en el bloque de memoria asignado a la generación 0 (que llamaremos de ahora en adelante M0), aunque aún haya mucha memoria disponible.

Descartando los dos primeros puntos ya que no estamos llamando a GC.Collect ni nuestro servidor está escaso de memoria, lo único que puede gatillar un llamado a GC.Collect, es que se cumpla el punto número 3.


Además, debemos considerar que casi todas las peticiones de memoria que hacemos en nuestra aplicación, serán resueltas con memoria del H0. Las que no siguen esta regla son las peticiones de memoria de grandes bloques (mayores a 85k).


¿Entonces?


Si se produce una recolección cada vez que se llena M0, la forma de acortar o alargar la frecuencia de recolecciones está “linealmente” relacionada con el tamaño del éste (M0). Uso comillas en linealmente ya que casi nunca una aplicación sigue siempre un mismo patrón de petición (allocation pattern).


El GC recolectará la memoria cada vez que se llene M0. Si el GC encuentra que está recolectando muchas veces en poco tiempo, lo que sabemos que impactará el rendimiento de tu aplicación, agrandará el tamaño de M0 y dejará de recolectar tan seguido.


De esta forma, el GC se va adiestrando y ajustando a la aplicación.


¿Se imaginan que ocurriría si los objetos de gran tamaño se pidiesen desde G0?


Se dispararían las llamadas a GC.Collect ya que el M0 se llenaría muy rápido.


Ejemplo


Veamos un ejemplo de una pequeñísima aplicación de consola en C# y analicemos cómo se comporta el GC con performance monitor.


El código de la aplicación de consola es el siguiente:





[STAThread]
static void Main(string[] args)
{
       System.Threading.Thread.Sleep(3000);
       for (int i = 0; i<1300;i++)
             Console.WriteLine (“Ejecución (” + i.ToString() + “) : ” + Consultar());
}
 

Y la función Consultar hace lo siguiente:





public static string Consultar()
{
       SqlClient.SqlConnection oConn = new SqlClient.SqlConnection(“…”);
       SqlClient.SqlCommand oComm = new SqlClient.SqlCommand(“select …”, oConn);
       oConn.Open();
       oComm.ExecuteScalar();
       oComm.Dispose();
       oConn.Dispose();
       string s = new string(‘c’, 2500);
       string g = string.Empty;
       g = s;
       g = g + s;
       System.Threading.Thread.Sleep(50);
       return string.Empty;
}
 

Estamos consumiendo memoria no administrada y administrada al mismo tiempo, además de realizar una pequeña concatenación de strings con el fin de molestar un poco al GC.


En performance monitor se puede observar el siguiente comportamiento.


Las líneas azules corresponden a todo lo que se realiza sobre la generación 0, las rojas sobre la generación 1 y las negras, como esperarán, para la generación 2. A su vez, las líneas gruesas corresponden a la cantidad de recolecciones de memoria, las delgadas al tamaño del heap y las punteadas (- – – – -) a la cantidad de memoria que se mueve de una generación a otra (sólo hay dos colores ya que no hay memoria que mover desde la generación 2).


Los contadores utilizados y su color, todos pertenecientes al objeto .Net CLR Memory de performance monitor,  son los siguientes:



  • # Gen 0 Collections

  • # Gen 1 Collections

  • # Gen 2 Collections

  • Gen 0 heap size

  • Gen 1 heap size

  • Gen 2 heap size

  • Promoted Memory from Gen 0, en línea – – – – – – –

  • Promoted Memory from Gen 1, en línea – – – – – – –






Inicialmente la aplicación comienza con heaps de 0 bytes para cada una de las generaciones. Esto se puede ver por las tres líneas delgadas de colores que parten desde la izquierda. Todas parten desde cero. (Click en la imagen para agrandar)



Ante la primera petición de memoria, y debido a que el GC no puede predecir cómo se realizarán los requerimientos de memoria en el futuro, éste solicita un tamaño ni muy chico ni muy grande para comenzar. Así, 2,7 MB de memoria son asignados al M0. Nada de espacio es asignado a M1 y M2 ya que en ellos no se hacen peticiones de memoria. Posterior a esa petición de memoria, se hacen 2 recolecciones muy seguidas, las que pueden verse en la imagen inferior, dentro del rectángulo verde. Las recolecciones se reflejan en la subida de la línea azul gruesa.







En conjunto con el aumento en las recolecciones, la línea punteada azul nos dice que se traspasó memoria de G0 a G1. Esto se ve reflejado en el aumento del tamaño de M1, representado por la línea roja delgada, dentro del rectángulo verde.



 


Nota: Debido a que la tasa más pequeña de refresco de performance monitor  es un segundo, como se producen 2 recolecciones casi seguidas, no es posible ver las variaciones con más detalle.


Nota: Técnicamente hablando, G1 y M1 son casi equivalentes entre ellos. G1 corresponde a los objetos (y su memoria) en generación 1 y M1 al bloque de memoria donde se alojan los objetos. Descartando los problemas generados por los objetos pinned, podemos decir que son equivalentes. Lo mismo para G2 y M2.


Recapitulando, tenemos un aumento en el tamaño de M1 (línea roja) debido a recolecciones de memoria que generaron un traspaso de memoria desde G0, que  es reflejado por la línea azul punteada. El GC ajusta el tamaño de M0 para encontrar esa frecuencia ideal de recolecciones.


La aplicación sigue funcionando, pero debido a que M0 es tan grande, el GC no necesita hacer recolecciones de memoria mientras quede espacio disponible en él. Después de varias peticiones de memoria, éste se ha llenado y es momento de hacer una nueva recolección.


Si miramos la siguiente imagen (click para agrandar), en el cuadro verde veremos que se produce una nueva recolección.








Analicemos los cambios por parte:



  • Debido a que no hay más espacio disponible en M0, se realiza una recolección, que se refleja en la línea gruesa azul

  • La cantidad de memoria traspasada de la G0 a la G1 (línea azul punteada) es igual al aumento del tamaño del M1 (línea roja delgada).

  • El tamaño de M0 se ajusta haciéndose más pequeño debido a que el GC determina que ocurrió mucho tiempo desde la última recolección y estima que deben realizarse más seguido.







La siguiente recolección es muy interesante. Miremos la imagen de la izquierda, la que incluiremos con zoom (click para agrandar), hecho por el programa Paint. Ya sé que está feo, pero sirve mucho. Nuevamente, lo interesante está en el rectángulo verde.


 


Analicemos los cambios uno a uno:



  • Debido a que no hay más espacio disponible en M0, se realiza una recolección, que se refleja en la línea gruesa azul

  • Debido a la recolección, la memoria que aún se está usando en G0, se traspasa a G1 pero ya no hay más espacio para crear un nuevo M0  y poder crear nuevas variables. Cuando esto ocurre, se repite el mismo proceso que se venía haciendo anteriormente en M0 pero ahora en M1. Hay que realizar una recolección en M1 para liberar espacio.

  • La recolección en M1 se ve reflejada por la crecida de la línea roja gruesa. Esta había estado en 0 todo el tiempo.

  • La cantidad de memoria traspasada de la G1 a la G2 (línea roja punteada) es igual al aumento del tamaño del M2 (línea negra delgada). En este caso, calzan perfectamente ambas líneas, obligando al performance monitor a hacer una línea extraña, una mezcla de rojo medio punteado y negro.

  • El tamaño de M0 se ajusta haciéndose más pequeño debido a que el GC estima que las recolecciones deben realizarse más seguido.

  • El tamaño de M1 se hace 0 ya que no hay memoria utilizada por objetos en G1 (se traspasó a G2)

  • El tamaño de M2 crece por primera vez. Ya hay objetos almacenados en la generación 2.

De ahora en adelante, el GC ha encontrado el tamaño ideal del M0 y todas las recolecciones se realizarán con una similar frecuencia. Tomen nota del tamaño de M0. La línea azul delgada no varía. Esto ocurre porque nuestra aplicación tiene un patrón de petición muy repetitivo. Veamos la siguiente imagen.



Si se fijan, al extremo derecho de la imagen anterior, se ve que hay una nueva recolección de G1. Veámoslo con el maravilloso zoom de paint.


Lo ocurrido aquí es muy similar a lo de antes:








  • Debido a que no hay más espacio disponible en M0, se realiza una recolección, que se refleja en la línea gruesa azul, que no se ve en esta imagen muy pequeña, pero si se ve en la primera imagen del documento

  • Debido a la recolección, la memoria que aún se está usando en G0, se traspasa a G1 pero ya no hay más espacio para crear un nuevo M0  y poder crear nuevas variables.


  • Cuando esto ocurre, se repite el mismo proceso que se venía haciendo anteriormente en M0 pero ahora en M1. Hay que realizar una recolección en M1 para liberar espacio. Noten que la cantidad de memoria traspasada de G0 a G1 es muy similar durante el último tiempo. Debido a esto no se pueden ver puntillas de la línea azul punteada.

  • La recolección en M1 se ve reflejada por la crecida de la línea roja gruesa. Esta estaba en 1 desde la vez anterior.

  • La cantidad de memoria traspasada de la G1 a la G2 (línea roja punteada) que no se ve ya que está tapada por las otras [:(], es igual al aumento del tamaño del M2 (línea negra delgada). Se ve que aumenta sutilmente.

  • El tamaño de M1 se hace 0 ya que no hay memoria utilizada por objetos en G1 (se traspasó a G2)

  • El tamaño de M0 sigue idéntico (línea azul)

Si este proceso se repitiese lo suficiente, en algún momento la G2 deberá ser recolectada.

¿Qué sucede si el patrón de petición de memoria no es tan lineal como en este ejemplo?

Supongamos que no vamos a pedir bloques de 2.500 caracteres sino que de cualquier valor entre 1 y 10.000.


El nuevo código queda así. En donde decía





public static string Consultar()
{
       …
       string s = new string(‘c’, 2500);
       string g = string.Empty;
       …
}

 


Se reemplaza ahora por lo siguiente.





public static string Consultar()
{
       …
       Random r = new Random((int)(DateTime.Now.Ticks % int.MaxValue));
       string s = new string(‘c’, r.Next(1, 10000));
       string g = string.Empty;
       …
}

Lo anterior hará que no siempre se pida la misma cantidad de memoria, dificultándole un poco más el trabajo al GC.








Revisando detalladamente la imagen anterior (hagan click sobre ella para verla más grande), hay dos cosas que vale la pena mostrar  y que se diferencian del anterior caso.


Estas son:



  • La línea azul delgada ya no es plana. El GC va haciendo variaciones en el tamaño de M0 para poder encontrar el óptimo. Sin ser pesimistas, pero como sabemos que difícilmente una aplicación sigue un patrón de petición fijo, sabemos que jamás lo encontrará. Sin embargo, podemos decir que tiene éxito acercándose al óptimo ya que las recolecciones se hacen en una frecuencia similar y el tamaño de M0 varía dentro de una vecindad.

  • Noten que en la imagen, en la parte  inferior se pueden ver unas puntillas de la línea azul punteadas, que corresponden a la memoria que es traspasada de G0 a G1. Esto complementa lo dicho en el punto precedente y se diferencia del caso anterior donde la cantidad de memoria que pasaba de G0 a G1 era constante (no se veían las puntillas).

Casos más complejos


Supongan ahora que tienen un servidor web con una aplicación realizada en .net y su sitio recibe miles de visitas y requerimientos por hora. ¿Cómo funciona el GC en ese caso y cómo se vería eso en performance monitor?


Como decían mis profesores del colegio: Tarea para la casa.


Patrick.
Santiago de Chile

6 Replies to “Adiestramiento del Garbage Collector (GC) y contadores de rendimiento”

  1. Patrick,

    Están muy interesantes los articulos de memoria. Gracias por la información. Te comento una situación para ver si te ha sucedido: Yo soy programador de C++, ahora que estoy programando en C#, y todo objeto que creo lo destruyó mediante un dispose o un utilizando el using.

    La destrucción de los ArrayList y otros objetos parecidos me imagino que están relacionado al scope de donde fueron definidos y el garbage collector los destruye.

    En este momento la aplicación es un servidor transaccional que tienen un pool de hilos y un hilo que recolecta hilos que hayan muerto y los destruye. Estos hilos del pool según unos criterios crean otros hilos para almacenar info en la bd y se le envia referencia a hilo recolector para que los destruya cuando han terminado.

    El problema que tengo es que en Windows XP la memoria se libera bien, no presenta problemas aún bajo alto tráfico transaccional. Pero Windows 2003 no logra liberar la memoria. Mientras en XP la aplicación no sobrepasa los 50Mb, en 2003 llega bajo la misma carga a 200Mb y no baja. He probado enviarla una sobrecarga y dejarlo media hora sin recibir transacciones y la memoria no baja.

    Me puedes orientar si hay alguna particularidad de 2003 en un dual core 2 que hace que no se comporte igual que el XP?

    Nota: Ya trate de activarle la opción GCServer y no funciona.

    Saludos

  2. Bryan,

    Lo que te voy a decir ahora, seguramente no te gustará, porque normalmente no les gusta a la personas que vienen de C++. Los programadores de C++ son muy cuidadosos con la liberación de memoria y prefieren el estilo determinista por sobre el no determinista del GC.

    La petición y asignación de memoria por parte del Garbage Collector (GC) se hará sin considerar la cantidad de memoria que ya esté usando éste. Dicho de otra forma, mientras haya memoria disponible en el servidor y no sea requerida por otras aplicaciones o el SO, el GC hará uso de ésta.

    Además, en un servidor Windows 2003 Dual Core, hará uso del modo Server del GC. A diferencia de XP (modo Workstation) donde cada bloque asignado al GC de memoria reservada del SO es de 16 MB, en modo server, el bloque más pequeño que reserva el GC es de 64 MB. Esto no significa que toda la memoria este en uso (commit), pero puede darte señales para entender ese comportamiento.

    Por último, si estás mirando la memoria consumida usando Task Manager, lo que estás mirando es el working set y no la memoria privada ni virtual. Mira este link: http://msmvps.com/blogs/pmackay/archive/2007/03/02/posts-y-tips-de-baja-calidad-y-el-impacto-de-stos.aspx

    Saludos,
    Patrick

  3. Gracias por la aclaración. De hecho nombre el task manager pero en realidad es una herramienta que da un profile completo de la aplicación.

    Al analizar en detalle encontré el problema de la aplicación. En realidad el garbage collector si está haciendo lo suyo, le dí seguimiento mediante unos profilers y encontré que la memoria asignadas a objetos convencionales está siendo relativamente bien administrada.

    El problema es REFLECTION, en este momento se está ejecutando dinámicamente código generado dependiendo de parámetros del sistema y de los datos o información provenientes de la transacción entrante.
    Las herramientas detectaron que cuando el CodeDomProvider realiza el CompileAssemblyFromSource se recarga la memoria y está no es fácilmente liberada por el GC por lo que recae en un memory leak.

    Encontré en varios artículos que dicen que la carga de CódigoDinámico con CodeDomProvider tiene implicaciones en memoria y que su liberación no es simple. En este momento revise información de DynamicMehtod pero me parece “poco flexible”, creo que lo mejor es hacer un mini “parser/compiler” para resolver esto.

    Te agradecería que si conoces de una forma de compilación dinámica que no afecte la memoria en C# y sea eficiente me comentes para investigarla. Gracias por los comentarios de la memoria.

    Saludos.

  4. Bryan,

    Mi experiencia con generación y compilación de código dinámico es nula. Sin embargo, de tu respuesta puedo entender que la compilación esté generando un assembly dinámico.

    Efectivamente los assemblies dinámicos no se descargar de un dominio de aplicación, y creo que ese es el leak al que haces referencia.

    Una recomendación que te puedo dar, pero que debes estudiar bien para que no te afecte el rendimiento de tu aplicación, es la creación de un nuevo dominio (AppDomain) y dentro de éste, hacer la compilación. Después de terminado el proceso, podrás descargar el assembly en memoria descargando el dominio que lo contiene.

    Te recalco que la creación y destrucción de dominios no tiene buen rendimiento, pero puede ser una buena alternativa si el código que quieres ejecutar no es considerado crítico en rendimiento.

    Saludos,
    Patrick.

  5. My brother suggested I might like this website. He was entirely right.
    This post actually made my day. You can not imagine simply how much time I had spent for this info!
    Thanks!

Leave a Reply

Your email address will not be published. Required fields are marked *