Un modello di conteggio di riferimento per le lingue gestite in memoria?


11

Java e .NET hanno meravigliosi garbage collector che gestiscono la memoria per te e modelli convenienti per il rilascio rapido di oggetti esterni ( Closeable, IDisposable), ma solo se sono di proprietà di un singolo oggetto. In alcuni sistemi potrebbe essere necessario consumare una risorsa in modo indipendente da due componenti e rilasciarla solo quando entrambi i componenti rilasciano la risorsa.

Nel moderno C ++ risolveresti questo problema con a shared_ptr, che rilascerebbe deterministicamente la risorsa quando tutte le cose shared_ptrvengono distrutte.

Esistono schemi documentati e comprovati per la gestione e il rilascio di risorse costose che non hanno un unico proprietario in sistemi di raccolta dei rifiuti non orientati agli oggetti e orientati agli oggetti?



1
@JoshCaswell Sì, e questo risolverebbe il problema, ma sto lavorando in uno spazio di raccolta rifiuti.
C. Ross,

8
Il conteggio dei riferimenti è una strategia di Garbage Collection.
Jörg W Mittag,

Risposte:


15

In generale, lo eviti avendo un unico proprietario, anche in lingue non gestite.

Ma il principio è lo stesso per le lingue gestite. Invece di chiudere immediatamente la risorsa costosa su un Close()decrementare un contatore (incrementato su Open()/ Connect()/ etc) fino a quando non si preme 0 a quel punto la chiusura effettivamente fa la chiusura. Probabilmente sembrerà e si comporterà come il modello Flyweight.


Questo è quello che stavo pensando anch'io, ma esiste un modello documentato per questo? Il peso mosca è certamente simile, ma specificamente per la memoria, come di solito definito.
C. Ross,

@ C.Ross Questo sembra essere un caso in cui i finalizzatori sono incoraggiati. È possibile utilizzare una classe wrapper attorno alla risorsa non gestita, aggiungendo un finalizzatore a quella classe per rilasciare la risorsa. Puoi anche averlo implementare IDisposable, tenere il conto per rilasciare la risorsa il più presto possibile, ecc. Probabilmente la cosa migliore, molte volte, è avere tutti e tre, ma il finalizzatore è probabilmente la parte più critica e l' IDisposableimplementazione è il meno critico.
Panzercrisis,

11
@Panzercrisis, tranne per il fatto che i finalizzatori non sono garantiti per l'esecuzione e, soprattutto, non sono garantiti per l'esecuzione tempestiva .
Caleth,

@Caleth Stavo pensando che la cosa dei conteggi avrebbe aiutato con la parte di prontezza. Per quanto non funzionino affatto, vuoi dire che il CLR potrebbe non aggirarlo prima che il programma finisca, o vuoi dire che potrebbero essere squalificati?
Panzercrisis,


14

In un linguaggio garbage collection (dove GC non è deterministico), non è possibile legare in modo affidabile la pulizia di una risorsa diversa dalla memoria alla durata di un oggetto: non è possibile stabilire quando verrà eliminato un oggetto. La fine della vita è interamente a discrezione del garbage collector. Il GC garantisce solo che un oggetto vivrà mentre è raggiungibile. Una volta che un oggetto diventa irraggiungibile, potrebbe essere ripulito ad un certo punto in futuro, il che può comportare l'esecuzione di finalizzatori.

Il concetto di "proprietà delle risorse" non si applica in realtà in un linguaggio GC. Il sistema GC possiede tutti gli oggetti.

Cosa offrono questi linguaggi con try-with-resource + Closeable (Java), usando istruzioni + IDisposable (C #) o con istruzioni + gestori di contesto (Python) è un modo per il flusso di controllo (! = Oggetti) di contenere una risorsa che viene chiuso quando il flusso di controllo lascia un ambito. In tutti questi casi, questo è simile a un inserimento automatico try { ... } finally { resource.close(); }. La durata dell'oggetto che rappresenta la risorsa non è correlata alla durata della risorsa: l'oggetto potrebbe continuare a vivere dopo la chiusura della risorsa e l'oggetto potrebbe diventare irraggiungibile mentre la risorsa è ancora aperta.

Nel caso delle variabili locali, questi approcci sono equivalenti a RAII, ma devono essere utilizzati esplicitamente nel sito di chiamata (diversamente dai distruttori C ++ che verranno eseguiti per impostazione predefinita). Un buon IDE avviserà quando questo viene omesso.

Questo non funziona per gli oggetti a cui fanno riferimento posizioni diverse dalle variabili locali. Qui, è irrilevante se ci sono uno o più riferimenti. È possibile tradurre i riferimenti alle risorse tramite riferimenti agli oggetti alla proprietà delle risorse tramite il flusso di controllo creando un thread separato che contiene questa risorsa, ma anche i thread sono risorse che devono essere scartate manualmente.

In alcuni casi è possibile delegare la proprietà delle risorse a una funzione chiamante. Invece di oggetti temporanei che fanno riferimento a risorse che dovrebbero (ma non possono) ripulire in modo affidabile, la funzione chiamante contiene un insieme di risorse che devono essere ripulite. Questo funziona solo fino a quando la durata di uno di questi oggetti sopravvive alla durata della funzione e pertanto fa riferimento a una risorsa che è già stata chiusa. Questo non può essere rilevato da un compilatore, a meno che la lingua non abbia un tracciamento della proprietà simile a Rust (nel qual caso esistono già soluzioni migliori per questo problema di gestione delle risorse).

Questo lascia l'unica soluzione praticabile: la gestione manuale delle risorse, possibilmente implementando il conteggio dei riferimenti. Questo è soggetto a errori, ma non impossibile. In particolare, dover pensare alla proprietà è insolito nei linguaggi GC, quindi il codice esistente potrebbe non essere sufficientemente esplicito sulle garanzie di proprietà.


3

Molte buone informazioni dalle altre risposte.

Tuttavia, per essere espliciti, il modello che potresti cercare è che usi piccoli oggetti di proprietà singola per il costrutto del flusso di controllo simile a RAII tramite usinge IDispose, in combinazione con un oggetto (più grande, eventualmente conteggiato con riferimento) che contiene alcuni (operativi sistema) risorse.

Quindi ci sono i piccoli oggetti a proprietario singolo non condivisi che (tramite l'oggetto più piccolo IDisposee il usingcostrutto del flusso di controllo) possono a loro volta informare l'oggetto condiviso più grande (forse personalizzato Acquiree Releasemetodi).

(I metodi Acquiree Releasemostrati di seguito sono quindi disponibili anche al di fuori del costrutto using, ma senza la sicurezza dell'implicito tryin using.)


Un esempio in 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;
    }
}

Se questo dovrebbe essere C # (che sembra), l'implementazione di riferimento <T> è leggermente errata. Il contratto IDisposable.Disposeprevede che la chiamata Disposepiù volte sullo stesso oggetto deve essere vietata. Se dovessi implementare un modello del genere renderei anche Releaseprivato per evitare errori non necessari e utilizzare la delega invece dell'ereditarietà (rimuovere l'interfaccia, fornire una SharedDisposableclasse semplice che può essere utilizzata con dispositivi usa e getta arbitrari), ma quelli sono più questioni di gusto.
Voo

@Voo, ok, buon punto, grazie!
Erik Eidt,

1

La stragrande maggioranza degli oggetti in un sistema dovrebbe generalmente adattarsi a uno dei tre modelli:

  1. Oggetti il ​​cui stato non cambierà mai e ai quali i riferimenti vengono mantenuti puramente come mezzo per incapsulare lo stato. Le entità che contengono riferimenti non sanno né si preoccupano se altre entità detengono riferimenti allo stesso oggetto.

  2. Oggetti che sono sotto il controllo esclusivo di una singola entità, che è il solo proprietario di tutto lo stato in esso, e utilizza l'oggetto esclusivamente come mezzo per incapsulare lo stato (possibilmente mutevole) in esso.

  3. Oggetti che appartengono a una singola entità, ma che altre entità possono utilizzare in modo limitato. Il proprietario dell'oggetto può utilizzarlo non solo come mezzo per incapsulare lo stato, ma anche per incapsulare una relazione con le altre entità che lo condividono.

Tracciare la garbage collection funziona meglio del conteggio dei riferimenti per il n. 1, perché il codice che utilizza tali oggetti non deve fare nulla di speciale quando viene fatto con l'ultimo riferimento rimanente. Il conteggio dei riferimenti non è necessario per # 2 perché gli oggetti avranno esattamente un proprietario e sapranno quando non avranno più bisogno dell'oggetto. Lo scenario n. 3 può presentare qualche difficoltà se il proprietario di un oggetto lo uccide mentre altre entità mantengono ancora dei riferimenti; anche lì, un GC di tracciamento può essere migliore del conteggio dei riferimenti per garantire che i riferimenti a oggetti morti rimangano identificabili in modo affidabile come riferimenti a oggetti morti, fintanto che tali riferimenti esistono.

Ci sono alcune situazioni in cui può essere necessario che un oggetto condivisibile privo di proprietario acquisisca e trattieni risorse esterne fintanto che qualcuno ha bisogno dei suoi servizi e dovrebbe rilasciarli quando i suoi servizi non sono più richiesti. Ad esempio, un oggetto che incapsula il contenuto di un file di sola lettura potrebbe essere condiviso e utilizzato da molte entità contemporaneamente senza che nessuno di loro debba conoscere o preoccuparsi dell'esistenza reciproca. Tali circostanze sono tuttavia rare. La maggior parte degli oggetti avrà un unico proprietario chiaro, oppure sarà meno proprietario. La proprietà multipla è possibile, ma raramente utile.


0

La proprietà condivisa ha raramente senso

Questa risposta potrebbe essere leggermente non tangente, ma devo chiedermi, quanti casi ha senso dal punto di vista dell'utente per condividere la proprietà ? Almeno nei domini in cui ho lavorato, praticamente non ce n'erano perché altrimenti ciò implicherebbe che l'utente non deve semplicemente rimuovere qualcosa una volta da un posto, ma rimuoverlo esplicitamente da tutti i proprietari rilevanti prima che la risorsa sia effettivamente rimosso dal sistema.

Spesso è un'idea ingegneristica di livello inferiore impedire che le risorse vengano distrutte mentre qualcos'altro vi sta ancora accedendo, come un altro thread. Spesso quando un utente richiede di chiudere / rimuovere / eliminare qualcosa dal software, dovrebbe essere rimosso il più presto possibile (ogni volta che è sicuro da rimuovere), e certamente non dovrebbe indugiare in giro e causare una perdita di risorse per tutto il tempo l'applicazione è in esecuzione.

Ad esempio, una risorsa di gioco in un videogioco potrebbe fare riferimento a un materiale dalla libreria dei materiali. Certamente non vogliamo, diciamo, un crash puntatore penzolante se il materiale viene rimosso dalla libreria dei materiali in un thread mentre un altro thread sta ancora accedendo al materiale a cui fa riferimento l'asset di gioco. Ma ciò non significa che abbia senso che le risorse di gioco condividano la proprietà dei materiali a cui fanno riferimento con la libreria dei materiali. Non vogliamo forzare l'utente a rimuovere esplicitamente il materiale dalla libreria di risorse e materiali. Vogliamo solo assicurarci che i materiali non vengano rimossi dalla biblioteca del materiale, l'unico proprietario sensibile dei materiali, fino a quando altri fili non avranno finito di accedere al materiale.

Perdite di risorse

Eppure ho lavorato con un ex team che ha adottato GC per tutti i componenti del software. E mentre ciò ha davvero aiutato a garantire che non avessimo mai distrutto risorse mentre altri thread le stavano ancora accedendo, abbiamo invece ottenuto la nostra quota di perdite di risorse .

E queste non erano banali perdite di risorse di un tipo che sconvolge solo gli sviluppatori, come un kilobyte di memoria trapelato dopo un'ora di sessione. Si trattava di perdite epiche, spesso gigabyte di memoria su una sessione attiva, che portavano a segnalazioni di bug. Perché ora quando si fa riferimento alla proprietà di una risorsa (e quindi condivisa in proprietà) tra, diciamo, 8 diverse parti del sistema, allora ne basta uno solo per non riuscire a rimuovere la risorsa in risposta all'utente che ne richiede la rimozione essere trapelato e possibilmente indefinitamente.

Quindi non sono mai stato un grande fan del GC o del conteggio dei riferimenti applicati su larga scala a causa della facilità con cui sono stati creati software che perdono. Quello che sarebbe stato in precedenza un crash puntatore penzolante che è facile da rilevare si trasforma in una perdita di risorse molto difficile da rilevare che può facilmente volare sotto il radar dei test.

Riferimenti deboli possono mitigare questo problema se il linguaggio / la libreria li fornisce, ma ho trovato difficile ottenere un team di sviluppatori di skillset misti per poter utilizzare coerentemente riferimenti deboli quando appropriato. E questa difficoltà non era legata solo al team interno, ma a ogni singolo sviluppatore di plugin per il nostro software. Anche loro potrebbero facilmente causare la fuoriuscita di risorse dal sistema semplicemente memorizzando un riferimento persistente a un oggetto in modi che hanno reso difficile risalire al plugin come colpevole, quindi abbiamo anche ottenuto la parte del nostro leone di segnalazioni di errori derivanti dalle nostre risorse software essere trapelato semplicemente perché un plugin il cui codice sorgente era al di fuori del nostro controllo non è riuscito a rilasciare riferimenti a quelle risorse costose.

Soluzione: rimozione differita e periodica

Quindi la mia soluzione in seguito alla quale ho applicato i miei progetti personali che mi ha dato il meglio che ho trovato da entrambi i mondi è stata quella di eliminare il concetto che referencing=ownershipperò ha ancora differito la distruzione delle risorse.

Di conseguenza, ora ogni volta che l'utente fa qualcosa che richiede la rimozione di una risorsa, l'API viene espressa in termini di rimozione della risorsa:

ecs->remove(component);

... che modella la logica dell'utente finale in modo molto semplice. Tuttavia, la risorsa (componente) non può essere rimossa immediatamente se ci sono altri thread di sistema nella loro fase di elaborazione in cui potrebbero accedere allo stesso componente contemporaneamente.

Quindi questi thread di elaborazione danno quindi tempo qua e là che consente a un thread che assomiglia a un garbage collector di svegliarsi e " fermare il mondo " e distruggere tutte le risorse che sono state richieste per essere rimosse bloccando i thread dall'elaborazione di quei componenti fino al termine . L'ho sintonizzato in modo che la quantità di lavoro che deve essere fatto qui sia generalmente minima e non si riduca notevolmente ai frame rate.

Ora non posso dire che questo sia un metodo provato e testato e ben documentato, ma è qualcosa che sto usando da alcuni anni ormai senza mal di testa e perdite di risorse. Raccomando di esplorare approcci come questo quando è possibile per la tua architettura adattarsi a questo tipo di modello di concorrenza in quanto è molto meno pesante di GC o di conteggio dei ref e non rischia questo tipo di perdite di risorse che volano sotto il radar dei test.

L'unico posto in cui ho trovato utili il conteggio dei ref o GC è per le strutture di dati persistenti. In quel caso è il territorio della struttura dei dati, ben distinto dalle preoccupazioni degli utenti, e lì ha davvero senso che ogni copia immutabile condivida potenzialmente la proprietà degli stessi dati non modificati.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.