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:
- Llamado a GC.Collect
- Falta de memoria en el sistema operativo
- Falta de memoria en la aplicación
Revisando cada punto:
- Según vimos en el post mencionado al comienzo del artículo, jamás debe llamarse a GC.Collect.
- 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.
- 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:
|
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:
![]() |
|
- 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