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

La contradicción de las aplicaciones ultra parametrizables y customizables

Estas  últimas semanas han estado un poco aburridas. No se han presentado problemas de aplicaciones pero seguramente se presentarán… tarde o temprano. Sin embargo, a falta de problemas que se presentan, nos encargamos de buscarlos, o más bien dicho, de forzarlos.


Una de las últimas actividades realizadas corresponde a la revisión de una aplicación que será liberada pronto. Esta revisión consistió en generar pruebas de carga para diferentes casos de uso y analizar los resultados.

Actividad realizada

Grabamos los scripts con [ACT], los modificamos con [fiddler] y los ejecutamos con [ACT].

Resultados

Los resultados fueron sorprendentes, y no lo que se esperaba. La aplicación no lograba procesar más de 0,5 requerimientos por segundo, consumiendo el 70% de la CPU con un sólo usuario conectado.

Análisis detallado

La aplicación analizada pertenece a la fauna de aplicaciones que han sido concebidas con la idea de ser totalmente customizables y parametrizables. Particularmente no estoy en contra de las aplicaciones parametrizables, y más aún, el no permitir que se adapten a ciertas variables es una limitación, y producirá en el cortísimo plazo problemas de mantención.

¿Cuál es la arquitectura utilizada?

La aplicación está montada sobre un framework desarrollado por un tercero.
Nota aparte: ¿Se han fijado que ahora todo el mundo hace frameworks?, ¿y a casi cualquier cosa desarrollada le llaman framework? Bueno, volvamos a la aplicación.


Esta es una aplicación web desarrollada en .net montada con este framework. No puedo decir que es una aplicación ASP.NET ya que apenas utiliza ASP.NET.

¿Por qué no usa ASP.NET?

Cada página no tiene controles asp.net; ninguno. Así de simple. Incluso las páginas generadas no tienen viewstate, prueba de lo anterior.

¿Cómo es esto posible?

Las páginas son generadas desde una definición existente en algún archivo XML (400 kilobytes), el cual, en algún lugar indica con cierto criterio, donde buscar los controles que deben pintarse en la página, los que están definidos en una tabla en una base de datos de SQL Server.


Debido a lo anterior, no es de extrañarse que para pintar una página, recurra muchas veces al servidor de datos. Para complicar aún más el escenario, cada ítem de la página es un ítem en la base de datos. Todo esto para poder definir paramétricamente las páginas. Notar al final las contradicciones de este modelo.


Más aún, la lectura desde el servidor SQL es procesada en largas funciones y ciclos, los cuales, para más males, concatenan string en algunos casos. El resultado de esta gran concatenación es, como alguno de ustedes podrá adivinar/suponer, XML.


Luego, este XML es transformado en conjunto con un XSL que define la salida de la página. Recordar que el requerimiento era que la aplicación fuese mantenible fácilmente, algo que ya no se cumple.


Ok, después de todo este trabajo, la página está pintada.

¿Qué pasa si hago clic sobre algún botón?

Cómo no existen los controles de servidor, no existe un evento asociado al botón, y la página se procesa como se hacía en ASP, es decir, con request.form[] y sus amigos.


Lo bueno al menos es que como todo esto lo maneja este framework, es él el encargado de procesar el evento y llamar a las funciones de negocio asociadas.


Las funciones de negocio se definen en un XML, de varios cientos de kilobytes. En este archivo, utilizando información súper rígida, se define la ruta física del assembly, además del tipo y la función que se llamará. No es necesario definir los parámetros de entrada, porque se usa un único parámetro de tipo string, que en el fondo, es un XML.


Si en las funciones de negocio se requiere llamar a un procedimiento almacenados (SP), hay que hacerlo a través del framework. Obviamente hay que construir un XML con la información del SP y los parámetros. Sin embargo, en el XML no se escribe el nombre del SP sino que un pseudo-nombre asociado a una pseudo-base de datos. Entonces, el framework internamente se encarga de ir a buscar a otro XML, y que tiene como nombre el mismo valor de la pseudo-base de datos, la definición real del SP.

Contradicciones vitales

El sistema fue concebido así para poder ser fácilmente adaptable sin necesidad de mayores cambios en el código. La contradicción está en que el único que es capaz de hacer algún cambio en la aplicación es alguien que domine todos los elementos mencionados hasta ahora.


Es interesante que no se esté utilizando el GAC. ¿Será porque los assemblies cambian mucho en el tiempo y existe un costo administrativo de firmarlos e incluirlos en el GAC? Si cambian mucho en el tiempo, ¿Cuál era el problema de hacer una aplicación más rígida, o dicho de otra forma, una aplicación normal utilizando controles ASP.NET y librerías de clases?

Otra información

¿Si se preguntan cuántas veces baja a la base de datos para pintar una página especifica?
Más de 400


Saludos,
Patrick