La mayoría de los desarrolladores de .NET tenemos algunas nociones básicas sobre que es el Garbage Collector (en adelante GC) y para qué sirve. No obstante, con frecuencia pasamos por alto algunos detalles de su funcionamiento interno que provocan que nuestro código no sea todo lo eficiente y escalable que debería.
El objetivo del GC es proporcionar una capa de abstracción para los desarrolladores en cuestiones de manejo de memoria. Esto introduce una gran ventaja sobre otros lenguajes de programación en los que el desarrollador se tiene ocupar por completo de esta tarea. Escribir código que maneje correctamente su memoria en todas las situaciones no es ni mucho menos trivial, y las posibilidades de introducir bugs en la aplicación son múltiples: corrupción del heap, corrupción del stack, pérdida de memoria, fragmentación de memoria etc.
Si bien el GC simplifica la tarea de manejo de memoria para los desarrolladores, no les exime por completo. Para hacer buen uso de la memoria en .NET es importante conocer como la maneja el GC internamente. Vayamos por partes.
El GC de .NET es un colector de basura generacional. Esto significa que clasifica los objetos en distintas generaciones, lo cual le permite realizar colecciones de basura parciales (de una o varias generaciones) y así evitar hacer siempre colecciones de basura completas de todo el heap de .NET. Esta característica es una de las más importantes en cuanto al rendimiento del GC, y permiten que el GC de .NET sea escalable para aplicaciones de alta concurrencia como por ejemplo aplicaciones ASP.NET.
En el GC de .NET tiene tres generaciones (0, 1 y 2), y todos los objetos se crean en la generación 0 siempre y cuando no superen el tamaño de 85.000 bytes (enseguida veremos qué pasa con estos objetos). Las colecciones de basura se desencadenan cuando se intenta reservar memoria para un nuevo objeto y se sobrepasa el límite de memoria designado a la generación en cuestión. Los límites de memoria asignados a cada generación se modifican dinámicamente durante la vida del proceso para adaptarse a los patrones de reserva de memoria de la aplicación.
Cuando el GC realiza una colección de basura, revisa todos los objetos de la generación o generaciones afectadas, y comprueba si estan referenciados. Para que un objeto se consideré referenciado, tiene que estar referenciado por un objeto raíz. Los objetos raíz son (simplificando un poco):
· Threads – Todos los objetos referenciados en la pila: variables locales, parámetros, etc.
· Strong Reference – Objetos estáticos, objetos de caché y variables globales.
· Weak Reference – Aunque los objetos WeakReference no evitan que sus objetos referenciados sean “colectados”, se consideran objetos raíz.
· Pinned Objects – Los objetos marcados como Pinned no pueden ser “colectados” ni movidos por el GC, y por tanto los objetos a los que estos referencian tampoco pueden ser colectados. Esta técnica se suele utilizar para pasar un objeto .NET como referencia a una API nativa (no .NET), de forma que la dirección de memoria del objeto .NET no cambie hasta que no finalice la llamada a la API. Los objetos deben permanecer Pinned lo mínimo indispensable dado que pueden causar fragmentación del heap de .NET.
· Objetos que implementan destructor o Finalize() – Esta categoría la trato en un post separado: Cosas que deberías saber sobre los destructores en .NET
Los objetos que no están referenciados serán eliminados por el GC y su espacio en memoria será liberado, y los objetos supervivientes a la colección serán promocionados a la siguiente generación con la excepción de los objetos en la generación 2 que ya no pueden promocionar más. Por último, los "huecos" de espacio libre de los objetos eliminados es consolidado de forma que los objetos supervivientes son reubicados en direcciones de memoria contiguas. Una aplicación con una ratio de colecciones saludable, suele tener 10 veces más colecciones de la generación 0 que de la generación 1, y 10 veces más colecciones de la generación 1 que de la generación 2, es decir un ratio de 100:10:1 para GEN 0:GEN 1:GEN 2.
El colector de basura generacional es indispensable para alcanzar el nivel de rendimiento necesario en una aplicación de alta concurrencia, y se basa en la siguiente regla heurística: los objetos que han existido mucho tiempo, van a seguir existiendo durante mucho tiempo más. Es decir que si un objeto ha sobrevivido a dos colecciones y ha promocionado hasta la generación 2, lo más probable es que vaya a seguir sobreviviendo a colecciones venideras. Por lo tanto no tiene sentido colectar basura con la misma frecuencia en la generación 2 que en la 0. Tras haber realizado miles de pruebas de carga con distintos tipos de aplicaciones, esta presunción ha resultado ser cierta (casi siempre).
Cómo hacía referencia antes, los objetos cuyo tamaño es superior a los 85.000 bytes reciben un trato distinto. Estos objetos se crean en el Large Object Heap (en adelante LOH), también conocido a veces como generación 3. ¿Porqué necesitamos una generación o un heap especial para objetos grandes? Básicamente por dos motivos.
1) Se asume que los objetos grandes generalmente tienen una vida larga (misma regla heurística que para la generación 2).
2) Los objetos grandes son “caros” de mover y por este motivo el espacio libre en el LOH no se consolida y por tanto favorece la fragmentación de memoria. Por esto los objetos grandes se crean en un heap específico.
Cuando se desencadena una colección en la generación 2 o en el LOH, se realiza una colección completa (es decir de las generaciones 0, 1, 2 y LOH). Las colecciones de basura completas son costosas, sobre todo en cuanto a consumo de CPU, dado que potencialmente hay muchos objetos que revisar y mover una vez se ha liberado el espacio.
Dicho esto, ¿qué consideraciones debemos tener en cuanto al uso de memoria cuando desarrollamos aplicaciones .NET? Estas son algunas, pero no dudéis en aportar vuestros propios comentarios:
· Cuidado con los objetos alojados en el LOH. Siempre que tenga sentido, es deseable reutilizarlos y mantenerlos referenciados durante toda la vida del proceso.
· Cuidado con la concatenación de cadenas (String) en un bucle, por ejemplo generando dinámicamente un XML o un fragmento de HTML. Estas prácticas, si no se implementan correctamente, suelen terminar en objetos String de gran tamaño en el LOH que provocan constantes colecciones de basura completas y el correspondiente consumo de 100% CPU. Utilizad la clase StringBuilder para esto.
· Cuidado con los objetos que cacheamos, y las referencias a otros objetos que estos pueden mantener. Cachear objetos indirectamente de forma “involuntaria” es la forma más frecuente de provocar un memory leak en aplicaciones .NET.
Si queréis seguir profundizando en el funcionamiento del GC, os recomiendo los siguientes recursos:
Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework
http://msdn.microsoft.com/en-us/magazine/bb985010.aspx
Garbage Collection Part 2: Automatic Memory Management in the Microsoft .NET Framework
http://msdn.microsoft.com/en-us/magazine/bb985011.aspx
Garbage Collector Basics and Performance Hints
http://msdn.microsoft.com/en-us/library/ms973837.aspx
Maoni's WebLog - CLR Garbage Collector
http://blogs.msdn.com/maoni/
Hasta la próxima,
- Daniel Mossberg
Si alguna vez os habéis molestado en analizar unos logs de IIS, quizá os hayais encontrado con el escenario en el que cuando tenemos habilitada la autenticación de Windows integrada, cada petición que hacemos realmente necesita tres peticiones para llevarse a cabo. Este es el patrón típico de la autenticación NTLM. La primera petición falla dando un error HTTP 401.2, la segunda falla también pero con un error 401.1, y la tercera es la definitiva devolviendo un resultado HTTP 200 - OK.
Os explico el porqué de esta secuencia:
SECUENCIA
PETICIÓN
RESPUESTA
RESULTADO HTTP
1
El cliente (por ejemplo Internet Explorer) hace una petición GET (o POST) a un servidor IIS. Esta primera petición es anónima, puesto que a priori el cliente no tiene porqué saber nada sobre este servidor: no sabe si el servidor requiere algún tipo de autenticación, no sabe qué tipos de autenticación acepta el servidor, etc.
Supongamos que el servidor sólo tiene habilitada la autenticación de Windows integrada. Puesto que la petición que ha llegado es anónima, el servidor responde con un error HTTP 401.2 (Logon Failed due to server configuration) y le especifica al cliente los métodos de autenticación que acepta. Para este ejemplo supongamos que acepta Negotiate y NTLM.
401.2[Logon Failed due to server configuration]
2
El cliente elige autenticarse mediante NTLM (el motivo de esta elección es irrelevante para el ejemplo) y le pasa un encabezado HTTP al servidor indicando que se va a autenticar por NTLM. En este encabezado indica también la cuenta de usuario con la que se va a autenticar (pero no la contraseña).
Para que el servidor compruebe que el cliente es quién dice ser, necesita comprobar que conoce la contraseña sin pedírsela. Para ello genera un número aleatorio de 16-byte, conocido como el “challenge” (o reto) y se lo envía al cliente. En esta segunda petición, el servidor vuelve a responder con un error HTTP 401.1 (Logon Failed) puesto que el cliente todavía no está autenticado.
401.1[Logon Failed]
3
El cliente cifra el “challenge” con un hash de la contraseña y genera lo que llamamos el “response”. De esta manera, demuestra que conoce dicha contraseña sin que la contraseña en ningún momento se intercambie por la red. Vuelve a enviar la misma petición GET por tercera (y última) vez y con el “response”.
El servidor (que tampoco conoce la contraseña del cliente) envía los siguientes datos al controlador de dominio:
· Nombre de usuario (del cliente)
· El “challenge” enviado al cliente
· El “response” generado por el cliente
El controlador de dominio que sí conoce la contraseña del cliente, cifra el “challenge” con un hash de la contraseña del usuario igual que hizo IE y compara el resultado con el “response” que ha generado IE. Si todo es correcto, por fin se autentica al usuario y se le envía la respuesta definitiva (HTTP 200 –OK).
200[OK]
Una vez hemos visto el motivo de este intercambio de peticiones y respuestas HTTP, más de uno se pregunta ¿esto no genera un excesivo tráfico de red? Las respuesta es que depende. Evidentemente, el protocolo de autenticación NTLM genera más tráfico de red que un sitio web configurado con autenticación anónima, pero el tráfico adicional normalmente no supone un problema.
Por un lado, todos los clientes HTTP que soportan autenticación NTLM implementan una optimización, que implica que en la segunda petición del handshake NTLM únicamente se incluyen los encabezados HTTP (y por lo tanto se excluye el HTTP entity-body, es decir los datos asociados a la petición en caso de que los haya). Os pongo un ejemplo, si la petición en cuestión es un POST de un fichero de 100MB, el primer POST anónimo (junto con los 100MB de fichero adjunto) va a fallar con un error 401.1. Cuando el cliente decide autenticarse por NTLM, hace un segundo POST pero esta vez sin entity-body (es decir, sin los 100MB de fichero adjunto) dado que ya sabe que esta petición también va a fallar inevitablemente con un error 401.1. En la última petición, de nuevo volvemos a incluir el entity-body, que será la petición definitiva y cuando finalmente logremos subir el fichero al servidor HTTP.
Adicionalmente, Internet Explorer (en adelante IE) incluye una característica conocida como pre-autenticación NTLM que evita realizar la primera petición anónima una vez determina que un servidor en concreto acepta autenticación NTLM. La pre-autenticación NTLM directamente envía el encabezado HTTP de autenticación por NTLM (paso 2 de la secuencia) indicando al servidor el nombre de usuario para que este genere el “challenge”.
Esta optimización puede dar problemas si una vez iniciado este comportamiento de IE, vamos a parar a un directorio virtual en el mismo sitio web que no admite autenticación NTLM (sino que únicamente utiliza autenticación anónima). En este caso, IE va a hacer una petición enviando los encabezados HTTP (especificando autenticación por NTLM) esperando un 401.1 por parte del servidor. Puesto que IE “cree” que la petición definitiva va a ser la siguiente, elige no mandar los datos del entity-body para ahorrar ancho de banda. Puesto que el servidor no acepta autenticación NTLM pero si acepta autenticación anónima, ignora el encabezado HTTP de autenticación y responde con un HTTP 200 definitivo. Esto provoca que, por ejemplo, los datos del formulario HTML (o el fichero de 100MB del ejemplo anterior) nunca se lleguen a enviar con el POST. El siguiente artículo habla precisamente de este problema:
You cannot post data to a non-NTLM-authenticated Web site
http://support.microsoft.com/kb/251404/en-us
Si tenemos una aplicación ASP.NET que realiza llamadas a un web service, se autentica por NTLM y queremos evitar la primera petición anónima, podemos implementar la pre-autenticación NTLM de la siguiente manera:
MyWebService proxy = new MyWebService();
proxy.Credentials = CredentialCache.DefaultNetworkCredentials;
proxy.PreAuthenticate = true;
proxy.HelloWorld();
Por último, si queremos reducir aún más el ancho de banda causado por la autenticación NTLM, podemos reducir los Custom Errrors que devuelve IIS para los errores HTTP 401.1 y 401.2.
Happy hacking.