Compartir a través de


Garbage Collector Basics and Performance Hints (Conceptos básicos del recolector de elementos no utilizados y sugerencias de rendimiento)

 

Rico Mariani
Microsoft Corporation

Abril de 2003

Resumen: El recolector de elementos no utilizados de .NET proporciona un servicio de asignación de alta velocidad con un buen uso de memoria y sin problemas de fragmentación a largo plazo. En este artículo se explica cómo funcionan los recolectores de elementos no utilizados y, a continuación, se describen algunos de los problemas de rendimiento que se pueden encontrar en un entorno recopilador de elementos no utilizados. (10 páginas impresas)

Se aplica a:
   Microsoft® .NET Framework

Contenido

Introducción
Modelo simplificado
Recolección de elementos no utilizados
Rendimiento
Finalización
Conclusión

Introducción

Para comprender cómo hacer un buen uso del recolector de elementos no utilizados y qué problemas de rendimiento podría surgir al ejecutarse en un entorno recopilador de elementos no utilizados, es importante comprender los conceptos básicos de cómo funcionan los recolectores de elementos no utilizados y cómo afectan esos trabajos internos a la ejecución de programas.

Este artículo se divide en dos partes: En primer lugar, analizaré la naturaleza del recolector de elementos no utilizados de Common Language Runtime (CLR) en términos generales mediante un modelo simplificado y, a continuación, analizaré algunas implicaciones de rendimiento de esa estructura.

Modelo simplificado

Para fines explicativos, considere el siguiente modelo simplificado del montón administrado. Tenga en cuenta que esto no es lo que realmente se implementa.

Figura 1. Modelo simplificado del montón administrado

Las reglas de este modelo simplificado son las siguientes:

  • Todos los objetos recolecibles de elementos no utilizados se asignan desde un intervalo contiguo de espacio de direcciones.
  • El montón se divide en generaciones (más adelante) para que sea posible eliminar la mayoría de la basura examinando solo una pequeña fracción del montón.
  • Los objetos de una generación tienen aproximadamente la misma edad.
  • Las generaciones con un número superior indican áreas del montón con objetos más antiguos: es mucho más probable que esos objetos sean estables.
  • Los objetos más antiguos están en las direcciones más bajas, mientras que los nuevos objetos se crean en direcciones crecientes. (Las direcciones están aumentando en la figura 1 anterior).
  • El puntero de asignación para los nuevos objetos marca el límite entre las áreas de memoria usadas (asignadas) y sin usar (libres).
  • Periódicamente, el montón se compacta quitando objetos muertos y deslizando los objetos activos hacia el extremo inferior del montón. Esto expande el área sin usar en la parte inferior del diagrama en el que se crean nuevos objetos.
  • El orden de los objetos en la memoria sigue siendo el orden en el que se crearon, para una buena localidad.
  • Nunca hay espacios entre los objetos del montón.
  • Solo se confirma parte del espacio libre . Cuando sea necesario, ** se adquiere más memoria del sistema operativo en el intervalo de direcciones reservadas .

Recolección de elementos no utilizados

El tipo de recolección más fácil de entender es la recolección de elementos no utilizados totalmente compactado, por lo que comenzaré discutiendo eso.

Colecciones completas

En una colección completa, debemos detener la ejecución del programa y buscar todas las raíces en el montón de GC. Estas raíces vienen en una variedad de formas, pero son especialmente variables globales y de pila que apuntan al montón. A partir de las raíces, visitamos cada objeto y seguimos cada puntero de objeto contenido en cada objeto visitado que marca los objetos a medida que avanzamos. De este modo, el recopilador habrá encontrado todos los objetos accesibles o activos . Los demás objetos, los inalcanzables , ahora están condenados.

Ilustración 2. Raíces en el montón de GC

Una vez identificados los objetos inaccesibles, queremos reclamar ese espacio para su uso posterior; El objetivo del recopilador en este punto es deslizar los objetos activos y eliminar el espacio desperdiciado. Con la ejecución detenida, es seguro que el recopilador mueva todos esos objetos y corrija todos los punteros para que todo esté vinculado correctamente en su nueva ubicación. Los objetos supervivientes se promueven al número de próxima generación (que es decir, los límites de las generaciones se actualizan) y la ejecución se puede reanudar.

Colecciones parciales

Desafortunadamente, la recolección completa de elementos no utilizados es simplemente demasiado costosa para hacer cada vez, por lo que ahora es adecuado analizar cómo tener generaciones en la recolección nos ayuda a salir.

En primer lugar, consideremos un caso imaginario en el que estamos extraordinariamente afortunados. Supongamos que había una colección completa reciente y que el montón está bien compacto. La ejecución del programa se reanuda y se producen algunas asignaciones. De hecho, se producen muchas y muchas asignaciones y, después de suficientes asignaciones, el sistema de administración de memoria decide que es el momento de recopilar.

Aquí tenemos suerte. Supongamos que, en todo momento, se estaba ejecutando desde la última colección en la que no se escribió ninguno de los objetos más antiguos, solo se han asignado recientemente, generación cero (generación0), los objetos se han escrito en. Si esto fuera a ocurrir, estaríamos en una gran situación porque podemos simplificar el proceso de recolección de elementos no utilizados masivamente.

En lugar de nuestra recopilación completa habitual, podemos simplemente suponer que todos los objetos más antiguos (gen1, gen2) todavía están activos, o al menos lo suficiente de ellos están vivos que no vale la pena mirar esos objetos. Además, dado que ninguno de ellos se escribió (recuerde lo afortunado que somos?) no hay punteros de los objetos más antiguos a los objetos más recientes. Por lo tanto, lo que podemos hacer es mirar todas las raíces como de costumbre, y si cualquier raíz apunta a objetos antiguos simplemente omite esos objetos. Para otras raíces (aquellas que apuntan a gen0) seguimos como de costumbre, siguiendo todos los punteros. Cada vez que encontramos un puntero interno que vuelve a los objetos más antiguos, lo omitemos.

Cuando se realice ese proceso, habrámos visitado todos los objetos activos de la generación0 sin haber visitado ningún objeto de las generaciones anteriores. Los objetos gen0 pueden ser condenados como de costumbre y deslizamos hacia arriba solo esa región de memoria, dejando los objetos más antiguos sin desactivar.

Ahora esto es realmente una gran situación para nosotros porque sabemos que la mayoría del espacio muerto es probable que esté en objetos más jóvenes donde hay una gran cantidad de abandono. Muchas clases crean objetos temporales para sus valores devueltos, cadenas temporales y otras clases de utilidad como enumeradores y whatnot. Al mirar solo la generación0 , nos da una manera fácil de volver a la mayoría del espacio muerto examinando solo unos pocos de los objetos.

Desafortunadamente, nunca tenemos la suerte de usar este enfoque, ya que al menos algunos objetos más antiguos están enlazados a cambios para que apunten a nuevos objetos. Si eso sucede, no basta con omitirlos.

Hacer que las generaciones funcionen con barreras de escritura

Para que el algoritmo anterior funcione realmente, debemos saber qué objetos más antiguos se han modificado. Para recordar la ubicación de los objetos sucios, usamos una estructura de datos denominada tabla de tarjetas y para mantener esta estructura de datos, el compilador de código administrado genera barreras de escritura llamadas. Estas dos nociones son fundamentales para el éxito de la recolección de elementos no utilizados basados en la generación.

La tabla de tarjetas se puede implementar de varias maneras, pero la manera más fácil de pensar es como una matriz de bits. Cada bit de la tabla de tarjetas representa un intervalo de memoria en el montón, digamos 128 bytes. Cada vez que un programa escribe un objeto en alguna dirección, el código de barrera de escritura debe calcular el fragmento de 128 bytes que se escribió y, a continuación, establecer el bit correspondiente en la tabla de tarjetas.

Con este mecanismo en vigor, ahora podemos volver a visitar el algoritmo de recopilación. Si estamos realizando una recolección de elementos no utilizados de generación0 , podemos usar el algoritmo como se explicó anteriormente, ignorando los punteros a generaciones anteriores, pero una vez que hemos hecho esto, también debemos encontrar cada puntero de objeto en cada objeto que se encuentra en un fragmento que se marcó como modificado en la tabla de tarjetas. Debemos tratar a esos como raíces. Si también consideramos esos punteros, recopilaremos correctamente solo los objetos gen0 .

Este enfoque no ayuda en absoluto si la tabla de tarjetas siempre estaba llena, pero en la práctica comparativamente pocos de los punteros de las generaciones anteriores realmente se modifican, por lo que hay un ahorro sustancial de este enfoque.

Rendimiento

Ahora que tenemos un modelo básico para cómo funcionan las cosas, vamos a considerar algunas cosas que podrían ir mal que lo harían lento. Eso nos dará una buena idea de qué tipo de cosas debemos intentar evitar para sacar el mejor rendimiento del recopilador.

Demasiadas asignaciones

Esto es realmente lo más básico que puede ir mal. Asignar memoria nueva con el recolector de elementos no utilizados es realmente muy rápido. Como puede ver en la figura 2 anterior, todo lo que debe ocurrir normalmente es que el puntero de asignación se mueva para crear espacio para el nuevo objeto en el lado "asignado", no se vuelve mucho más rápido que eso. Sin embargo, antes o después, una recolección de elementos no utilizados tiene que suceder y, todas las cosas son iguales, es mejor que eso suceda más tarde que antes. Por lo tanto, quiere asegurarse de que al crear nuevos objetos es realmente necesario y adecuado hacerlo, aunque crear solo uno sea rápido.

Esto puede parecer un consejo obvio, pero en realidad es muy fácil olvidar que una pequeña línea de código que escribe podría desencadenar una gran cantidad de asignaciones. Por ejemplo, supongamos que está escribiendo una función de comparación de algún tipo y supongamos que los objetos tienen un campo de palabras clave y que desea que la comparación no tenga distinción entre mayúsculas y minúsculas en las palabras clave en el orden especificado. Ahora, en este caso, no puede comparar simplemente la cadena de palabras clave completas, ya que la primera palabra clave podría ser muy corta. Sería tentador usar String.Split para dividir la cadena de palabra clave en partes y, a continuación, comparar cada pieza con el fin de usar la comparación normal sin distinción entre mayúsculas y minúsculas. ¿Suena genial?

Bueno, como resulta hacerlo como eso no es una buena idea. Verá que String.Split va a crear una matriz de cadenas, lo que significa que un nuevo objeto de cadena para cada palabra clave originalmente en la cadena de palabras clave más un objeto más para la matriz. ¡Yikes! Si hacemos esto en el contexto de una ordenación, es una gran cantidad de comparaciones y la función de comparación de dos líneas ahora está creando un gran número de objetos temporales. De repente, el recolector de elementos no utilizados va a estar trabajando muy duro en su nombre, e incluso con el esquema de recolección más inteligente hay solo una gran cantidad de basura para limpiar. Mejor escribir una función de comparación que no requiera las asignaciones en absoluto.

Asignaciones de Too-Large

Cuando se trabaja con un asignador tradicional, como malloc(), los programadores suelen escribir código que realiza tantas llamadas a malloc() como sea posible porque saben que el costo de asignación es comparativamente alto. Esto se traduce en la práctica de asignar en fragmentos, a menudo asignando objetos especulativamente que podríamos necesitar, para que podamos hacer menos asignaciones totales. Los objetos previamente asignados se administran manualmente desde algún tipo de grupo, creando de forma eficaz un tipo de asignador personalizado de alta velocidad.

En el mundo administrado, esta práctica es mucho menos atractiva por varias razones:

En primer lugar, el costo de realizar una asignación es extremadamente bajo: no hay ninguna búsqueda de bloques libres como con los asignadores tradicionales; todo lo que debe ocurrir es el límite entre las áreas libres y asignadas que deben moverse. El bajo costo de asignación significa que el motivo más atractivo para agrupar simplemente no está presente.

En segundo lugar, si decide asignar previamente, por supuesto va a realizar más asignaciones de las necesarias para sus necesidades inmediatas, lo que podría forzar a su vez recolecciones de elementos no utilizados adicionales que, de lo contrario, podrían haber sido innecesarias.

Por último, el recolector de elementos no utilizados no podrá recuperar espacio para los objetos que se están reciclando manualmente, ya que desde la perspectiva global todos esos objetos, incluidos los que no están actualmente en uso, todavía están activos. Es posible que encuentre que una gran cantidad de memoria está desperdiciada manteniendo los objetos listos para usar, pero no en uso a mano.

Esto no es decir que la asignación previa siempre es una mala idea. Es posible que quiera hacerlo para forzar que determinados objetos se asignen inicialmente juntos, por ejemplo, pero es probable que sea menos atractivo como una estrategia general de lo que sería en código no administrado.

Demasiados punteros

Si crea una estructura de datos que es una gran malla de punteros, tendrá dos problemas. En primer lugar, habrá una gran cantidad de escrituras de objetos (vea la figura 3 a continuación) y, en segundo lugar, cuando llegue el momento de recopilar esa estructura de datos, hará que el recolector de elementos no utilizados siga todos esos punteros y, si es necesario, cambie todos a medida que las cosas se muevan. Si la estructura de datos es de larga duración y no cambiará mucho, el recopilador solo tendrá que visitar todos esos punteros cuando se produzcan colecciones completas (en el nivel gen2 ). Pero si crea una estructura de este tipo sobre una base transitoria, por ejemplo, como parte de las transacciones de procesamiento, pagará el costo mucho más a menudo.

Figura 3. Estructura de datos pesada en punteros

Las estructuras de datos que son pesadas en punteros también pueden tener otros problemas, no relacionados con el tiempo de recolección de elementos no utilizados. De nuevo, como se explicó anteriormente, cuando se crean objetos, se asignan de forma contigua en el orden de asignación. Esto es excelente si va a crear una estructura de datos grande, posiblemente compleja, por ejemplo, restaurando información a partir de un archivo. Aunque tenga tipos de datos dispares, todos los objetos se cerrarán juntos en la memoria, lo que a su vez ayudará al procesador a tener acceso rápido a esos objetos. Sin embargo, a medida que pase el tiempo y se modifique la estructura de datos, es probable que sea necesario adjuntar nuevos objetos a los objetos antiguos. Esos nuevos objetos se crearán mucho más adelante y, por tanto, no estarán cerca de los objetos originales en la memoria. Incluso cuando el recolector de elementos no utilizados compacta la memoria, los objetos no se ordenan aleatoriamente en la memoria, simplemente "deslizan" juntos para quitar el espacio desperdiciado. El trastorno resultante podría ser tan malo con el tiempo que puede estar inclinado a hacer una copia nueva de toda su estructura de datos, todo bien empaquetado, y dejar que el viejo desordenado sea condenado por el recopilador en su debido curso.

Demasiadas raíces

Por supuesto, el recolector de elementos no utilizados debe dar un tratamiento especial a las raíces en el momento de la recolección, siempre deben ser enumerados y debidamente considerados a su vez. La colección gen0 solo puede ser rápida en la medida en que no se le da una inundación de raíces que se deben tener en cuenta. Si fuera a crear una función profundamente recursiva que tenga muchos punteros de objetos entre sus variables locales, el resultado puede ser realmente bastante costoso. Este costo se incurre no solo en tener que tener en cuenta todas esas raíces, sino también en el número extra-grande de objetos gen0 que esas raíces podrían mantener viva durante no mucho tiempo (se describe a continuación).

Demasiadas escrituras de objetos

Una vez más haciendo referencia a nuestra explicación anterior, recuerde que cada vez que un programa administrado modificó un puntero de objeto, también se desencadena el código de barrera de escritura. Esto puede ser malo por dos motivos:

En primer lugar, el costo de la barrera de escritura podría ser comparable al costo de lo que estaba intentando hacer en primer lugar. Si, por ejemplo, está realizando operaciones sencillas en algún tipo de clase de enumerador, es posible que tenga que mover algunos de los punteros clave de la colección principal al enumerador en cada paso. Esto es algo que puede evitar, ya que se duplica eficazmente el costo de copiar esos punteros debido a la barrera de escritura y es posible que tenga que hacerlo una o varias veces por bucle en el enumerador.

En segundo lugar, desencadenar barreras de escritura es doblemente incorrecta si está escribiendo de hecho en objetos más antiguos. A medida que modifica los objetos anteriores, se crean raíces adicionales para comprobar (descritos anteriormente) cuando se produce la siguiente recolección de elementos no utilizados. Si ha modificado lo suficiente de los objetos antiguos, negaría eficazmente las mejoras de velocidad habituales asociadas a la recopilación solo de la generación más joven.

Estas dos razones son, por supuesto, complementadas por las razones habituales de no hacer demasiadas escrituras en cualquier tipo de programa. Todas las cosas son iguales, es mejor tocar menos memoria (lectura o escritura, de hecho) para hacer un uso más económico de la memoria caché del procesador.

Demasiados objetos de vida casi larga

Por último, quizás el mayor problema del recolector de elementos no utilizados generacionales es la creación de muchos objetos, que no son exactamente temporales ni son exactamente de larga duración. Estos objetos pueden causar muchos problemas, ya que no serán limpiados por una colección gen0 (el más barato), ya que seguirán siendo necesarios, y podrían incluso sobrevivir a una colección gen1 porque todavía están en uso, pero pronto mueren después de eso.

El problema es, una vez que un objeto ha llegado al nivel gen2 , solo una recolección completa se deshacerá de él, y las recolecciones completas son lo suficientemente costosas que el recolector de elementos no utilizados los retrasa siempre y cuando sea razonablemente posible. Por lo tanto, el resultado de tener muchos objetos de "casi larga duración" es que su gen2 tiende a crecer, potencialmente a un ritmo alarmante; es posible que no se limpie casi tan rápido como le gustaría, y cuando se limpia, seguramente será mucho más costoso hacerlo de lo que podría haber deseado.

Para evitar estos tipos de objetos, las mejores líneas de defensa van de la siguiente manera:

  1. Asigne el menor número de objetos posible, con la atención debida a la cantidad de espacio temporal que está usando.
  2. Mantenga los tamaños de objeto de mayor duración en un mínimo.
  3. Mantenga el menor número de punteros de objetos en la pila como sea posible (esos son raíces).

Si haces estas cosas, es más probable que las colecciones gen0 sean muy eficaces y gen1 no crezcan muy rápido. Como resultado, las colecciones gen1 se pueden realizar con menos frecuencia y, cuando se hace prudente realizar una colección gen1 , los objetos de duración media ya estarán muertos y se pueden recuperar, de forma barata, en ese momento.

Si las cosas van muy bien, durante las operaciones de estado estable, el tamaño gen2 no aumentará en absoluto.

Finalización

Ahora que hemos tratado algunos temas con el modelo de asignación simplificado, me gustaría complicar un poco las cosas para que podamos analizar un fenómeno más importante, y que es el costo de los finalizadores y la finalización. Brevemente, un finalizador puede estar presente en cualquier clase: es un miembro opcional que el recolector de elementos no utilizados promete llamar a en objetos inactivos de otro modo antes de reclamar la memoria de ese objeto. En C# se usa la sintaxis ~Class para especificar el finalizador.

Cómo afecta la finalización de la colección

Cuando el recolector de elementos no utilizados encuentra primero un objeto que, de lo contrario, está muerto, pero todavía debe finalizarse, debe abandonar su intento de reclamar el espacio para ese objeto en ese momento. En su lugar, el objeto se agrega a una lista de objetos que necesitan finalizar y, además, el recopilador debe asegurarse de que todos los punteros del objeto permanecen válidos hasta que se complete la finalización. Esto es básicamente lo mismo que decir que cada objeto en necesidad de finalización es como un objeto raíz temporal desde la perspectiva del recopilador.

Una vez completada la colección, el subproceso de finalización con nombre adecuado pasará por la lista de objetos que necesitan finalizar e invocará a los finalizadores. Cuando esto se hace, los objetos vuelven a estar muertos y se recopilarán de forma natural de la manera normal.

Finalización y rendimiento

Con este conocimiento básico de la finalización ya podemos deducir algunas cosas muy importantes:

En primer lugar, los objetos que necesitan finalizar residen más tiempo que los objetos que no lo hacen. De hecho, pueden vivir mucho más tiempo. Por ejemplo, supongamos que es necesario finalizar un objeto que se encuentra en la generación2 . La finalización se programará, pero el objeto todavía está en la generación2, por lo que no se volverá a recopilar hasta que se produzca la siguiente colección gen2 . Eso podría ser un tiempo muy largo, y, de hecho, si las cosas van bien, será mucho tiempo, porque las colecciones de gen2 son costosas y, por lo tanto, queremos que sucedan muy poco frecuentemente. Es posible que los objetos más antiguos que necesiten finalizar tengan que esperar docenas si no cientos de colecciones gen0 antes de que se recupere su espacio.

En segundo lugar, los objetos que necesitan finalizar provocan daños colaterales. Dado que los punteros de objeto internos deben permanecer válidos, no solo los objetos que necesitan la finalización se mantendrán en la memoria, sino que todo lo que hace referencia el objeto, directa e indirectamente, también permanecerá en la memoria. Si un gran árbol de objetos estaba anclado por un único objeto que requería la finalización, todo el árbol perduraría, potencialmente durante mucho tiempo, como acabamos de analizar. Por lo tanto, es importante usar finalizadores con moderación y colocarlos en objetos que tengan el menor número posible de punteros de objetos internos. En el ejemplo de árbol que acabo de proporcionar, puede evitar fácilmente el problema moviendo los recursos en necesidad de finalizar a un objeto independiente y mantener una referencia a ese objeto en la raíz del árbol. Con ese modesto cambio solo el objeto (esperamos que un objeto pequeño agradable) persistaría y el costo de finalización se minimiza.

Por último, los objetos que necesitan finalizar el trabajo de creación para el subproceso de finalizador. Si el proceso de finalización es complejo, el subproceso de finalizador y el único pasará mucho tiempo realizando esos pasos, lo que puede provocar un trabajo pendiente de trabajo y, por lo tanto, hacer que más objetos permanezcan esperando la finalización. Por lo tanto, es vitalmente importante que los finalizadores realicen el menor trabajo posible. Recuerde también que, aunque todos los punteros de objeto permanecen válidos durante la finalización, puede ser el caso de que esos punteros conducen a objetos que ya se han finalizado y, por lo tanto, pueden ser menos que útiles. Por lo general, es más seguro evitar seguir punteros de objeto en el código de finalización aunque los punteros sean válidos. Una ruta de acceso de código de finalización corta y segura es la mejor.

IDisposable y Dispose

En muchos casos, es posible que los objetos que, de lo contrario, siempre deban finalizarse para evitar ese costo mediante la implementación de la interfaz IDisposable . Esta interfaz proporciona un método alternativo para reclamar recursos cuya duración es conocida para el programador y que realmente sucede un poco. Por supuesto, es mejor aún si los objetos simplemente usan memoria y, por lo tanto, no requieren ninguna finalización ni eliminación en absoluto; pero si la finalización es necesaria y hay muchos casos en los que la administración explícita de los objetos es fácil y práctica, la implementación de la interfaz IDisposable es una excelente manera de evitar, o al menos reducir los costos de finalización.

En el análisis de C#, este patrón puede ser bastante útil:

class X:  IDisposable
{
   public X(…)
   {
   … initialize resources … 
   }

   ~X()
   {
   … release resources … 
   }

   public void Dispose()
   {
// this is the same as calling ~X()
        Finalize(); 

// no need to finalize later
System.GC.SuppressFinalize(this); 
   }
};

Cuando una llamada manual a Dispose obvia la necesidad de que el recopilador mantenga activo el objeto y llame al finalizador.

Conclusión

El recolector de elementos no utilizados de .NET proporciona un servicio de asignación de alta velocidad con un buen uso de memoria y sin problemas de fragmentación a largo plazo, pero es posible hacer cosas que le proporcionarán mucho menos que un rendimiento óptimo.

Para sacar el máximo partido del asignador, debe tener en cuenta prácticas como las siguientes:

  • Asigne toda la memoria (o tanto como sea posible) que se usará con una estructura de datos determinada al mismo tiempo.
  • Quite las asignaciones temporales que se pueden evitar con poca penalización en complejidad.
  • Minimice el número de veces que se escriben punteros de objeto, especialmente aquellas escrituras realizadas en objetos más antiguos.
  • Reduzca la densidad de punteros en las estructuras de datos.
  • Utilice limitado los finalizadores y, a continuación, solo en objetos "hoja", tanto como sea posible. Interrumpa los objetos si es necesario para ayudar con esto.

Una práctica habitual de revisar las estructuras de datos clave y realizar perfiles de uso de memoria con herramientas como El generador de perfiles de asignación hará un largo camino para mantener eficaz el uso de memoria y hacer que el recolector de elementos no utilizados funcione mejor para usted.