Recolección de basura en C ++ – ¿por qué?

Sigo escuchando gente quejándose de que C ++ no tiene recolección de basura. También escuché que el Comité de Estándares de C ++ está buscando agregarlo al idioma. Me temo que simplemente no veo el punto … usar RAII con punteros inteligentes elimina la necesidad de hacerlo, ¿verdad?

Mi única experiencia con la recolección de basura fue en un par de computadoras hogareñas baratas de los ochenta, donde eso significaba que el sistema se congelaría por unos segundos cada cierto tiempo. Estoy seguro de que ha mejorado desde entonces, pero como puedes adivinar, eso no me dejó una alta opinión al respecto.

¿Qué ventajas podría ofrecer la recolección de basura a un desarrollador experimentado de C ++?

Sigo escuchando gente quejándose de que C ++ no tiene recolección de basura.

Lo siento mucho por ellos. Seriamente.

C ++ tiene RAII, y siempre me quejo de no encontrar RAII (o RAII castrada) en los lenguajes recogidos de basura.

¿Qué ventajas podría ofrecer la recolección de basura a un desarrollador experimentado de C ++?

Otra herramienta.

Matt J lo escribió bastante bien en su publicación ( Garbage Collection in C ++ – ¿por qué? ): No necesitamos las características de C ++ ya que la mayoría de ellas podrían codificarse en C, y no necesitamos las funciones de C ya que la mayoría de ellas podrían codificado en Asamblea, etc. C ++ debe evolucionar.

Como desarrollador: no me importa GC. Probé tanto RAII como GC, y considero que RAII es muy superior. Como dijo Greg Rogers en su publicación ( Garbage Collection en C ++, ¿por qué? ), Las pérdidas de memoria no son tan terribles (al menos en C ++, donde son raras si realmente se usa C ++) como para justificar GC en lugar de RAII. GC tiene desasignación / finalización no determinista y es solo una forma de escribir un código que simplemente no se preocupa por las opciones de memoria específicas .

Esta última oración es importante: es importante escribir un código que “no me importa”. De la misma manera en C ++ RAII no nos importa la liberación de recursos porque RAII lo hace por nosotros, o por inicialización de objetos porque el constructor lo hace por nosotros, a veces es importante simplemente codificar sin importar quién es el dueño de qué memoria, y qué tipo de puntero (compartido, débil, etc.) necesitamos para este o este fragmento de código. Parece que hay una necesidad de GC en C ++. (incluso si yo personalmente no lo veo)

Un ejemplo de buen uso de GC en C ++

A veces, en una aplicación, tienes “datos flotantes”. Imagine una estructura de datos similar a un árbol, pero nadie es realmente el “propietario” de los datos (ya nadie le importa realmente cuándo se destruirá exactamente). Múltiples objetos pueden usarlo y luego descartarlo. Desea que se libere cuando ya nadie lo esté usando.

El enfoque C ++ usa un puntero inteligente. El boost :: shared_ptr viene a la mente. Por lo tanto, cada elemento de datos es propiedad de su propio puntero compartido. Guay. El problema es que cada pieza de datos puede referirse a otra pieza de datos. No puede usar punteros compartidos porque están utilizando un contador de referencia, que no admitirá referencias circulares (A señala a B y B apunta a A). Por lo tanto, debe saber mucho sobre dónde usar punteros débiles (boost :: weak_ptr) y cuándo usar punteros compartidos.

Con un GC, solo usa los datos estructurados de árbol.

La desventaja es que no debes preocuparte cuando los “datos flotantes” realmente serán destruidos. Solo que será destruido.

Conclusión

Así que, al final, si se hace correctamente y es compatible con las expresiones idiomáticas actuales de C ++, GC sería una herramienta más para C ++ .

C ++ es un lenguaje multiparadigm: agregar un GC quizás haga llorar a algunos fanáticos de C ++ por traición, pero al final, podría ser una buena idea, y supongo que el C ++ Standards Comitee no permitirá que este tipo de característica principal rompa el idioma, por lo que podemos confiar en que hagan el trabajo necesario para habilitar un C ++ GC correcto que no interfiera con C ++: como siempre en C ++, si no necesita una función, no la use y le costará nada.

La respuesta corta es que la recolección de basura es muy similar en principio a RAII con punteros inteligentes. Si cada pieza de memoria que alguna vez ha asignado se encuentra dentro de un objeto, y ese objeto solo se conoce mediante punteros inteligentes, tiene algo cercano a la recolección de basura (potencialmente mejor). La ventaja viene de no tener que ser tan juiciosos sobre el scope y el punteo inteligente de cada objeto, y dejar que el tiempo de ejecución haga el trabajo por usted.

Esta pregunta parece análoga a “¿Qué tiene que ofrecer C ++ al desarrollador de ensamblaje experimentado? Las instrucciones y subrutinas eliminan la necesidad de hacerlo, ¿no?”

Con el advenimiento de los buenos revisores de memoria como valgrind, no veo mucho uso para la recolección de basura como una red de seguridad “por si acaso”, olvidamos desasignar algo, especialmente porque no ayuda mucho en la gestión del caso más genérico de recursos. que no sea la memoria (aunque estos son mucho menos comunes). Además, asignar y desasignar explícitamente la memoria (incluso con punteros inteligentes) es bastante raro en el código que he visto, ya que los contenedores son una forma mucho más simple y mejor en general.

Pero la recolección de basura puede ofrecer beneficios de rendimiento potencialmente, especialmente si se están asignando montones de objetos de vida corta. GC también ofrece potencialmente una mejor localidad de referencia para los objetos recién creados (comparable a los objetos en la stack).

El factor motivador para el soporte de GC en C ++ parece ser la progtwigción lambda, las funciones anónimas, etc. Resulta que las bibliotecas lambda se benefician de la capacidad de asignar memoria sin preocuparse por la limpieza. El beneficio para los desarrolladores comunes sería una comstackción de bibliotecas lambda más simple, más confiable y más rápida.

GC también ayuda a simular la memoria infinita; la única razón por la que necesita eliminar POD es que necesita reciclar la memoria. Si tiene GC o memoria infinita, ya no es necesario eliminar POD.

El comité no está agregando recolección de basura, están agregando un par de características que permiten que la recolección de basura se implemente de manera más segura. Solo el tiempo dirá si realmente tienen algún efecto sobre los comstackdores futuros. Las implementaciones específicas pueden variar ampliamente, pero lo más probable es que impliquen una recolección basada en la accesibilidad, lo que podría implicar un pequeño locking, dependiendo de cómo se haga.

Sin embargo, una cosa es que ningún recolector de basura que cumpla con los estándares podrá invocar destructores, solo para reutilizar silenciosamente la memoria perdida.

¿Qué ventajas podría ofrecer la recolección de basura a un desarrollador experimentado de C ++?

No tener que buscar fugas de recursos en el código de sus colegas con menos experiencia.

No entiendo cómo se puede argumentar que RAII reemplaza GC, o es muy superior. Hay muchos casos manejados por un gc que RAII simplemente no puede tratar en absoluto. Ellos son diferentes bestias.

En primer lugar, RAII no es a prueba de balas: funciona en contra de algunas fallas comunes que son omnipresentes en C ++, pero hay muchos casos en los que RAII no ayuda en absoluto; es frágil a eventos asincrónicos (como señales bajo UNIX). Fundamentalmente, RAII se basa en el scope: cuando una variable está fuera del scope, se libera automáticamente (suponiendo que el destructor se implemente correctamente, por supuesto).

Aquí hay un ejemplo simple donde ni auto_ptr ni RAII pueden ayudarte:

 #include  #include  #include  #include  #include  using namespace std; volatile sig_atomic_t got_sigint = 0; class A { public: A() { printf("ctor\n"); }; ~A() { printf("dtor\n"); }; }; void catch_sigint (int sig) { got_sigint = 1; } /* Emulate expensive computation */ void do_something() { sleep(3); } void handle_sigint() { printf("Caught SIGINT\n"); exit(EXIT_FAILURE); } int main (void) { A a; auto_ptr aa(new A); signal(SIGINT, catch_sigint); while (1) { if (got_sigint == 0) { do_something(); } else { handle_sigint(); return -1; } } } 

El destructor de A nunca será llamado. Por supuesto, es un ejemplo artificial y algo artificial, pero una situación similar puede ocurrir realmente; por ejemplo, cuando su código es llamado por otro código que maneja SIGINT y del cual no tiene ningún control (ejemplo concreto: extensiones mex en matlab). Es la misma razón por la que finalmente en python no garantiza la ejecución de algo. Gc puede ayudarte en este caso.

Otros modismos no funcionan bien con esto: en cualquier progtwig no trivial, necesitarás objetos con estado (estoy usando la palabra objeto en un sentido muy amplio aquí, puede ser cualquier construcción permitida por el lenguaje); si necesita controlar el estado fuera de una función, no puede hacer eso fácilmente con RAII (razón por la cual RAII no es tan útil para la progtwigción asincrónica). OTOH, gc tiene una vista de toda la memoria de su proceso, es decir, conoce todos los objetos que asignó y puede limpiar de forma asincrónica.

También puede ser mucho más rápido usar gc, por las mismas razones: si necesita asignar / desasignar muchos objetos (en particular objetos pequeños), gc superará ampliamente a RAII, a menos que escriba un asignador personalizado, ya que el gc puede asignar / Limpia muchos objetos en un solo pase. Algunos proyectos bien conocidos de C ++ usan gc, incluso cuando el rendimiento es importante (ver por ejemplo Tim Sweenie sobre el uso de gc en Unreal Tournament: http://lambda-the-ultimate.org/node/1277 ). GC básicamente aumenta el rendimiento a costa de la latencia.

Por supuesto, hay casos en que RAII es mejor que gc; en particular, el concepto de GC está principalmente relacionado con la memoria, y ese no es el único recurso. Cosas como archivos, etc … pueden manejarse bien con RAII. Los lenguajes sin manejo de memoria como python o ruby ​​tienen algo como RAII para esos casos, BTW (con instrucción en python). RAII es muy útil cuando necesita controlar cuándo se libera el recurso, y eso es lo que sucede con frecuencia con los archivos o lockings, por ejemplo.

Es un error común suponer que debido a que C ++ no tiene recogido basura en el idioma , no puede usar la recolección de basura en el período C ++. Esto no tiene sentido. Conozco a los progtwigdores elitistas de C ++ que utilizan el recostackdor Boehm como una cuestión de rutina en su trabajo.

La recolección de basura permite posponer la decisión sobre quién posee un objeto.

C ++ usa semántica de valores, por lo que con RAII, de hecho, los objetos se recogen cuando salen del scope. Esto a veces se denomina “GC inmediato”.

Cuando su progtwig comienza a usar semántica de referencia (a través de punteros inteligentes, etc.), el idioma ya no lo admite, queda al ingenio de su biblioteca de punteros inteligentes.

Lo difícil de GC es decidir cuándo un objeto ya no se necesita.

La recolección de basura hace que la sincronización RCU sin lockings sea mucho más fácil de implementar de manera correcta y eficiente.

Mayor seguridad y escalabilidad de hilos

Hay una propiedad de GC que puede ser muy importante en algunos escenarios. La asignación de puntero es naturalmente atómica en la mayoría de las plataformas, mientras que la creación de punteros de referencia contados (“inteligentes”) seguros para subprocesos es bastante difícil e introduce una sobrecarga significativa de sincronización. Como resultado, a los punteros inteligentes a menudo se les dice “no escalar bien” en la architecture multi-core.

La recolección de basura es realmente la base para la administración automática de recursos. Y tener GC cambia la forma en que abordas los problemas de una manera que es difícil de cuantificar. Por ejemplo, cuando está haciendo una gestión de recursos manual, necesita:

  • Considere cuándo se puede liberar un artículo (¿todos los módulos / clases terminaron con él?)
  • Considere quién es la responsabilidad de liberar un recurso cuando esté listo para ser liberado (¿qué clase / módulo debería liberar este artículo?)

En el caso trivial no hay complejidad. Por ejemplo, abre un archivo al comienzo de un método y lo cierra al final. O la persona que llama debe liberar este bloque de memoria devuelto.

Las cosas empiezan a complicarse rápidamente cuando tiene múltiples módulos que interactúan con un recurso y no está claro quién debe limpiar. El resultado final es que todo el enfoque para abordar un problema incluye ciertos patrones de progtwigción y diseño que son un compromiso.

En los idiomas que tienen recolección de basura puede usar un patrón desechable donde puede liberar recursos que sabe que ha terminado, pero si no los libera, el GC está allí para salvar el día.


Punteros inteligentes, que en realidad es un ejemplo perfecto de los compromisos que mencioné. Los punteros inteligentes no pueden evitar que se filtren estructuras de datos cíclicas a menos que tenga un mecanismo de respaldo. Para evitar este problema, a menudo se compromete y evita el uso de una estructura cíclica, aunque de lo contrario podría ser la mejor opción.

Yo también tengo dudas de que el comité de C ++ esté agregando una colección completa de basura al estándar.

Pero diría que la razón principal para agregar / tener recolección de basura en un lenguaje moderno es que hay muy pocas buenas razones contra la recolección de basura. Desde los años ochenta hubo varios avances enormes en el campo de la administración de memoria y la recolección de basura, y creo que incluso hay estrategias de recolección de basura que podrían ofrecerle garantías en tiempo real (por ejemplo, “GC no tomará más que … .. En el peor de los casos”).

El uso de RAII con punteros inteligentes elimina la necesidad de hacerlo, ¿verdad?

Los punteros inteligentes se pueden usar para implementar el recuento de referencias en C ++, que es una forma de recolección de basura (administración automática de memoria), pero los GC de producción ya no usan el recuento de referencias porque tiene algunas deficiencias importantes:

  1. El recuento de referencias pierde ciclos. Considere A↔B, ambos objetos A y B se refieren entre sí, por lo que ambos tienen un recuento de referencia de 1 y ninguno se recostack, pero ambos deben recuperarse. Algoritmos avanzados como la eliminación de prueba resuelven este problema pero agregan mucha complejidad. El uso de weak_ptr como una solución está volviendo a la gestión de memoria manual.

  2. El conteo de referencias ingenuo es lento por varias razones. En primer lugar, requiere que los recuentos de referencia fuera del caché se realicen a menudo (consulte la columna compartida de Boost hasta 10 veces más lenta que la recolección de basura de OCaml ). En segundo lugar, los destructores inyectados al final del scope pueden incurrir en llamadas a funciones virtuales innecesarias y costosas e nhibernate optimizaciones como la eliminación de llamadas finales.

  3. El conteo de referencias basado en el scope mantiene la basura flotante como objetos que no se reciclan hasta el final del scope, mientras que los GC de seguimiento pueden reclamarlos tan pronto como se vuelven inalcanzables, por ejemplo, ¿se puede recuperar un local antes de recuperar un bucle?

¿Qué ventajas podría ofrecer la recolección de basura a un desarrollador experimentado de C ++?

La productividad y la fiabilidad son los principales beneficios. Para muchas aplicaciones, la administración manual de la memoria requiere un esfuerzo significativo del progtwigdor. Al simular una máquina de memoria infinita, la recolección de basura libera al progtwigdor de esta carga, lo que le permite concentrarse en la resolución de problemas y evade algunas clases importantes de errores (punteros colgantes, falta de free , doble free ). Además, la recolección de basura facilita otras formas de progtwigción, por ejemplo, resolviendo el problema del filo ascendente (1970) .

En un marco que admite GC, una referencia a un objeto inmutable como una cadena se puede pasar de la misma manera que una primitiva. Considere la clase (C # o Java):

 public class MaximumItemFinder { String maxItemName = ""; int maxItemValue = -2147483647 - 1; public void AddAnother(int itemValue, String itemName) { if (itemValue >= maxItemValue) { maxItemValue = itemValue; maxItemName = itemName; } } public String getMaxItemName() { return maxItemName; } public int getMaxItemValue() { return maxItemValue; } } 

Tenga en cuenta que este código nunca tiene que ver con el contenido de ninguna de las cadenas, y simplemente puede tratarlas como primitivas. Una statement como maxItemName = itemName; probablemente genere dos instrucciones: una carga de registro seguida de una tienda de registro. El MaximumItemFinder no tendrá forma de saber si las personas que llaman a AddAnother retendrán cualquier referencia a las cadenas transferidas, y las personas que llaman no tendrán manera de saber por cuánto tiempo MaximumItemFinder retendrá referencias a ellas. Las personas que llaman de getMaxItemName no tendrán forma de saber si y cuándo MaximumItemFinder y el proveedor original de la cadena devuelta han abandonado todas las referencias a la misma. Sin embargo, dado que el código simplemente puede pasar referencias de cadena alrededor de valores primitivos, ninguna de esas cosas es importante .

Tenga en cuenta también que aunque la clase anterior no sería segura para subprocesos en presencia de llamadas simultáneas a AddAnother , se garantizaría que cualquier llamada a GetMaxItemName devolvería una referencia válida a una cadena vacía o una de las cadenas que se habían pasado a AddAnother . La sincronización de subprocesos sería necesaria si se quisiera garantizar cualquier relación entre el nombre del elemento máximo y su valor, pero la seguridad de la memoria está asegurada incluso en su ausencia .

No creo que haya ninguna forma de escribir un método como el anterior en C ++ que defienda la seguridad de la memoria en presencia de uso arbitrario de múltiples subprocesos sin utilizar la sincronización de subprocesos o requiriendo que cada variable de cadena tenga su propia copia de sus contenidos , en su propio espacio de almacenamiento, que no puede ser liberado o reubicado durante la vida de la variable en cuestión. Ciertamente, no sería posible definir un tipo de referencia de cadena que podría definirse, asignarse y pasarse tan barato como un int .

La recolección de basura puede hacer que las filtraciones sean su peor pesadilla

El GC completo que maneja cosas como las referencias cíclicas sería algo así como una actualización sobre un shared_ptr ref- shared_ptr . Me gustaría de alguna manera darle la bienvenida en C ++, pero no en el nivel de idioma.

Una de las bellezas de C ++ es que no fuerza la recolección de basura sobre usted.

Quiero corregir una idea errónea común: un mito de la recolección de basura que de alguna manera elimina las filtraciones. Desde mi experiencia, las peores pesadillas del código de depuración escrito por otros y tratando de detectar las fugas lógicas más costosas implican la recolección de basura con lenguajes como Python integrado a través de una aplicación de host que hace un uso intensivo de recursos.

Cuando se habla de temas como GC, hay teoría y luego hay práctica. En teoría, es maravilloso y evita fugas. Sin embargo, en el nivel teórico, todos los idiomas son maravillosos y libres de fugas, ya que, en teoría, todos escribirían un código perfectamente correcto y evaluarían cada uno de los casos posibles en los que una sola parte del código podría fallar.

La recolección de basura combinada con una colaboración en equipo no ideal causó las peores fugas difíciles de depurar en nuestro caso.

El problema aún tiene que ver con la propiedad de los recursos. Aquí tiene que tomar decisiones de diseño claras cuando se trata de objetos persistentes, y la recolección de basura hace que sea demasiado fácil pensar que no.

Dados algunos recursos, R , en un ambiente de equipo donde los desarrolladores no se comunican constantemente y revisan el código de los demás en todo momento (algo demasiado común en mi experiencia), es bastante fácil para el desarrollador A guardar un identificador para ese recurso. El desarrollador B hace, quizás de una manera oscura que indirectamente agrega R a alguna estructura de datos. Lo mismo hace C En un sistema recolectado de basura, esto ha creado 3 propietarios de R

Debido a que el desarrollador A fue quien creó originalmente el recurso y cree que es el propietario del mismo, recuerda liberar la referencia a R cuando el usuario indica que ya no desea usarlo. Después de todo, si no lo hace, no pasará nada y sería evidente por las pruebas que la lógica de eliminación del extremo del usuario no hizo nada. Entonces recuerda lanzarlo, como haría cualquier desarrollador razonablemente competente. Esto desencadena un evento para el cual B maneja y también recuerda liberar la referencia a R

Sin embargo, C olvida. No es uno de los desarrolladores más fuertes en el equipo: un recluta algo nuevo que solo ha trabajado en el sistema durante un año. O tal vez ni siquiera está en el equipo, solo un desarrollador de terceros popular que escribe complementos para nuestro producto que muchos usuarios agregan al software. Con la recolección de basura, es cuando obtenemos esas fugas silenciosas de recursos lógicos. Son del peor tipo: no se manifiestan necesariamente en el lado visible del usuario del software como un error obvio, además del hecho de que durante la duración del funcionamiento del progtwig, el uso de la memoria simplemente sigue aumentando y aumentando con algún misterioso propósito. Tratar de reducir estos problemas con un depurador puede ser tan divertido como depurar una condición de carrera sensible al tiempo.

Sin recolección de basura, el desarrollador C habría creado un puntero colgante . Puede intentar acceder a él en algún momento y hacer que el progtwig se bloquee. Ahora que es un error de prueba / visible para el usuario. C se avergüenza un poco y corrige su error. En el escenario del GC, solo intentar descubrir dónde se está filtrando el sistema puede ser tan difícil que algunas de las fugas nunca se corrigen. Estas no son fugas físicas de tipo valgrind que pueden detectarse fácilmente y ubicarse en una línea específica de código.

Con la recolección de basura, el desarrollador C ha creado una fuga muy misteriosa. Su código puede continuar accediendo a R que ahora es solo una entidad invisible en el software, irrelevante para el usuario en este punto, pero aún en estado válido. Y a medida que el código de C crea más filtraciones, está creando más procesamiento oculto en recursos irrelevantes, y el software no solo está perdiendo memoria sino que también se está volviendo cada vez más lento.

Así que la recolección de basura no necesariamente mitiga las fugas de recursos lógicos. Puede, en escenarios menos que ideales, hacer que las filtraciones sean mucho más fáciles de pasar desapercibidas y permanezcan en el software. Los desarrolladores pueden sentirse tan frustrados tratando de rastrear sus pérdidas lógicas de GC que simplemente les dicen a sus usuarios que reinicien el software periódicamente como una solución alternativa. Elimina punteros colgantes, y en un software obsesionado con la seguridad donde el locking es completamente inaceptable bajo cualquier escenario, entonces preferiría GC. Pero a menudo estoy trabajando en productos menos críticos para la seguridad pero de uso intensivo de recursos, donde un accidente que se puede reparar rápidamente es preferible a un error silencioso misterioso y oscuro, y las filtraciones de recursos no son errores triviales.

En ambos casos, estamos hablando de objetos persistentes que no residen en la stack, como un gráfico de escena en un software 3D o los videoclips disponibles en un compositor o los enemigos en un mundo de juego. Cuando los recursos vinculan sus vidas a la stack, tanto C ++ como cualquier otro lenguaje de GC tienden a hacer que sea trivial administrar los recursos correctamente. La verdadera dificultad radica en recursos persistentes que hacen referencia a otros recursos.

En C o C ++, puede tener punteros colgantes y lockings resultantes de segfaults si no puede designar claramente a quién pertenece un recurso y cuándo debe liberarlos (por ejemplo: se establece en nulo en respuesta a un evento). Sin embargo, en GC, ese choque fuerte y desagradable pero a menudo fácil de detectar se intercambia por una fuga silenciosa de recursos que puede que nunca se detecte.