¿Cuáles son las complejidades de la programación no administrada en memoria?

24

O, en otras palabras, ¿qué problemas específicos resolvió la recolección automatizada de basura? Nunca he hecho programación de bajo nivel, por lo que no sé cuán complicado puede ser liberar recursos.

El tipo de errores que aborda GC parece (al menos para un observador externo) el tipo de cosas que un programador que conoce bien su lenguaje, bibliotecas, conceptos, modismos, etc., no haría. Pero podría estar equivocado: ¿el manejo manual de la memoria es intrínsecamente complicado?

vemv
fuente
3
Expanda para decirnos cómo su pregunta no es respondida por el artículo de Wikipedia sobre la colección garbace y más específicamente la sección sobre sus beneficios
yannis
Otro beneficio es la seguridad, por ejemplo, los desbordamientos de búfer son altamente explotables y muchas otras vulnerabilidades de seguridad surgen de la gestión de memoria (incorrecta).
StuperUser
77
@StuperUser: Eso no tiene nada que ver con el origen de la memoria. Puede amortiguar la memoria de desbordamiento que vino de un GC muy bien. El hecho de que los idiomas de GC usualmente eviten esto es ortogonal, y los idiomas que están menos de treinta años detrás de la tecnología de GC los está comparando para ofrecer también protección contra el desbordamiento del búfer.
DeadMG

Respuestas:

29

Nunca he hecho programación de bajo nivel, por lo que no sé cuán complicado puede ser liberar recursos.

Es curioso cómo la definición de "bajo nivel" cambia con el tiempo. Cuando estaba aprendiendo a programar, cualquier lenguaje que proporcionara un modelo de montón estandarizado que hiciera posible un patrón simple de asignación / libre se consideró de alto nivel. En la programación de bajo nivel , tendría que hacer un seguimiento de la memoria usted mismo (¡no las asignaciones, sino las ubicaciones de memoria en sí mismas!), O escribir su propio asignador de montón si se siente realmente elegante.

Dicho esto, en realidad no hay nada de miedo o "complicado" al respecto. ¿Recuerdas cuando eras niño y tu madre te dijo que guardaras tus juguetes cuando termines de jugar con ellos, que ella no es tu criada y que no iba a limpiar tu habitación por ti? La gestión de la memoria es simplemente este mismo principio aplicado al código. (GC es como tener una criada que será limpiar después de ti, pero ella es muy perezoso y un poco desorientado.) El principio de que es simple: cada variable en el código tiene uno y sólo uno de los propietarios, y es la responsabilidad de que el propietario de liberar la memoria de la variable cuando ya no sea necesaria. ( El principio de propiedad única) Esto requiere una llamada por asignación, y existen varios esquemas que automatizan la propiedad y la limpieza de una forma u otra para que ni siquiera tenga que escribir esa llamada en su propio código.

Se supone que la recolección de basura resuelve dos problemas. Invariablemente hace un muy mal trabajo en uno de ellos, y dependiendo de la implementación puede o no funcionar bien con el otro. Los problemas son pérdidas de memoria (conservar la memoria después de que haya terminado con ella) y referencias colgantes (liberar memoria antes de que haya terminado con ella). Veamos ambos problemas:

Referencias colgantes: Discutir esto primero porque es realmente serio. Tienes dos punteros para el mismo objeto. Libera uno de ellos y no se da cuenta del otro. Luego, en algún momento posterior, intentas leer (o escribir o liberar) el segundo. Se produce un comportamiento indefinido. Si no lo nota, puede dañar fácilmente su memoria. Se supone que la recolección de basura hace que este problema sea imposible al garantizar que nunca se libere nada hasta que desaparezcan todas las referencias. En un lenguaje totalmente administrado, esto casi funciona, hasta que tenga que lidiar con recursos de memoria externos no administrados. Luego vuelve a la casilla 1. Y en un lenguaje no administrado, las cosas son aún más complicadas. (Husmear en Mozilla '

Afortunadamente, lidiar con este problema es básicamente un problema resuelto. No necesita un recolector de basura, necesita un administrador de memoria de depuración. Utilizo Delphi, por ejemplo, y con una única biblioteca externa y una simple directiva de compilación puedo configurar el asignador en "Modo de depuración completa". Esto agrega una sobrecarga de rendimiento insignificante (menos del 5%) a cambio de habilitar algunas funciones que realizan un seguimiento de la memoria utilizada. Si libero un objeto, llena su memoria con0x80bytes (fácilmente reconocible en el depurador) y si alguna vez intento llamar a un método virtual (incluido el destructor) en un objeto liberado, se da cuenta e interrumpe el programa con un cuadro de error con tres trazas de pila, cuando se creó el objeto, cuándo fue liberado y dónde estoy ahora, además de alguna otra información útil, plantea una excepción. Obviamente, esto no es adecuado para las versiones de lanzamiento, pero hace que el seguimiento y la solución de problemas de referencia sean triviales.

El segundo problema son las pérdidas de memoria. Esto es lo que sucede cuando continúa reteniendo la memoria asignada cuando ya no la necesita. Puede suceder en cualquier idioma, con o sin recolección de basura, y solo se puede arreglar escribiendo su código correctamente. La recolección de basura ayuda a mitigar una forma específica de pérdida de memoria, del tipo que ocurre cuando no hay referencias válidas a una pieza de memoria que aún no se ha liberado, lo que significa que la memoria permanece asignada hasta que finaliza el programa. Desafortunadamente, la única forma de lograr esto de manera automática es convirtiendo cada asignación en una pérdida de memoria.

Probablemente voy a ser criticado por los defensores de GC si trato de decir algo así, así que permítame explicarlo. Recuerde que la definición de una pérdida de memoria se mantiene en la memoria asignada cuando ya no la necesita. Además de no tener referencias a algo, también puede perder memoria al tener una referencia innecesaria a él, como sostenerlo en un objeto contenedor cuando debería haberlo liberado. He visto algunas pérdidas de memoria causadas por esto, y son muy difíciles de rastrear si tiene un GC o no, ya que implican una referencia perfectamente válida a la memoria y no hay "errores" claros para las herramientas de depuración. captura. Hasta donde sé, no existe una herramienta automatizada que le permita detectar este tipo de pérdida de memoria.

Por lo tanto, un recolector de basura solo se preocupa por la variedad de pérdidas de memoria sin referencias, porque ese es el único tipo que se puede tratar de manera automatizada. Si pudiera ver todas sus referencias a todo y liberar cada objeto tan pronto como tenga cero referencias apuntando a él, sería perfecto, al menos con respecto al problema de no referencias. Hacer esto de manera automatizada se llama recuento de referencias, y se puede hacer en algunas situaciones limitadas, pero tiene sus propios problemas que resolver. (Por ejemplo, el objeto A que contiene una referencia al objeto B, que contiene una referencia al objeto A. En un esquema de conteo de referencias, ninguno de los objetos puede liberarse automáticamente, incluso cuando no hay referencias externas a A o B). los recolectores de basura usan el rastreoen su lugar: comience con un conjunto de objetos conocidos, encuentre todos los objetos a los que hacen referencia, encuentre todos los objetos a los que hacen referencia, y así sucesivamente hasta que haya encontrado todo. Lo que no se encuentra en el proceso de rastreo es basura y se puede tirar a la basura. (Hacer esto con éxito, por supuesto, requiere un lenguaje administrado que imponga ciertas restricciones en el sistema de tipos para garantizar que el recolector de basura de rastreo siempre pueda diferenciar entre una referencia y una pieza de memoria aleatoria que parece un puntero).

Hay dos problemas con el rastreo. Primero, es lento y, mientras está sucediendo, el programa tiene que estar más o menos en pausa para evitar las condiciones de carrera. Esto puede conducir a problemas notables de ejecución cuando se supone que el programa está interactuando con un usuario, o un rendimiento empantanado en una aplicación de servidor. Esto puede mitigarse mediante diversas técnicas, como dividir la memoria asignada en "generaciones" con el principio de que si una asignación no se recopila la primera vez que lo intentas, es probable que se quede por un tiempo. Tanto el framework .NET como la JVM usan recolectores de basura generacionales.

Desafortunadamente, esto alimenta el segundo problema: la memoria no se libera cuando termina. A menos que el rastreo se ejecute inmediatamente después de que termine con un objeto, se mantendrá hasta el próximo rastro, o incluso más si supera la primera generación. De hecho, una de las mejores explicaciones del recolector de basura .NET que he visto explica que, para que el proceso sea lo más rápido posible, ¡el GC tiene que aplazar la recolección el mayor tiempo posible! Por lo tanto, el problema de las pérdidas de memoria se "resuelve" de forma bastante extraña al perder la mayor cantidad de memoria posible durante el mayor tiempo posible. Esto es lo que quiero decir cuando digo que un GC convierte cada asignación en una pérdida de memoria. De hecho, no hay ninguna garantía de que cualquier objeto dado jamás ser recogida.

¿Por qué es esto un problema, cuando la memoria aún se recupera cuando es necesario? Por un par de razones Primero, imagine asignar un objeto grande (un mapa de bits, por ejemplo) que requiere una cantidad significativa de memoria. Y luego, una vez que haya terminado, necesita otro objeto grande que tome la misma cantidad de memoria (o casi la misma). Si el primer objeto hubiera sido liberado, el segundo puede reutilizar su memoria. Pero en un sistema de recolección de basura, es posible que todavía esté esperando que se ejecute el siguiente rastreo, por lo que terminará desperdiciando innecesariamente la memoria para un segundo objeto grande. Básicamente es una condición de carrera.

En segundo lugar, mantener la memoria innecesariamente, especialmente en grandes cantidades, puede causar problemas en un sistema moderno multitarea. Si consume demasiada memoria física, puede causar que su programa u otros programas tengan que buscar (intercambie parte de su memoria en el disco), lo que realmente ralentiza las cosas. Para ciertos sistemas, como los servidores, la paginación no solo puede ralentizar el sistema, sino que puede bloquear todo si está bajo carga.

Al igual que el problema de referencias colgantes, el problema de no referencias se puede resolver con un administrador de memoria de depuración. Nuevamente, mencionaré el modo de depuración completa del administrador de memoria FastMM de Delphi, ya que es el que estoy más familiarizado. (Estoy seguro de que existen sistemas similares para otros idiomas).

Cuando finaliza un programa que se ejecuta en FastMM, puede hacer que informe sobre la existencia de todas las asignaciones que nunca se liberaron. El modo de depuración completa va un paso más allá: puede guardar un archivo en un disco que contenga no solo el tipo de asignación, sino también un seguimiento de la pila desde el momento en que se asignó y otra información de depuración, para cada asignación filtrada. Esto hace que el rastreo de pérdidas de memoria sin referencias sea trivial.

Cuando realmente lo miras, la recolección de basura puede o no funcionar bien al evitar referencias colgantes, y universalmente hace un mal trabajo al manejar las pérdidas de memoria. Su única virtud, de hecho, no es la recolección de basura en sí, sino un efecto secundario: proporciona una forma automatizada de realizar la compactación del montón. Esto puede evitar un problema arcano (agotamiento de la memoria a través de la fragmentación del montón) que puede matar los programas que se ejecutan continuamente durante mucho tiempo y tienen un alto grado de pérdida de memoria, y la compactación del montón es prácticamente imposible sin la recolección de basura. Sin embargo, cualquier buen asignador de memoria en estos días usa cubos para minimizar la fragmentación, lo que significa que la fragmentación solo se convierte realmente en un problema en circunstancias extremas. Para un programa en el que es probable que la fragmentación del montón sea un problema, ' Es aconsejable utilizar un recolector de basura compactante. Pero, en cualquier otro caso, el uso de la recolección de basura es una optimización prematura, y existen mejores soluciones a los problemas que "resuelve".

Mason Wheeler
fuente
55
Me encanta esta respuesta, sigo leyéndola de vez en cuando. No puedo hacer una observación relevante, así que todo lo que puedo decir es: gracias.
vemv
3
Me gustaría señalar que sí, los GC tienden a "perder" memoria (al menos por un tiempo), pero esto no es un problema porque recogerá la memoria cuando el asignador de memoria no puede asignar memoria antes de la recopilación. Con un lenguaje que no sea GC, una fuga siempre se mantiene, lo que significa que en realidad puede quedarse sin memoria debido a demasiada memoria no recopilada. "la recolección de basura es una optimización prematura" ... GC no es una optimización y no fue diseñado con eso en mente. De lo contrario, buena respuesta.
Thomas Eding
77
@ThomasEding: GC ciertamente es una optimización; se optimiza para un esfuerzo mínimo del programador, a expensas del rendimiento y varias otras métricas de calidad del programa.
Mason Wheeler
55
Es curioso que apuntes al rastreador de errores de Mozilla en un momento dado, porque Mozilla ha llegado a una conclusión bastante diferente. Firefox tenía y sigue teniendo innumerables problemas de seguridad derivados de errores de administración de memoria. Tenga en cuenta que no se trata de lo fácil que fue corregir el error una vez detectado, por lo general, el daño ya está hecho cuando los desarrolladores se dan cuenta del problema. Mozilla está financiando el lenguaje de programación Rust precisamente para ayudar a evitar que tales errores se introduzcan en primer lugar.
1
Sin embargo, Rust no usa la recolección de basura, usa el recuento de referencias exactamente como lo describe Mason, solo con extensas comprobaciones en tiempo de compilación en lugar de tener que usar un depurador para detectar errores en tiempo de ejecución ...
Sean Burton
13

Considerando una técnica de administración de memoria no recolectada de basura de una era equivalente a la de los recolectores de basura en uso en los sistemas populares actuales, como el RAII de C ++. Dado este enfoque, el costo de no usar la recolección de basura automatizada es mínimo, y GC presenta muchos de sus propios problemas. Como tal, sugeriría que "No mucho" es la respuesta a su problema.

Recuerde, cuando las personas piensan en no GC, piensan mallocy free. Pero esta es una falacia lógica gigante: estaría comparando la gestión de recursos ajenos a GC de principios de la década de 1970 con los recolectores de basura de finales de los 90. Esto es obviamente una comparación- bastante injusto los recolectores de basura que estaban en uso cuando mallocy freefueron diseñados eran demasiado lentos para ejecutar cualquier programa significativo, si no recuerdo mal. Comparar algo de un período de tiempo vagamente equivalente, por ejemplo unique_ptr, es mucho más significativo.

Los recolectores de basura pueden manejar los ciclos de referencia más fácilmente, aunque estas son experiencias bastante raras. Además, los GC pueden simplemente "arrojar" código porque el GC se encargará de toda la administración de memoria, lo que significa que pueden conducir a ciclos de desarrollo más rápidos.

Por otro lado, tienden a encontrarse con problemas masivos cuando se trata de memoria que proviene de cualquier lugar, excepto su propio grupo de GC. Además, pierden muchos de sus beneficios cuando se trata de concurrencia, porque de todos modos hay que tener en cuenta la propiedad del objeto.

Editar: muchas de las cosas que mencionas no tienen nada que ver con GC. Estás confundiendo la gestión de la memoria y la orientación a objetos. Vea, aquí está la cosa: si programa en un sistema completamente no administrado, como C ++, puede tener tantos límites de verificación como desee, y las clases de contenedor estándar lo ofrecen. No hay nada de GC en la verificación de límites, por ejemplo, o la escritura fuerte.

Los problemas que menciona se resuelven por la orientación a objetos, no por GC. El origen de la memoria de matriz y asegurarse de no escribir fuera de ella son conceptos ortogonales.

Editar: Vale la pena señalar que las técnicas más avanzadas pueden evitar la necesidad de cualquier forma de asignación de memoria dinámica. Por ejemplo, considere el uso de esto , que implementa la combinación Y en C ++ sin asignación dinámica en absoluto.

DeadMG
fuente
La discusión extendida aquí se ha limpiado: si todos pueden conversar sobre el tema, realmente lo agradecería.
@DeadMG, ¿sabes qué se supone que debe hacer el combinador? Se supone que COMBINA. Por definición, el combinador es una función sin ninguna variable libre.
SK-logic
2
@ SK-logic: podría haber elegido implementarlo únicamente por plantilla y no tener ninguna variable miembro. Pero entonces no podría aprobar cierres, lo que limita significativamente su utilidad. ¿Quieres venir a chatear?
DeadMG
@DeadMG, una definición es clara como el cristal. No hay variables libres. Considero que cualquier lenguaje es "suficientemente funcional" si es posible definir el combinador Y (correctamente, no a su manera). Un gran "+" es si es posible definirlo a través de los combinadores S, K e I. De lo contrario, el lenguaje no es lo suficientemente expresivo.
SK-logic
44
@ SK-logic: ¿Por qué no vienes al chat , como preguntó el amable moderador? Además, un combinador en Y es un combinador en Y, hace el trabajo o no. La versión Haskell del Y-combinador es básicamente exactamente la misma que esta, es solo que el estado expresado está oculto para usted.
DeadMG
11

La "libertad de tener que preocuparse por liberar recursos" que supuestamente proporcionan los idiomas recolectados de basura es, en gran medida, una ilusión. Sigue agregando cosas en un mapa sin quitar ninguna, y pronto entenderás de lo que estoy hablando.

De hecho, las pérdidas de memoria son bastante frecuentes en los programas escritos en lenguajes GC, porque estos lenguajes tienden a hacer que los programadores sean flojos, y les hacen adquirir una falsa sensación de seguridad de que el lenguaje siempre se ocupará (mágicamente) de alguna manera de cada objeto que No deseo tener que pensar más.

La recolección de basura es simplemente una instalación necesaria para los lenguajes que tienen otro objetivo más noble: tratar todo como un puntero a un objeto y, al mismo tiempo, ocultarle al programador el hecho de que es un puntero, de modo que el programador no puede comprometerse suicidio al intentar la aritmética del puntero y similares. Todo ser un objeto significa que los lenguajes GCed necesitan asignar objetos con mucha más frecuencia que los lenguajes no GCed, lo que significa que si ponen la carga de desasignar esos objetos en el programador, serían inmensamente poco atractivos.

Además, la recolección de basura es útil para proporcionar al programador la capacidad de escribir código estricto, manipulando objetos dentro de expresiones, de una manera de programación funcional, sin tener que dividir las expresiones en declaraciones separadas para proporcionar la desasignación de cada objeto único que participa en la expresión.

Aparte de todo eso, tenga en cuenta que al comienzo de mi respuesta escribí "es en gran medida una ilusión". No escribí que sea una ilusión. Ni siquiera escribí que es principalmente una ilusión. La recolección de basura es útil para quitarle al programador la tarea doméstica de atender la desasignación de sus objetos. Entonces, en este sentido, es una característica de productividad.

Mike Nakis
fuente
4

El recolector de basura no aborda ningún "error". Es una parte necesaria de algunas semánticas de idiomas de alto nivel. Con un GC es posible definir niveles más altos de abstracciones, como cierres léxicos y similares, mientras que con una gestión manual de la memoria esas abstracciones serán permeables, innecesariamente ligadas a los niveles más bajos de la gestión de recursos.

Un "principio de propiedad única", mencionado en los comentarios, es un buen ejemplo de una abstracción tan permeable. Un desarrollador no debe preocuparse en absoluto por el número de enlaces a una instancia de estructura de datos elemental en particular, de lo contrario, cualquier parte del código no sería genérico y transparente sin una gran cantidad de limitaciones y requisitos adicionales (no directamente visibles en el código) . Dicho código no se puede componer en un código de nivel superior, que es una violación intolerable del principio de separación de las capas de responsabilidad (un componente fundamental de la ingeniería del software, desafortunadamente no respetado en absoluto por la mayoría de los desarrolladores de bajo nivel).

SK-logic
fuente
1
@Mason Wheeler, incluso C ++ implementa una forma muy limitada de cierres. Pero no es un cierre adecuado, generalmente utilizable.
SK-logic
1
Te equivocas. Ningún GC puede protegerlo del hecho de que no puede referirse a las variables de la pila. Y es curioso: en C ++, también puede adoptar el enfoque "Copiar un puntero a una variable asignada dinámicamente que se destruirá de manera apropiada y automática".
DeadMG
1
@DeadMG, ¿no ves que tu código está filtrando entidades de bajo nivel a través de cualquier otro nivel que construyas en la parte superior?
SK-logic
1
@ SK-Logic: OK, tenemos un problema de terminología. ¿Cuál es su definición de "cierre real" y qué pueden hacer que los cierres de Delphi no puedan hacer? (E incluir cualquier cosa sobre la administración de memoria en su definición es mover las publicaciones de objetivos. Hablemos sobre el comportamiento, no los detalles de implementación.)
Mason Wheeler
1
@ SK-Logic: ... ¿y tiene un ejemplo de algo que se puede hacer con cierres lambda simples sin tipo que los cierres de Delphi no pueden lograr?
Mason Wheeler
2

Realmente, administrar su propia memoria es solo una fuente potencial más de errores.

Si olvida una llamada a free(o cualquiera que sea el equivalente en el idioma que esté usando), su programa puede pasar todas sus pruebas, pero pierde memoria. Y en un programa moderadamente complejo, es bastante fácil pasar por alto una llamada free.

Dawood dice que reinstalar a Monica
fuente
3
Perderse freeno es lo peor. Temprano freees mucho más devastador.
herby
2
Y el doble free!
quant_dev
Jeje! Yo estaría de acuerdo con los dos comentarios anteriores. Nunca he cometido una de estas transgresiones (hasta donde yo sé), pero puedo ver cuán terribles pueden ser los efectos. La respuesta de quant_dev lo dice todo: los errores con la asignación de memoria y la desasignación son notoriamente difíciles de encontrar y corregir.
Dawood dice que reinstale a Mónica el
1
Esto es una falacia. Estás comparando "principios de 1970" con "finales de 1990". Los GC que existían en ese momento mallocy que freeera el camino a seguir sin GC eran demasiado lentos para ser útiles para cualquier cosa. Debe compararlo con un enfoque moderno que no sea GC, como RAII.
DeadMG
2
@DeadMG RAII no es gestión manual de memoria
quant_dev
2

El recurso manual no solo es tedioso, sino también difícil de depurar. En otras palabras, no solo es tedioso hacerlo bien, sino que también, cuando lo haces mal, no es obvio dónde está el problema. Esto se debe a que, a diferencia de, por ejemplo, la división por cero, los efectos del error aparecen lejos de la fuente del error, y conectar los puntos requiere tiempo, atención y experiencia.

cuant_dev
fuente
1

Creo que la recolección de basura recibe mucho crédito por las mejoras en el lenguaje que no tienen nada que ver con GC, aparte de ser parte de una gran ola de progreso.

El único beneficio sólido para GC que conozco es que puede liberar un objeto en su programa y saber que desaparecerá cuando todos hayan terminado con él. Puede pasarlo al método de otra clase y no preocuparse por ello. No le importa a qué otros métodos se le pasa, ni a qué otras clases se refieren. (Las pérdidas de memoria son responsabilidad de la clase que hace referencia a un objeto, no de la clase que lo creó).

Sin GC, debe realizar un seguimiento del ciclo de vida completo de la memoria asignada. Cada vez que pasa una dirección hacia arriba o hacia abajo desde la subrutina que la creó, tiene una referencia fuera de control a esa memoria. En los viejos tiempos, incluso con un solo hilo, la recursión y un sistema operativo irritante (Windows NT) me impedían controlar el acceso a la memoria asignada. Tuve que manipular el método gratuito en mi propio sistema de asignación para mantener los bloques de memoria durante un tiempo hasta que se borraran todas las referencias. El tiempo de espera era pura conjetura, pero funcionó.

Así que ese es el único beneficio de GC que conozco, pero no podría vivir sin él. No creo que ningún tipo de OOP vuele sin él.

RalphChapin
fuente
1
Justo fuera de mi cabeza, Delphi y C ++ han tenido bastante éxito como lenguajes OOP sin ningún GC. Todo lo que necesita para evitar "referencias fuera de control" es un poco de disciplina. Si comprende el Principio de propiedad única, (vea mi respuesta), los problemas de los que habla aquí se convierten en problemas totales.
Mason Wheeler
@MasonWheeler: Cuando llega el momento de liberar el objeto propietario, necesita conocer todos los lugares a los que se hace referencia a sus objetos propios. Mantener esta información y usarla para eliminar las referencias me parece una gran cantidad de trabajo. A menudo encontré que las referencias aún no podían borrarse. Tuve que marcar al propietario como eliminado, y luego darle vida periódicamente para ver si podía liberarse de forma segura. Nunca he usado Delphi, pero por un pequeño sacrificio en la eficiencia de ejecución C # / Java me dio un gran impulso en el tiempo de desarrollo sobre C ++. (No todo se debe a GC, pero ayudó.)
RalphChapin
1

Fugas físicas

El tipo de errores que aborda GC parece (al menos para un observador externo) el tipo de cosas que un programador que conoce bien su lenguaje, bibliotecas, conceptos, modismos, etc., no haría. Pero podría estar equivocado: ¿el manejo manual de la memoria es intrínsecamente complicado?

Viniendo del extremo C que hace que la gestión de la memoria sea lo más manual y pronunciada posible para que podamos comparar los extremos (C ++ automatiza principalmente la gestión de la memoria sin GC), diría "no realmente" en el sentido de comparar con GC cuando llega a las fugas . Un principiante y, a veces, incluso un profesional pueden olvidarse de escribir freepara un determinado malloc. Definitivamente sucede.

Sin embargo, existen herramientas como la valgrinddetección de fugas que detectarán inmediatamente, al ejecutar el código, cuándo / dónde se producen dichos errores hasta la línea exacta de código. Cuando está integrado en el CI, se vuelve casi imposible fusionar tales errores, y es fácil como corregirlos. Por lo tanto, nunca es un gran problema en ningún equipo / proceso con estándares razonables.

De acuerdo, podría haber algunos casos exóticos de ejecución que pasan desapercibidos cuando freeno se llamaron, tal vez al encontrar un oscuro error de entrada externo como un archivo corrupto, en cuyo caso el sistema puede perder 32 bytes o algo así. Creo que eso definitivamente puede suceder incluso bajo estándares de prueba bastante buenos y herramientas de detección de fugas, pero tampoco sería tan crítico perder un poco de memoria en algo que casi nunca sucede. Veremos un problema mucho más grande en el que podemos filtrar recursos masivos incluso en las rutas de ejecución comunes a continuación de una manera que GC no puede evitar.

También es difícil sin algo parecido a una pseudoforma de GC (recuento de referencias, por ejemplo) cuando la vida útil de un objeto debe extenderse para alguna forma de procesamiento diferido / asincrónico, tal vez por otro hilo.

Punteros colgantes

El problema real con más formas manuales de administración de memoria no es para mí las filtraciones. ¿Cuántas aplicaciones nativas escritas en C o C ++ sabemos que realmente tienen fugas? ¿El kernel de Linux tiene fugas? MySQL? CryEngine 3? ¿Estaciones de trabajo de audio digital y sintetizadores? ¿Fuga Java VM (está implementado en código nativo)? Photoshop?

En todo caso, creo que cuando miramos a nuestro alrededor, las aplicaciones con más fugas tienden a ser las escritas usando esquemas GC. Pero antes de que se tome como un golpe en la recolección de basura, el código nativo tiene un problema importante que no está relacionado en absoluto con las pérdidas de memoria.

El problema para mí siempre fue la seguridad. Incluso cuando recordamos a freetravés de un puntero, si hay otros punteros al recurso, se convertirán en punteros colgantes (invalidados).

Cuando intentamos acceder a los punteros de esos punteros colgantes, terminamos con un comportamiento indefinido, aunque casi siempre una violación de seguridad / acceso que conduce a un bloqueo duro e inmediato.

Todas esas aplicaciones nativas que mencioné anteriormente tienen potencialmente un caso oscuro o dos que pueden provocar un bloqueo principalmente debido a este problema, y ​​definitivamente hay una buena cantidad de aplicaciones de mala calidad escritas en código nativo que son muy pesadas, y a menudo en gran parte debido a este problema.

... y es porque la administración de recursos es difícil independientemente de si usa GC o no. La diferencia práctica es a menudo fugas (GC) o fallas (sin GC) ante un error que conduce a una mala gestión de los recursos.

Gestión de recursos: recolección de basura

La gestión compleja de recursos es un proceso manual difícil, pase lo que pase. GC no puede automatizar nada aquí.

Tomemos un ejemplo donde tenemos este objeto, "Joe". Joe es referenciado por varias organizaciones a las que es miembro. Cada mes aproximadamente extraen una cuota de membresía de su tarjeta de crédito.

ingrese la descripción de la imagen aquí

También tenemos una referencia a Joe para controlar su vida. Digamos que, como programadores, ya no necesitamos a Joe. Está empezando a molestarnos y ya no necesitamos que estas organizaciones a las que pertenece desperdicien su tiempo tratando con él. Así que intentamos borrarlo de la faz de la tierra eliminando su referencia de línea de vida.

ingrese la descripción de la imagen aquí

... pero espera, estamos usando recolección de basura. Cada referencia fuerte a Joe lo mantendrá cerca. Por lo tanto, también eliminamos las referencias a él de las organizaciones a las que pertenece (cancelando su suscripción).

ingrese la descripción de la imagen aquí

... excepto los gritos, ¡olvidamos cancelar su suscripción a la revista! Ahora Joe permanece en la memoria, molestándonos y usando recursos, y la compañía de revistas también termina procesando la membresía de Joe todos los meses.

Este es el error principal que puede causar que muchos programas complejos escritos usando esquemas de recolección de basura se filtren y comiencen a usar más y más memoria cuanto más tiempo se ejecuten, y posiblemente más y más procesamiento (la suscripción recurrente a la revista). Se olvidaron de eliminar una o más de esas referencias, haciendo imposible que el recolector de basura haga su magia hasta que se cierre todo el programa.

Sin embargo, el programa no se bloquea. Es perfectamente seguro Simplemente va a seguir acumulando memoria y Joe todavía se quedará por ahí. Para muchas aplicaciones, este tipo de comportamiento con fugas en el que simplemente arrojamos más y más memoria / procesamiento al problema podría ser mucho más preferible que un bloqueo duro, especialmente dada la cantidad de memoria y potencia de procesamiento que nuestras máquinas tienen hoy en día.

Gestión de recursos: manual

Ahora consideremos la alternativa en la que usamos punteros a Joe y la administración manual de memoria, así:

ingrese la descripción de la imagen aquí

Estos enlaces azules no gestionan la vida de Joe. Si queremos eliminarlo de la faz de la tierra, solicitamos manualmente destruirlo, así:

ingrese la descripción de la imagen aquí

Ahora eso normalmente nos dejaría con punteros colgantes por todo el lugar, así que vamos a quitarle los punteros a Joe.

ingrese la descripción de la imagen aquí

... ¡Vaya, cometimos exactamente el mismo error nuevamente y olvidamos cancelar la suscripción a la revista de Joe!

Excepto que ahora tenemos un puntero colgante. Cuando la suscripción a la revista intente procesar la tarifa mensual de Joe, todo el mundo explotará, por lo general, tenemos un colapso duro al instante.

Este mismo error básico de mala administración de recursos en el que el desarrollador olvidó eliminar manualmente todos los punteros / referencias a un recurso puede provocar muchos bloqueos en las aplicaciones nativas. Por lo general, no acumulan memoria mientras más tiempo se ejecutan, ya que a menudo se bloquean en este caso.

Mundo real

Ahora el ejemplo anterior está usando un diagrama ridículamente simple. Una aplicación del mundo real puede requerir miles de imágenes unidas para cubrir un gráfico completo, con cientos de diferentes tipos de recursos almacenados en un gráfico de escena, recursos de GPU asociados a algunos de ellos, aceleradores vinculados a otros, observadores distribuidos en cientos de complementos observando una serie de tipos de entidades en la escena en busca de cambios, observadores observadores observadores, audios sincronizados con animaciones, etc. Por lo tanto, puede parecer que es fácil evitar el error que describí anteriormente, pero generalmente no es tan simple en un mundo real base de código de producción para una aplicación compleja que abarca millones de líneas de código.

La posibilidad de que alguien, algún día, administre mal los recursos en algún lugar de esa base de código tiende a ser bastante alta, y esa probabilidad es la misma con o sin GC. La principal diferencia es lo que sucederá como resultado de este error, que también afecta potencialmente la rapidez con la que este error se detectará y solucionará.

Choque contra fugas

¿Ahora cuál es peor? ¿Un choque inmediato o una fuga silenciosa de memoria donde Joe se queda misteriosamente?

La mayoría podría responder a esto último, pero digamos que este software está diseñado para ejecutarse durante horas, posiblemente días, y cada uno de estos Joe's y Jane's que agregamos aumenta el uso de memoria del software en un gigabyte. No es un software de misión crítica (los bloqueos en realidad no matan a los usuarios), sino uno de rendimiento crítico.

En este caso, un bloqueo duro que aparece inmediatamente al depurar, señalando el error que cometió, podría ser preferible a un software con fugas que incluso podría pasar desapercibido en su procedimiento de prueba.

Por otro lado, si se trata de un software de misión crítica donde el rendimiento no es el objetivo, simplemente no se bloquea de ninguna manera posible, entonces la filtración podría ser preferible.

Referencias débiles

Hay una especie de híbrido de estas ideas disponible en los esquemas de GC conocidos como referencias débiles. Con referencias débiles, podemos hacer que todas estas organizaciones hagan referencia débil a Joe, pero no evitar que sea eliminado cuando la referencia fuerte (propietario / línea de vida de Joe) desaparece. Sin embargo, obtenemos el beneficio de poder detectar cuándo Joe ya no está cerca a través de estas referencias débiles, lo que nos permite obtener un tipo de error fácilmente reproducible.

Desafortunadamente, las referencias débiles no se usan tanto como probablemente deberían usarse, por lo que muchas aplicaciones complejas de GC pueden ser susceptibles a fugas, incluso si son potencialmente menos defectuosas que una aplicación C compleja, por ejemplo

En cualquier caso, si GC hace o no su vida más fácil o más difícil depende de lo importante que sea para su software evitar fugas, y de si se trata o no de una gestión de recursos compleja de este tipo.

En mi caso, trabajo en un campo de rendimiento crítico donde los recursos abarcan cientos de megabytes a gigabytes, y no libero esa memoria cuando los usuarios solicitan la descarga debido a un error como el anterior en realidad puede ser menos preferible a un bloqueo. Los bloqueos son fáciles de detectar y reproducir, lo que los convierte a menudo en el tipo de error favorito del programador, incluso si es el menos favorito del usuario, y muchos de estos bloqueos aparecerán con un procedimiento de prueba sensato incluso antes de que lleguen al usuario.

De todos modos, esas son las diferencias entre GC y la gestión de memoria manual. Para responder a su pregunta inmediata, diría que la administración manual de la memoria es difícil, pero tiene muy poco que ver con las fugas, y tanto la GC como las formas manuales de administración de la memoria siguen siendo muy difíciles cuando la administración de recursos no es trivial. Podría decirse que el GC tiene un comportamiento más complicado aquí donde el programa parece estar funcionando bien pero está consumiendo más y más y más recursos. El formulario manual es menos complicado, pero se bloqueará y quemará a lo grande con errores como el que se muestra arriba.


fuente
-1

Aquí hay una lista de problemas que enfrentan los programadores de C ++ cuando se trata de memoria:

  1. El problema de alcance se produce en la memoria asignada a la pila: su vida útil no se extiende fuera de la función en la que se asignó. Hay tres soluciones principales para este problema: memoria de almacenamiento dinámico y mover el punto de asignación hacia arriba en la pila de llamadas o asignar desde objetos internos .
  2. El problema del tamaño del problema está en la pila asignada y en la asignación desde el objeto interno y la memoria asignada en parte al montón: el tamaño del bloque de memoria no puede cambiar en el tiempo de ejecución. Las soluciones son matrices de memoria de almacenamiento dinámico, punteros y bibliotecas y contenedores.
  3. El problema del orden de definición es al asignar objetos internos: las clases dentro del programa deben estar en el orden correcto. Las soluciones restringen las dependencias a un árbol y reordenan las clases y no usan declaraciones directas, punteros y memoria de almacenamiento dinámico y usan declaraciones directas.
  4. El problema interno-externo está en la memoria asignada al objeto El acceso a la memoria dentro de los objetos se divide en dos partes, parte de la memoria está dentro de un objeto y otra memoria está fuera de él, y los programadores deben elegir correctamente usar composición o referencias basadas en esta decisión. Las soluciones están tomando la decisión correctamente, o punteros y memoria de montón.
  5. El problema de los objetos recursivos está en la memoria de objetos asignados. El tamaño de los objetos se vuelve infinito si el mismo objeto se coloca dentro de sí mismo, y las soluciones son referencias, memoria de almacenamiento dinámico y punteros.
  6. El problema de seguimiento de la propiedad está en la memoria asignada en el montón, el puntero que contiene la dirección de la memoria asignada en el montón debe pasarse del punto de asignación al punto de desasignación. Las soluciones son memoria asignada a la pila, memoria asignada a objetos, auto_ptr, shared_ptr, unique_ptr, contenedores stdlib.
  7. El problema de duplicación de propiedad está en la memoria asignada en el montón: la desasignación solo se puede hacer una vez. Las soluciones son memoria asignada por pila, memoria asignada por objeto, auto_ptr, shared_ptr, unique_ptr, contenedores stdlib.
  8. El problema del puntero nulo está en la memoria asignada en el montón: los punteros pueden ser NULL haciendo que muchas de las operaciones se bloqueen en tiempo de ejecución. Las soluciones son la memoria de pila, la memoria asignada a objetos y el análisis cuidadoso de las áreas y referencias del montón.
  9. El problema de pérdida de memoria está en la memoria asignada en el montón: olvidando llamar a la eliminación para cada bloque de memoria asignado. Las soluciones son herramientas como valgrind.
  10. El problema de desbordamiento de pila es para llamadas a funciones recursivas que utilizan memoria de pila. Normalmente, el tamaño de la pila está completamente determinado en el tiempo de compilación, excepto en el caso de los algoritmos recursivos. La definición incorrecta del tamaño de la pila del sistema operativo a menudo también causa este problema, ya que no hay forma de medir el tamaño requerido del espacio de la pila.

Como puede ver, la memoria de almacenamiento dinámico está resolviendo muchos problemas existentes, pero causa una complejidad adicional. GC está diseñado para manejar parte de esa complejidad. (perdón si algunos nombres de problemas no son los nombres correctos para estos problemas, a veces es difícil descubrir el nombre correcto)

tp1
fuente
1
-1: No es una respuesta a la pregunta.
Sjoerd