Un modèle de comptage de référence pour les langues gérées en mémoire?

11

Java et .NET ont de merveilleux récupérateurs qui gèrent la mémoire pour vous et des modèles pratiques pour libérer rapidement des objets externes ( Closeable, IDisposable), mais seulement s'ils appartiennent à un seul objet. Dans certains systèmes, une ressource peut avoir besoin d'être consommée indépendamment par deux composants et d'être libérée uniquement lorsque les deux composants libèrent la ressource.

Dans le C ++ moderne, vous résoudriez ce problème avec a shared_ptr, qui libérerait de manière déterministe la ressource lorsque tous les shared_ptrfichiers sont détruits.

Existe-t-il des modèles documentés et éprouvés de gestion et de libération de ressources coûteuses qui n'ont pas de propriétaire unique dans des systèmes de collecte des ordures orientés objet et non déterministes?

Traverser
la source
1
Avez-vous vu le comptage de référence automatique de Clang , également utilisé dans Swift ?
jscs
1
@JoshCaswell Oui, et cela résoudrait le problème, mais je travaille dans un espace récupéré.
C. Ross
8
Le comptage de références est une stratégie de récupération de place.
Jörg W Mittag

Réponses:

15

En général, vous l'évitez en ayant un seul propriétaire, même dans les langues non gérées.

Mais le principe est le même pour les langages gérés. Au lieu de fermer immédiatement la ressource coûteuse sur un, Close()vous décrémentez un compteur (incrémenté sur Open()/ Connect()/ etc) jusqu'à ce que vous atteigniez 0, moment auquel la fermeture fait la fermeture. Il ressemblera et agira probablement comme le modèle Flyweight.

Telastyn
la source
C'est ce que je pensais aussi, mais y a-t-il un modèle documenté pour cela? Le poids des mouches est certainement similaire, mais spécifiquement pour la mémoire telle qu'elle est généralement définie.
C. Ross
@ C.Ross Cela semble être un cas dans lequel les finaliseurs sont encouragés. Vous pouvez utiliser une classe wrapper autour de la ressource non managée, en ajoutant un finaliseur à cette classe pour libérer la ressource. Vous pouvez également le mettre en œuvre IDisposable, maintenir compte de libérer la ressource le plus tôt possible, etc. Probablement la meilleure chose, beaucoup de temps, est d'avoir tous les trois, mais le finaliseur est probablement la partie la plus critique et la IDisposablemise en œuvre est le moins critique.
Panzercrisis
11
@Panzercrisis sauf que les finaliseurs ne sont pas garantis pour fonctionner, et surtout pas garantis pour fonctionner rapidement .
Caleth
@Caleth, je pensais que le fait de compter aiderait à la rapidité. Dans la mesure où ils ne fonctionnent pas du tout, voulez-vous dire que le CLR pourrait tout simplement ne pas y arriver avant la fin du programme, ou voulez-vous dire qu'ils pourraient être disqualifiés purement et simplement?
Panzercrisis
14

Dans un langage garbage collection (où GC n'est pas déterministe), il n'est pas possible de lier de manière fiable le nettoyage d'une ressource autre que la mémoire à la durée de vie d'un objet: il n'est pas possible d'indiquer quand un objet sera supprimé. La fin de vie est entièrement à la discrétion du ramasse-miettes. Le GC garantit seulement qu'un objet vivra tant qu'il sera accessible. Une fois qu'un objet devient inaccessible, il peut être nettoyé à un moment donné dans le futur, ce qui peut impliquer l'exécution de finaliseurs.

Le concept de «propriété des ressources» ne s'applique pas vraiment dans un langage GC. Le système GC possède tous les objets.

Ce que ces langages offrent avec try-with-resource + Closeable (Java), en utilisant des instructions + IDisposable (C #), ou avec des instructions + context managers (Python) est un moyen pour le flux de contrôle (! = Objets) de contenir une ressource qui est fermé lorsque le flux de contrôle quitte une étendue. Dans tous ces cas, cela ressemble à une insertion automatique try { ... } finally { resource.close(); }. La durée de vie de l'objet représentant la ressource n'est pas liée à la durée de vie de la ressource: l'objet peut continuer à vivre après la fermeture de la ressource et l'objet peut devenir inaccessible tant que la ressource est toujours ouverte.

Dans le cas des variables locales, ces approches sont équivalentes à RAII, mais doivent être utilisées explicitement sur le site d'appel (contrairement aux destructeurs C ++ qui s'exécuteront par défaut). Un bon IDE avertira lorsque cela est omis.

Cela ne fonctionne pas pour les objets référencés à partir d'emplacements autres que des variables locales. Ici, peu importe qu'il y ait une ou plusieurs références. Il est possible de traduire le référencement des ressources via des références d'objet à la propriété des ressources via le flux de contrôle en créant un thread distinct qui contient cette ressource, mais les threads sont également des ressources qui doivent être supprimées manuellement.

Dans certains cas, il est possible de déléguer la propriété des ressources à une fonction appelante. Au lieu que les objets temporaires référencent des ressources qu'ils doivent (mais ne peuvent pas) nettoyer de manière fiable, la fonction appelante contient un ensemble de ressources qui doivent être nettoyées. Cela ne fonctionne que jusqu'à ce que la durée de vie de l'un de ces objets dépasse la durée de vie de la fonction, et fait donc référence à une ressource qui a déjà été fermée. Cela ne peut pas être détecté par un compilateur, à moins que le langage n'ait un suivi de propriété semblable à Rust (auquel cas il existe déjà de meilleures solutions pour ce problème de gestion des ressources).

Cela reste la seule solution viable: la gestion manuelle des ressources, éventuellement en implémentant vous-même le comptage des références. Ceci est sujet aux erreurs, mais pas impossible. En particulier, avoir à penser à la propriété est inhabituel dans les langages du GC, donc le code existant peut ne pas être suffisamment explicite sur les garanties de propriété.

amon
la source
3

Beaucoup de bonnes informations des autres réponses.

Pourtant, pour être explicite, le modèle que vous recherchez peut-être est que vous utilisez de petits objets appartenant à un seul pour la construction de flux de contrôle de type RAII via usinget IDispose, en conjonction avec un objet (plus grand, éventuellement compté par référence) qui en contient (fonctionnant système).

Il y a donc les petits objets à propriétaire unique non partagés qui (via les objets plus petits IDisposeet la usingconstruction de flux de contrôle) peuvent à leur tour informer le plus grand objet partagé (peut-être des méthodes Acquireet des Releaseméthodes personnalisées ).

(Les méthodes Acquireet Releaseindiquées ci-dessous sont également disponibles en dehors de la construction using, mais sans la sécurité de l' tryimplicite dans using.)


Un exemple 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;
    }
}
Erik Eidt
la source
Si cela doit être C # (à quoi il ressemble), alors votre implémentation Reference <T> est subtilement incorrecte. Le contrat IDisposable.Disposestipule que l'appel Disposeplusieurs fois sur le même objet doit être un no-op. Si je devais implémenter un tel modèle, je le rendrais également Releaseprivé pour éviter les erreurs inutiles et utiliserais la délégation au lieu de l'héritage (supprimez l'interface, fournissez une SharedDisposableclasse simple qui peut être utilisée avec des jetables arbitraires), mais ce sont plus des questions de goût.
Voo
@Voo, ok, bon point, merci!
Erik Eidt
1

La grande majorité des objets d'un système doivent généralement correspondre à l'un des trois modèles suivants:

  1. Objets dont l'état ne changera jamais et auxquels les références sont tenues uniquement comme un moyen d'encapsuler l'état. Les entités qui détiennent des références ne savent ni ne se soucient de savoir si d'autres entités détiennent des références au même objet.

  2. Les objets qui sont sous le contrôle exclusif d'une seule entité, qui est l'unique propriétaire de tous les états qui s'y trouvent, et qui utilisent l'objet uniquement comme moyen d'encapsuler l'état (éventuellement mutable) qui s'y trouve.

  3. Objets appartenant à une seule entité, mais que d'autres entités sont autorisées à utiliser de manière limitée. Le propriétaire de l'objet peut l'utiliser non seulement comme moyen d'encapsuler l'état, mais également encapsuler une relation avec les autres entités qui le partagent.

Le suivi de la récupération de place fonctionne mieux que le comptage de références pour # 1, car le code qui utilise de tels objets n'a pas besoin de faire quoi que ce soit de spécial lorsqu'il est fait avec la dernière référence restante. Le comptage de références n'est pas nécessaire pour # 2 car les objets auront exactement un propriétaire, et il saura quand il n'a plus besoin de l'objet. Le scénario n ° 3 peut poser quelques difficultés si le propriétaire d'un objet le tue alors que d'autres entités détiennent encore des références; même là, un GC de suivi peut être meilleur que le comptage de références pour garantir que les références à des objets morts restent identifiables de manière fiable en tant que références à des objets morts, aussi longtemps que de telles références existent.

Il y a quelques situations où il peut être nécessaire qu'un objet partageable sans propriétaire acquière et détienne des ressources externes tant que quiconque a besoin de ses services, et devrait les libérer lorsque ses services ne sont plus nécessaires. Par exemple, un objet qui encapsule le contenu d'un fichier en lecture seule pourrait être partagé et utilisé par de nombreuses entités simultanément sans qu'aucune d'entre elles n'ait à connaître ou à se soucier de l'existence de l'autre. De telles circonstances sont cependant rares. La plupart des objets auront soit un seul propriétaire clair, soit seront sans propriétaire. La propriété multiple est possible, mais rarement utile.

supercat
la source
0

La propriété partagée a rarement un sens

Cette réponse peut être légèrement décalée, mais je dois demander, combien de cas est-il sensé du point de vue de l'utilisateur de partager la propriété ? Au moins dans les domaines dans lesquels j'ai travaillé, il n'y en avait pratiquement aucun car sinon cela impliquerait que l'utilisateur n'a pas besoin de simplement supprimer quelque chose une fois d'un endroit, mais de le supprimer explicitement de tous les propriétaires concernés avant que la ressource ne soit réellement supprimé du système.

C'est souvent une idée d'ingénierie de niveau inférieur pour empêcher la destruction des ressources pendant que quelque chose d'autre y accède, comme un autre thread. Souvent, lorsqu'un utilisateur demande à fermer / supprimer / supprimer quelque chose du logiciel, il doit être supprimé dès que possible (chaque fois qu'il est sûr de le supprimer), et il ne doit certainement pas s'attarder et provoquer une fuite de ressources aussi longtemps que l'application est en cours d'exécution.

Par exemple, un élément de jeu dans un jeu vidéo peut référencer un matériau de la bibliothèque de matériaux. Nous ne voulons certainement pas, disons, un crash de pointeur pendant si le matériau est supprimé de la bibliothèque de matériaux dans un thread alors qu'un autre thread accède toujours au matériel référencé par l'actif du jeu. Mais cela ne signifie pas qu'il soit logique que les actifs du jeu partagent la propriété des matériaux qu'ils référencent avec la bibliothèque de matériaux. Nous ne voulons pas forcer l'utilisateur à supprimer explicitement le matériau de la bibliothèque de ressources et de matériaux. Nous voulons juste nous assurer que les matériaux ne sont pas supprimés de la bibliothèque de matériel, le seul propriétaire sensé des matériaux, jusqu'à ce que les autres threads aient fini d'accéder au matériel.

Fuites de ressources

Pourtant, j'ai travaillé avec une ancienne équipe qui a adopté GC pour tous les composants du logiciel. Et même si cela a vraiment aidé à nous assurer que nous n'avions jamais de ressources détruites pendant que d'autres threads y accédaient, nous avons finalement obtenu notre part de fuites de ressources .

Et il ne s'agissait pas de fuites de ressources insignifiantes qui ne dérangent que les développeurs, comme un kilo-octet de mémoire qui a fui après une session d'une heure. Il s'agissait de fuites épiques, souvent des gigaoctets de mémoire sur une session active, conduisant à des rapports de bogues. Parce que maintenant, lorsque la propriété d'une ressource est référencée (et donc partagée en propriété) entre, disons, 8 parties différentes du système, il suffit d'une seule pour ne pas supprimer la ressource en réponse à l'utilisateur qui demande qu'elle soit supprimée pour elle à fuir et éventuellement indéfiniment.

Je n'ai donc jamais été un grand fan du GC ou du comptage de références appliqué à grande échelle en raison de la facilité avec laquelle ils ont créé un logiciel qui fuit. Ce qui aurait été autrefois un crash de pointeur pendant qui est facile à détecter se transforme en une fuite de ressources très difficile à détecter qui peut facilement voler sous le radar des tests.

Des références faibles peuvent atténuer ce problème si la langue / bibliothèque les fournit, mais j'ai trouvé difficile d'obtenir une équipe de développeurs de compétences mixtes pour pouvoir utiliser systématiquement des références faibles chaque fois que cela était approprié. Et cette difficulté n'était pas uniquement liée à l'équipe interne, mais à chaque développeur de plugin unique pour notre logiciel. Eux aussi pourraient facilement provoquer une fuite de ressources du système en stockant simplement une référence persistante à un objet de manière à ce qu'il soit difficile de remonter au plugin en tant que coupable, nous avons donc également obtenu notre part du lion des rapports de bogues résultant de nos ressources logicielles être divulgué simplement parce qu'un plugin dont le code source était hors de notre contrôle n'a pas réussi à libérer des références à ces ressources coûteuses.

Solution: suppression différée et périodique

Donc, ma solution plus tard, que j'ai appliquée à mes projets personnels qui m'ont donné le meilleur que j'ai trouvé dans les deux mondes, a été d'éliminer le concept, referencing=ownershipmais qui a encore retardé la destruction des ressources.

Par conséquent, chaque fois que l'utilisateur fait quelque chose qui nécessite la suppression d'une ressource, l'API est exprimée en termes de suppression de la ressource:

ecs->remove(component);

... qui modélise la logique utilisateur de manière très simple. Cependant, la ressource (composant) ne peut pas être supprimée immédiatement s'il existe d'autres threads système dans leur phase de traitement où ils pourraient accéder simultanément au même composant.

Donc, ces threads de traitement donnent ensuite du temps ici et là, ce qui permet à un thread qui ressemble à un garbage collector de se réveiller et de " stopper le monde " et de détruire toutes les ressources dont la suppression a été demandée tout en empêchant les threads de traiter ces composants jusqu'à ce qu'il soit terminé . J'ai réglé cela pour que la quantité de travail à faire ici soit généralement minime et ne coupe pas sensiblement les fréquences d'images.

Maintenant, je ne peux pas dire que c'est une méthode éprouvée et bien documentée, mais c'est quelque chose que j'utilise depuis quelques années sans aucun mal de tête et sans fuite de ressources. Je recommande d'explorer des approches comme celle-ci lorsqu'il est possible pour votre architecture de s'adapter à ce type de modèle de concurrence, car il est beaucoup moins lourd que GC ou le comptage de références et ne risque pas ces types de fuites de ressources volant sous le radar des tests.

Le seul endroit où j'ai trouvé le comptage de références ou GC utile est pour les structures de données persistantes. Dans ce cas, c'est le territoire de la structure de données, loin des préoccupations des utilisateurs, et là, il est en fait logique que chaque copie immuable partage potentiellement la propriété des mêmes données non modifiées.


la source