c++ - memoria - RAII vs. recolector de basura
recolector de basura java (12)
Incluso escuché que tener un recolector de basura puede ser más eficiente, ya que puede liberar grandes cantidades de memoria a la vez en lugar de liberar pequeñas piezas de memoria en todo el código.
Eso es perfectamente factible, y, de hecho, se hace realmente, con RAII (o con malloc / free). Verá, no siempre usa necesariamente el asignador predeterminado, que desasigna solo por partes. En ciertos contextos, utiliza asignadores personalizados con diferentes tipos de funcionalidad. Algunos asignadores tienen la capacidad incorporada de liberar todo en alguna región del asignador, todo a la vez, sin tener que iterar elementos asignados individuales.
Por supuesto, entonces te preguntas cuándo desasignar todo, si el uso de esos asignadores (o la losa de memoria con la que están asociados tiene que RAIIed o no, y cómo.
Recientemente vi una gran charla de Herb Sutter sobre "Leak Free C ++ ..." en CppCon 2016, donde habló sobre el uso de punteros inteligentes para implementar RAII (adquisición de recursos es inicialización): conceptos y cómo resuelven la mayoría de los problemas de pérdida de memoria.
Ahora me preguntaba. Si sigo estrictamente las reglas RAII, lo que parece ser algo bueno, ¿por qué sería diferente de tener un recolector de basura en C ++? Sé que con RAII el programador tiene el control total de cuándo se vuelven a liberar los recursos, pero ¿es eso beneficioso en cualquier caso tener un recolector de basura? ¿Sería realmente menos eficiente? Incluso escuché que tener un recolector de basura puede ser más eficiente, ya que puede liberar grandes cantidades de memoria a la vez en lugar de liberar pequeñas piezas de memoria en todo el código.
Si sigo estrictamente las reglas RAII, lo que parece ser algo bueno, ¿por qué sería diferente de tener un recolector de basura en C ++?
Si bien ambos se ocupan de asignaciones, lo hacen de maneras completamente diferentes. Si se está refiriendo a un GC como el de Java, que agrega su propia sobrecarga, elimina parte del determinismo del proceso de liberación de recursos y maneja referencias circulares.
Sin embargo, puede implementar GC para casos particulares, con características de rendimiento muy diferentes. Implementé una vez para cerrar las conexiones de socket, en un servidor de alto rendimiento / alto rendimiento (simplemente llamar a la API de cierre de socket tomó demasiado tiempo y disminuyó el rendimiento de rendimiento). Esto no implicaba memoria, sino conexiones de red y ningún manejo cíclico de dependencias.
Sé que con RAII el programador tiene el control total de cuándo se vuelven a liberar los recursos, pero ¿es eso beneficioso en cualquier caso tener un recolector de basura?
Este determinismo es una característica que GC simplemente no permite. A veces desea saber que, después de un momento, se ha realizado una operación de limpieza (eliminar un archivo temporal, cerrar una conexión de red, etc.).
En tales casos, GC no lo corta, razón por la cual en C # (por ejemplo) tiene la interfaz
IDisposable
.
Incluso escuché que tener un recolector de basura puede ser más eficiente, ya que puede liberar grandes cantidades de memoria a la vez en lugar de liberar pequeñas piezas de memoria en todo el código.
Puede ser ... depende de la implementación.
"Eficiente" es un término muy amplio, en el sentido de los esfuerzos de desarrollo RAII es típicamente menos eficiente que GC, pero en términos de rendimiento GC es típicamente menos eficiente que RAII.
Sin embargo, es posible proporcionar ejemplos contr para ambos casos.
Tratar con GC genérico cuando tiene patrones de asignación de recursos (des) muy claros en lenguajes administrados puede ser bastante problemático, al igual que el código que usa RAII puede ser sorprendentemente ineficiente cuando
shared_ptr
se usa para todo sin ningún motivo.
La parte principal de la pregunta sobre si uno u otro es "beneficioso" o más "eficiente" no puede responderse sin dar mucho contexto y discutir sobre las definiciones de estos términos.
Más allá de eso, básicamente puedes sentir la tensión del antiguo "¿Es Java o C ++ el mejor lenguaje?" flamewar chisporroteando en los comentarios. Me pregunto cómo podría ser una respuesta "aceptable" a esta pregunta, y tengo curiosidad por verla eventualmente.
Pero aún no se ha señalado un punto sobre una posible diferencia conceptual importante: con RAII, está atado al hilo que llama al destructor. Si su aplicación es de un solo subproceso (y aunque fue Herb Sutter quien declaró que The Free Lunch Is Over : la mayoría del software actual todavía es de un solo subproceso), entonces un solo núcleo puede estar ocupado manejando la limpieza de objetos que no ya no es relevante para el programa real ...
En contraste con eso, el recolector de basura generalmente se ejecuta en su propio hilo, o incluso en varios hilos, y por lo tanto (en cierta medida) se desacopla de la ejecución de las otras partes.
(Nota: Algunas respuestas ya intentaron señalar patrones de aplicación con diferentes características, mencionaron eficiencia, rendimiento, latencia y rendimiento, pero este punto específico aún no se mencionó)
La recolección de basura resuelve ciertas clases de problemas de recursos que RAII no puede resolver. Básicamente, se reduce a dependencias circulares donde no identifica el ciclo de antemano.
Esto le da dos ventajas. Primero, habrá ciertos tipos de problemas que RAII no puede resolver. Estos son, en mi experiencia, raros.
El más grande es que permite que el programador sea flojo y no se preocupe por la vida útil de los recursos de memoria y ciertos otros recursos que no le importan la limpieza retrasada. Cuando no tiene que preocuparse por ciertos tipos de problemas, puede preocuparse más por otros problemas. Esto le permite concentrarse en las partes de su problema en las que desea concentrarse.
La desventaja es que sin RAII, es difícil administrar recursos cuya vida útil desea restringir. Básicamente, los lenguajes de GC lo reducen a tener una duración de vida limitada al alcance extremadamente simple o requieren que realice la gestión de recursos manualmente, como en C, con la declaración manual de que ha terminado con un recurso. Su sistema de vida útil de objetos está fuertemente ligado a GC, y no funciona bien para una gestión estricta de vida útil de sistemas complejos grandes (pero libres de ciclos).
Para ser justos, la gestión de recursos en C ++ requiere mucho trabajo para funcionar correctamente en sistemas tan grandes y complejos (pero libres de ciclos). C # y lenguajes similares solo lo hacen un poco más difícil, a cambio hacen que la carcasa sea fácil.
La mayoría de las implementaciones de GC también obliga a las clases completas de no localidad;
crear buffers contiguos de objetos generales, o componer objetos generales en un objeto más grande, no es algo que la mayoría de las implementaciones de GC faciliten.
Por otro lado, C # le permite crear
struct
tipo de valor con capacidades algo limitadas.
En la era actual de la arquitectura de la CPU, la facilidad de almacenamiento en caché es clave, y la falta de localidad GC fuerzas es una carga pesada.
Como estos lenguajes tienen un tiempo de ejecución de bytecode en su mayor parte, en teoría, el entorno JIT podría mover datos de uso común juntos, pero la mayoría de las veces solo obtienes una pérdida de rendimiento uniforme debido a errores frecuentes de caché en comparación con C ++.
El último problema con GC es que la desasignación es indeterminada y, a veces, puede causar problemas de rendimiento. Los GC modernos hacen que esto sea menos problemático que en el pasado.
La recolección de basura y RAII admiten una construcción común para la cual la otra no es realmente adecuada.
En un sistema de recolección de basura, el código puede tratar eficientemente las referencias a objetos inmutables (como cadenas) como proxies para los datos que contiene; pasar esas referencias es casi tan barato como pasar punteros "tontos", y es más rápido que hacer una copia separada de los datos para cada propietario, o tratar de rastrear la propiedad de una copia compartida de los datos. Además, los sistemas de recolección de basura facilitan la creación de tipos de objetos inmutables al escribir una clase que crea un objeto mutable, rellenarlo como se desee y proporcionar métodos de acceso, todo mientras evita las filtraciones de cualquier cosa que pueda mutarlo una vez que el constructor acabados. En los casos en que las referencias a objetos inmutables deben copiarse ampliamente, pero los objetos en sí no, GC supera a RAII.
Por otro lado, RAII es excelente para manejar situaciones en las que un objeto necesita adquirir servicios exclusivos de entidades externas. Si bien muchos sistemas de GC permiten que los objetos definan métodos de "Finalización" y soliciten notificación cuando se descubre que están abandonados, y tales métodos a veces logran liberar servicios externos que ya no son necesarios, rara vez son lo suficientemente confiables como para proporcionar una forma satisfactoria de asegurando la liberación oportuna de servicios externos. Para la gestión de recursos externos no fungibles, RAII supera a GC.
La diferencia clave entre los casos en que GC gana frente a aquellos en los que RAII gana es que GC es bueno para administrar la memoria fungible que se puede liberar según sea necesario, pero pobre en el manejo de recursos no fungibles. RAII es bueno para manejar objetos con una propiedad clara, pero malo para manejar poseedores de datos inmutables sin dueño que no tienen una identidad real aparte de los datos que contienen.
Debido a que ni GC ni RAII manejan bien todos los escenarios, sería útil que los idiomas brinden un buen soporte para ambos. Desafortunadamente, los idiomas que se centran en uno tienden a tratar al otro como una ocurrencia tardía.
Mas o menos. El modismo RAII puede ser mejor para la latencia y la inquietud . Un recolector de basura puede ser mejor para el rendimiento del sistema.
RAII trata de manera uniforme cualquier cosa que se pueda describir como un recurso. Las asignaciones dinámicas son uno de esos recursos, pero de ninguna manera son el único, y posiblemente no es el más importante. Los archivos, los sockets, las conexiones a la base de datos, la retroalimentación de la interfaz gráfica de usuario y más son cosas que se pueden administrar de manera determinista con RAII.
Los GC solo se ocupan de asignaciones dinámicas, lo que alivia al programador de preocuparse por el volumen total de objetos asignados durante la vida útil del programa (solo tienen que preocuparse por el ajuste máximo del volumen de asignación simultánea)
RAII y GC resuelven problemas en direcciones completamente diferentes. Son completamente diferentes, a pesar de lo que algunos dirían.
Ambos abordan el problema de que administrar recursos es difícil. Garbage Collection lo resuelve haciéndolo para que el desarrollador no tenga que prestar tanta atención a la gestión de esos recursos. RAII lo resuelve al facilitar que los desarrolladores presten atención a la gestión de sus recursos. Cualquiera que diga que hace lo mismo tiene algo que venderte.
Si observa las tendencias recientes en los idiomas, verá que ambos enfoques se usan en el mismo idioma porque, francamente, realmente necesita ambos lados del rompecabezas.
Está viendo muchos idiomas que utilizan una especie de recolección de basura para que no tenga que prestar atención a la mayoría de los objetos, y esos idiomas también ofrecen soluciones RAII (como Python
with
operador) para los momentos en que realmente desea prestar atención. a ellos
-
C ++ ofrece RAII a través de constructores / destructores y GC a través de
shared_ptr
(si puedo hacer el argumento de que el recuento y GC están en la misma clase de soluciones porque ambos están diseñados para ayudarlo a no tener que prestar atención a la vida útil) -
Python ofrece RAII a través
with
y GC a través de un sistema de recuento más un recolector de basura -
C # ofrece RAII a través de
IDisposable
andusing
y GC a través de un recolector de basura generacional
Los patrones están surgiendo en todos los idiomas.
RAII y recolección de basura están destinados a resolver diferentes problemas.
Cuando usa RAII, deja un objeto en la pila cuyo único propósito es limpiar lo que sea que desee administrar (sockets, memoria, archivos, etc.) al dejar el alcance del método. Esto es para la seguridad de excepción , no solo para la recolección de basura, por lo que obtiene respuestas sobre el cierre de sockets y la liberación de mutexes y similares. (Bien, entonces nadie mencionó mutexes además de mí). Si se produce una excepción, el desbobinado de la pila limpia naturalmente los recursos utilizados por un método.
La recolección de basura es la gestión programática de la memoria, aunque podría "recolectar basura" de otros recursos escasos si lo desea. Liberarlos explícitamente tiene más sentido el 99% del tiempo. La única razón para usar RAII para algo como un archivo o socket es que espera que el uso del recurso se complete cuando regrese el método.
La recolección de basura también se ocupa de los objetos que están asignados en el montón , cuando, por ejemplo, una fábrica construye una instancia de un objeto y lo devuelve. Tener objetos persistentes en situaciones donde el control debe dejar un alcance es lo que hace que la recolección de basura sea atractiva. Pero podría usar RAII en la fábrica, por lo que si se produce una excepción antes de regresar, no perderá recursos.
Tenga en cuenta que RAII es un lenguaje de programación, mientras que GC es una técnica de gestión de memoria. Entonces estamos comparando manzanas con naranjas.
Pero podemos restringir la RAII solo a sus aspectos de administración de memoria y comparar eso con las técnicas de GC.
La principal diferencia entre las llamadas técnicas de administración de memoria basadas en RAII (que realmente significa el recuento de referencias , al menos cuando considera los recursos de memoria e ignora los otros, como los archivos) y GC técnicas genuinas de GC es el manejo de referencias circulares (para gráficos cíclicos ) .
Con el recuento de referencias, debe codificar especialmente para ellos (utilizando referencias débiles u otras cosas).
En muchos casos útiles (piense en
std::vector<std::map<std::string,int>>
) el recuento de referencias es implícito (ya que solo puede ser 0 o 1) y prácticamente se omite, pero el constructor y Las funciones de destructor (esenciales para RAII) se comportan como si hubiera un bit de recuento de referencia (que está prácticamente ausente).
En
std::shared_ptr
hay un contador de referencia genuino.
Pero la memoria todavía se
maneja
implícitamente de forma
manual
(con
new
y
delete
activados dentro de constructores y destructores), pero esa
delete
"implícita" (en destructores) da la ilusión de la administración automática de memoria.
Sin embargo, las llamadas a
new
y
delete
aún ocurren (y cuestan tiempo).
Por cierto, la implementación del GC puede (y a menudo lo hace) manejar la circularidad de alguna manera especial, pero usted deja esa carga al GC (por ejemplo, lea sobre el algoritmo de Cheney ).
Algunos algoritmos de GC (especialmente el recolector de basura de copia generacional) no se molestan en liberar memoria para objetos individuales , se libera en masa después de la copia. En la práctica, el GC Ocaml (o el SBCL) puede ser más rápido que un estilo de programación genuino C ++ RAII (para algunos , no todos, algoritmos).
Algunos GC proporcionan la finalization (utilizada principalmente para administrar recursos externos que no son de memoria , como los archivos), pero rara vez la usará (ya que la mayoría de los valores consumen solo recursos de memoria). La desventaja es que la finalización no ofrece ninguna garantía de tiempo. Hablando en términos prácticos, un programa que utiliza la finalización lo está utilizando como último recurso (por ejemplo, el cierre de archivos aún debe ocurrir más o menos explícitamente fuera de la finalización, y también con ellos).
Todavía puede tener pérdidas de memoria con GC (y también con RAII, al menos cuando se usa incorrectamente), por ejemplo, cuando un valor se mantiene en alguna variable o en algún campo, pero nunca se usará en el futuro. Simplemente suceden con menos frecuencia.
Recomiendo leer el manual de recolección de basura .
En su código C ++, puede usar el GC de Boehm o el MPS de Ravenbrook o codificar su propio recolector de basura de rastreo . Por supuesto, usar un GC es una compensación (hay algunos inconvenientes, por ejemplo, no determinismo, falta de garantías de tiempo, etc.).
No creo que RAII sea la mejor forma de lidiar con la memoria en todos los casos. En varias ocasiones, codificar su programa en implementaciones de GC genuina y eficiente (piense en Ocaml o SBCL) puede ser más simple (desarrollar) y más rápido (ejecutar) que codificarlo con un elegante estilo RAII en C ++ 17. En otros casos no lo es. YMMV.
Como ejemplo, si codifica un intérprete de Scheme en C ++ 17 con el estilo RAII más sofisticado, aún necesitaría codificar (o usar) un GC explícito dentro de él (porque un montón de Scheme tiene circularidades). Y la mayoría de los asistentes de prueba están codificados en lenguajes GC-ed, a menudo funcionales, (el único que sé que está codificado en C ++ es Lean ) por buenas razones.
Por cierto, estoy interesado en encontrar una implementación de C ++ 17 de Scheme (pero menos interesado en codificarlo yo mismo), preferiblemente con alguna capacidad de subprocesamiento múltiple.
Uno de los problemas de los recolectores de basura es que es difícil predecir el rendimiento del programa.
Con RAII, sabe que en el tiempo exacto el recurso se saldrá del alcance, borrará algo de memoria y tomará algún tiempo. Pero si no es un maestro de la configuración del recolector de basura, no puede predecir cuándo se realizará la limpieza.
Por ejemplo: la limpieza de un montón de objetos pequeños se puede hacer de manera más efectiva con GC porque puede liberar gran parte, pero no será una operación rápida, y es difícil predecir cuándo ocurrirá y debido a la "limpieza de gran parte" lo hará tome algo de tiempo de procesador y puede afectar el rendimiento de su programa.