¿Un patrón de recuento de referencia para lenguajes gestionados por memoria?


11

Java y .NET tienen maravillosos recolectores de basura que administran la memoria para usted y patrones convenientes para liberar rápidamente objetos externos ( Closeable, IDisposable), pero solo si son propiedad de un solo objeto. En algunos sistemas, un recurso puede necesitar ser consumido independientemente por dos componentes, y solo se liberará cuando ambos componentes liberen el recurso.

En C ++ moderno, resolvería este problema con a shared_ptr, que liberaría de manera determinista el recurso cuando shared_ptrse destruyan todos los 's.

¿Existen patrones probados y documentados para administrar y liberar recursos costosos que no tienen un único propietario en sistemas de recolección de basura no deterministas orientados a objetos?


1
¿Has visto el recuento automático de referencias de Clang , también utilizado en Swift ?
jscs

1
@JoshCaswell Sí, y eso resolvería el problema, pero estoy trabajando en un espacio de recolección de basura.
C. Ross

8
El conteo de referencias es una estrategia de recolección de basura.
Jörg W Mittag

Respuestas:


15

En general, lo evita al tener un único propietario, incluso en idiomas no administrados.

Pero el principio es el mismo para los lenguajes administrados. En lugar de cerrar de inmediato el recurso costoso en un Close()decremento de un contador (incrementado en Open()/ Connect()/ etc) hasta llegar a 0, en cuyo punto el cierre realmente cierra. Es probable que se vea y actúe como el patrón Flyweight.


Esto es lo que estaba pensando también, pero ¿hay un patrón documentado para ello? El peso mosca es ciertamente similar, pero específicamente para la memoria como se define generalmente.
C. Ross

@ C.Ross Este parece ser un caso en el que se alienta a los finalizadores. Puede usar una clase contenedora alrededor del recurso no administrado, agregando un finalizador a esa clase para liberar el recurso. También puede hacer que se implemente IDisposable, mantenga los recuentos para liberar el recurso lo antes posible, etc. Probablemente lo mejor, muchas veces, es tener los tres, pero el finalizador es probablemente la parte más crítica, y la IDisposableimplementación es El menos crítico.
Panzercrisis

11
@Panzercrisis, excepto que no se garantiza que los finalizadores se ejecuten, y especialmente no se garantiza que se ejecuten rápidamente .
Caleth

@Caleth Estaba pensando que lo de los conteos ayudaría con la parte de puntualidad. En lo que respecta a que no se ejecutan en absoluto, ¿quiere decir que el CLR podría no funcionar antes de que finalice el programa, o quiere decir que podrían quedar descalificados por completo?
Panzercrisis


14

En un lenguaje de recolección de basura (donde GC no es determinista), no es posible vincular de manera confiable la limpieza de un recurso que no sea memoria con la vida útil de un objeto: no es posible establecer cuándo se eliminará un objeto. El final de la vida útil queda totalmente a discreción del recolector de basura. El GC solo garantiza que un objeto vivirá mientras sea accesible. Una vez que un objeto se vuelve inalcanzable, puede limpiarse en algún momento en el futuro, lo que puede implicar la ejecución de finalizadores.

El concepto de "propiedad de recursos" no se aplica realmente en un lenguaje GC. El sistema GC posee todos los objetos.

Lo que estos lenguajes ofrecen con try-with-resource + Closeable (Java), usando declaraciones + IDisposable (C #), o con declaraciones + context managers (Python) es una forma de flujo de control (! = Objetos) para contener un recurso que se cierra cuando el flujo de control deja un alcance. En todos estos casos, esto es similar a un insertado automáticamente try { ... } finally { resource.close(); }. La vida útil del objeto que representa el recurso no está relacionada con la vida útil del recurso: el objeto puede continuar viviendo después de que se cerró el recurso, y el objeto puede quedar inalcanzable mientras el recurso aún está abierto.

En el caso de las variables locales, estos enfoques son equivalentes a RAII, pero deben usarse explícitamente en el sitio de la llamada (a diferencia de los destructores de C ++ que se ejecutarán por defecto). Un buen IDE avisará cuando esto se omita.

Esto no funciona para objetos a los que se hace referencia desde ubicaciones que no sean variables locales. Aquí, es irrelevante si hay una o más referencias. Es posible traducir las referencias de recursos a través de referencias de objetos a la propiedad de los recursos a través del flujo de control creando un hilo separado que contenga este recurso, pero los hilos también son recursos que deben descartarse manualmente.

En algunos casos es posible delegar la propiedad del recurso a una función de llamada. En lugar de objetos temporales que hacen referencia a recursos que deberían (pero no pueden) limpiar de manera confiable, la función de llamada contiene un conjunto de recursos que deben limpiarse. Esto solo funciona hasta que la vida útil de cualquiera de estos objetos sobrevive a la vida útil de la función y, por lo tanto, hace referencia a un recurso que ya se ha cerrado. Esto no puede ser detectado por un compilador, a menos que el lenguaje tenga un seguimiento de propiedad similar al óxido (en cuyo caso ya hay mejores soluciones para este problema de gestión de recursos).

Esto deja como la única solución viable: gestión manual de recursos, posiblemente mediante la implementación del conteo de referencias usted mismo. Esto es propenso a errores, pero no imposible. En particular, tener que pensar en la propiedad es inusual en los lenguajes de GC, por lo que el código existente puede no ser lo suficientemente explícito sobre las garantías de propiedad.


3

Mucha información buena de las otras respuestas.

Aún así, para ser explícito, el patrón que podría estar buscando es que use objetos pequeños de propiedad individual para la construcción de flujo de control similar a RAII a través de usingy IDispose, junto con un objeto (más grande, posiblemente contado por referencia) que contiene algo (operativo los recursos del sistema.

Por lo tanto, están los pequeños objetos de propietario único no compartidos que (a través del objeto más pequeño IDisposey la usingconstrucción del flujo de control) pueden a su vez informar al objeto compartido más grande (tal vez personalizado Acquirey Releasemétodos).

(Los métodos Acquirey Releaseque se muestran a continuación también están disponibles fuera de la construcción de uso, pero sin la seguridad de lo tryimplícito using).


Un ejemplo en C #

void Test ( MyRefCountedClass myObj )
{
    using ( var usingRef = myObj.Acquire () )
    {
        var item = usingRef.Item;
        item.SomeMethod ();

        // the `using` automatically invokes Dispose() on usingRef
        //  which in turn invokes Release() on `myObj.
    }
}

interface IReferencable<T> where T: IReferencable<T> {
    Reference<T> Acquire ();
    void Release();
}

struct Reference<T>: IDisposable where T: IReferencable<T>
{
    public readonly T Item;
    public Reference(T item) { Item = item; _released = false; }
    public void Dispose() { if (! _released ) { _released = true; Item.Release(); } }
    private bool _released;
}

class MyRefCountedClass : IReferencable<MyRefCountedClass>
{
    private int _refCount = 0;

    public Reference<MyRefCountedClass> Acquire ()
    {
        _refCount++;
        return new Reference<MyRefCountedClass>(this);
    }

    public void Release ()
    {
        if (--_refCount <= 0)
            Dispose();
    }

    // NOTE that MyRefCountedClass does not have to implement IDisposable, but it can...
    // as shown here it doesn't implement the interface
    private void Dispose ()  
    {
        if ( _refCount > 0 )
            throw new Exception ("Dispose attempted on item in use.");
        // release other resources...
    }

    public int SomeMethod()
    {
        return 0;
    }
}

Si eso debería ser C # (que parece), entonces su implementación de Referencia <T> es sutilmente incorrecta. El contrato para los IDisposable.Disposeestados que llama Disposevarias veces en el mismo objeto debe ser un no-op. Si tuviera que implementar tal patrón, también lo haría Releaseprivado para evitar errores innecesarios y usaría la delegación en lugar de la herencia (elimine la interfaz, proporcione una SharedDisposableclase simple que se pueda usar con Desechables arbitrarios), pero eso es más cuestión de gustos.
Voo

@Voo, ok, buen punto, gracias!
Erik Eidt

1

La gran mayoría de los objetos en un sistema generalmente debe ajustarse a uno de tres patrones:

  1. Objetos cuyo estado nunca cambiará, y cuyas referencias se mantienen únicamente como un medio de encapsular el estado. Las entidades que contienen referencias no saben ni se preocupan por si otras entidades tienen referencias al mismo objeto.

  2. Los objetos que están bajo el control exclusivo de una sola entidad, que es el único propietario de todo el estado allí, y utiliza el objeto únicamente como un medio para encapsular el estado (posiblemente mutable) en el mismo.

  3. Objetos que son propiedad de una sola entidad, pero que otras entidades pueden usar de manera limitada. El propietario del objeto puede usarlo no solo como un medio para encapsular el estado, sino también para encapsular una relación con las otras entidades que lo comparten.

El seguimiento de la recolección de basura funciona mejor que el recuento de referencias para el n. ° 1, porque el código que usa dichos objetos no necesita hacer nada especial cuando se hace con la última referencia restante. El recuento de referencias no es necesario para el n. ° 2 porque los objetos tendrán exactamente un propietario, y sabrá cuándo ya no necesita el objeto. El escenario n. ° 3 puede plantear algunas dificultades si el propietario de un objeto lo mata mientras otras entidades aún tienen referencias; incluso allí, un GC de seguimiento puede ser mejor que el recuento de referencias para garantizar que las referencias a objetos muertos permanezcan identificables de manera confiable como referencias a objetos muertos, mientras exista dicha referencia.

Hay algunas situaciones en las que puede ser necesario que un objeto compartible sin propietario adquiera y retenga recursos externos siempre que alguien necesite sus servicios, y debería liberarlos cuando sus servicios ya no sean necesarios. Por ejemplo, un objeto que encapsula el contenido de un archivo de solo lectura podría ser compartido y utilizado por muchas entidades simultáneamente sin que ninguno de ellos tenga que saber o preocuparse por la existencia del otro. Tales circunstancias son raras, sin embargo. La mayoría de los objetos tendrán un único propietario claro o no tendrán propietario. La propiedad múltiple es posible, pero rara vez es útil.


0

La propiedad compartida rara vez tiene sentido

Esta respuesta puede estar ligeramente fuera de la tangente, pero tengo que preguntar, ¿cuántos casos tiene sentido desde el punto de vista del usuario final compartir la propiedad ? Al menos en los dominios en los que he trabajado, prácticamente no había ninguno porque, de lo contrario, eso implicaría que el usuario no necesita simplemente eliminar algo una vez de un lugar, sino eliminarlo explícitamente de todos los propietarios relevantes antes de que el recurso esté realmente eliminado del sistema.

A menudo es una idea de ingeniería de nivel inferior para evitar que los recursos se destruyan mientras otra cosa todavía está accediendo a ella, como otro hilo. A menudo, cuando un usuario solicita cerrar / eliminar / eliminar algo del software, debe eliminarse lo antes posible (siempre que sea seguro eliminarlo), y ciertamente no debe demorarse y causar una fuga de recursos durante el tiempo que sea necesario. La aplicación se está ejecutando.

Como ejemplo, un activo de juego en un videojuego podría hacer referencia a un material de la biblioteca de materiales. Ciertamente, no queremos, por ejemplo, un bloqueo del puntero colgante si el material se elimina de la biblioteca de materiales en un hilo mientras otro hilo todavía está accediendo al material al que hace referencia el activo del juego. Pero eso no significa que tenga sentido que los activos del juego compartan la propiedad de los materiales a los que hacen referencia con la biblioteca de materiales. No queremos obligar al usuario a eliminar explícitamente el material de los activos y la biblioteca de materiales. Solo queremos asegurarnos de que los materiales no se eliminen de la biblioteca de materiales, el único propietario sensible de los materiales, hasta que otros hilos terminen de acceder al material.

Fugas de recursos

Sin embargo, trabajé con un antiguo equipo que adoptó GC para todos los componentes del software. Y aunque eso realmente ayudó a asegurarnos de que nunca se destruyeran los recursos mientras otros subprocesos todavía estaban accediendo a ellos, en su lugar, terminamos recibiendo nuestra parte de fugas de recursos .

Y estas no fueron fugas de recursos triviales de un tipo que molesta solo a los desarrolladores, como un kilobyte de memoria filtrado después de una sesión de una hora. Estas fueron fugas épicas, a menudo gigabytes de memoria durante una sesión activa, lo que condujo a informes de errores. Porque ahora, cuando se hace referencia a la propiedad de un recurso (y, por lo tanto, se comparte en propiedad) entre, por ejemplo, 8 partes diferentes del sistema, solo se necesita una para no eliminar el recurso en respuesta al usuario que solicita que se elimine por él ser filtrado y posiblemente indefinidamente.

Por lo tanto, nunca fui un gran admirador del GC o del recuento de referencias aplicado a gran escala debido a lo fácil que fue crear un software con fugas. Lo que anteriormente habría sido un accidente puntero colgante que es fácil de detectar se convierte en una fuga de recursos muy difícil de detectar que puede volar fácilmente bajo el radar de las pruebas.

Las referencias débiles pueden mitigar este problema si el idioma / la biblioteca los proporciona, pero me resultó difícil lograr que un equipo de desarrolladores de conjuntos de habilidades mixtas pueda utilizar constantemente referencias débiles siempre que sea apropiado. Y esta dificultad no solo estaba relacionada con el equipo interno, sino con todos los desarrolladores de plugins para nuestro software. También podrían hacer que el sistema pierda recursos simplemente almacenando una referencia persistente a un objeto de manera que sea difícil rastrear al complemento como el culpable, por lo que también obtuvimos la mayor parte de los informes de errores resultantes de nuestros recursos de software se filtró simplemente porque un complemento cuyo código fuente estaba fuera de nuestro control no pudo liberar referencias a esos costosos recursos.

Solución: eliminación periódica diferida

Entonces, mi solución más adelante, que apliqué a mis proyectos personales que me dieron el tipo de lo mejor que encontré de ambos mundos, fue eliminar el concepto que referencing=ownershipaún difiere la destrucción de recursos.

Como resultado, ahora cada vez que el usuario hace algo que hace que un recurso necesite ser eliminado, la API se expresa en términos de solo eliminar el recurso:

ecs->remove(component);

... que modela la lógica del usuario final de una manera muy directa. Sin embargo, el recurso (componente) no puede eliminarse de inmediato si hay otros subprocesos del sistema en su fase de procesamiento en los que podrían acceder al mismo componente al mismo tiempo.

Por lo tanto, estos subprocesos de procesamiento producen tiempo aquí y allá, lo que permite que un subproceso que se asemeja a un recolector de basura se despierte y " pare el mundo " y destruya todos los recursos que se solicitó que se eliminen mientras bloquea los subprocesos del procesamiento de esos componentes hasta que finalice . He ajustado esto para que la cantidad de trabajo que se necesita hacer aquí sea generalmente mínima y no se reduzca notablemente en las velocidades de fotogramas.

Ahora no puedo decir que este sea un método probado y probado y bien documentado, pero es algo que he estado usando durante algunos años, sin dolores de cabeza y sin pérdidas de recursos. Recomiendo explorar enfoques como este cuando es posible que su arquitectura se ajuste a este tipo de modelo de concurrencia, ya que es mucho menos pesado que el GC o el recuento de ref. Y no se arriesga a que este tipo de fugas de recursos vuelen bajo el radar de las pruebas.

El único lugar donde encontré útil el recuento de ref. O GC es para las estructuras de datos persistentes. En ese caso, es el territorio de la estructura de datos, lejos de las preocupaciones del usuario final, y allí tiene sentido que cada copia inmutable pueda compartir la propiedad de los mismos datos no modificados.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.