16 avril 2025

Références circulaires et fuites de mémoire : Comment porter du code C# vers C++

Une fois le code traduit et compilé avec succès, nous rencontrons souvent des problèmes à l'exécution, notamment liés à la gestion de la mémoire, qui ne sont pas typiques de l'environnement C# avec son ramasse-miettes. Dans cet article, nous allons examiner des problèmes spécifiques de gestion de la mémoire, tels que les références circulaires et la suppression prématurée d'objets, et montrer comment notre approche aide à les détecter et à les résoudre.

Problèmes de gestion de la mémoire

1. Références fortes circulaires

En C#, le ramasse-miettes peut gérer correctement les références circulaires en détectant et en supprimant les groupes d'objets inaccessibles. Cependant, en C++, les pointeurs intelligents utilisent le comptage de références. Si deux objets se référencent mutuellement avec des références fortes (SharedPtr), leurs compteurs de références n'atteindront jamais zéro, même s'il n'y a plus de références externes vers eux depuis le reste du programme. Cela conduit à une fuite de mémoire, car les ressources occupées par ces objets ne sont jamais libérées.

Considérons un exemple typique :

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this); // Document référence Element
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc; // Element référence en retour Document
    }
}

Ce code est converti en ce qui suit :

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    SharedPtr<Document> owner; // Référence forte
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Ici, l'objet Document contient un SharedPtr vers Element, et l'objet Element contient un SharedPtr vers Document. Un cycle de références fortes est créé. Même si la variable qui détenait initialement le pointeur vers Document sort de portée, les compteurs de références pour les deux objets resteront à 1 en raison des références mutuelles. Les objets ne seront jamais supprimés.

Ce problème est résolu en définissant l'attribut CppWeakPtr sur l'un des champs impliqués dans le cycle, par exemple, sur le champ Element.owner. Cet attribut demande au traducteur d'utiliser une référence faible WeakPtr pour ce champ, ce qui n'incrémente pas le compteur de références fortes.

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
    }
}

class Element {
    [CppWeakPtr] private Document owner;
    public Element(Document doc)
    {
        owner = doc;
    }
}

Le code C++ résultant :

class Document : public Object {
    SharedPtr<Element> root; // Référence forte
public:
    Document()
    {
        root = MakeObject<Element>(this);
    }
}

class Element {
    WeakPtr<Document> owner; // Maintenant, c'est une référence faible
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Maintenant, Element détient une référence faible vers Document, brisant le cycle. Lorsque le dernier SharedPtr<Document> externe disparaît, l'objet Document est supprimé. Cela déclenche la suppression du champ root (SharedPtr<Element>), ce qui décrémente le compteur de références de Element. S'il n'y avait pas d'autres références fortes vers Element, il est également supprimé.

2. Suppression d'objet pendant la construction

Ce problème survient si un objet est passé via SharedPtr à un autre objet ou méthode pendant sa construction, avant qu'une référence forte “permanente” ne soit établie vers lui. Dans ce cas, le SharedPtr temporaire créé lors de l'appel du constructeur pourrait être la seule référence. S'il est détruit après la fin de l'appel, le compteur de références atteint zéro, entraînant un appel immédiat au destructeur et la suppression de l'objet pas encore entièrement construit.

Considérons un exemple :

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
    }
    public void Prepare(Element elm)
    {
        ...
    }
}

class Element {
    public Element(Document doc)
    {
        doc.Prepare(this);
    }
}

Le traducteur produit ce qui suit :

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this); // Protection contre la suppression prématurée
        root = MakeObject<Element>(this);
    }
    void Prepare(SharedPtr<Element> elm)
    {
        ...
    }
}

class Element {
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this); // Protection contre la suppression prématurée
        doc->Prepare(this);
    }
}

En entrant dans la méthode Document::Prepare, un objet SharedPtr temporaire est créé, qui pourrait alors supprimer l'objet Element incomplètement construit car aucune référence forte ne subsiste vers lui. Comme montré dans l'article précédent, ce problème est résolu en ajoutant une variable locale ThisProtector guard au code du constructeur de Element. Le traducteur le fait automatiquement. Le constructeur de l'objet guard incrémente le compteur de références fortes pour this de un, et son destructeur le décrémente à nouveau, sans provoquer la suppression de l'objet.

3. Double suppression d'un objet lorsqu'un constructeur lève une exception

Considérons une situation où le constructeur d'un objet lève une exception après que certains de ses champs ont déjà été créés et initialisés, lesquels pourraient à leur tour contenir des références fortes vers l'objet en cours de construction.

class Document {
    private Element root;
    public Document()
    {
        root = new Element(this);
        throw new Exception("Failed to construct Document object");
    }
}

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc;
    }
}

Après conversion, nous obtenons :

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this);
        root = MakeObject<Element>(this);
        throw Exception(u"Failed to construct Document object");
    }
}

class Element {
    SharedPtr<Document> owner;
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this);
        owner = doc;
    }
}

Après que l'exception est levée dans le constructeur du document et que l'exécution quitte le code du constructeur, le déroulement de la pile commence, incluant la suppression des champs de l'objet Document incomplètement construit. Ceci, à son tour, mène à la suppression du champ Element::owner, qui contient une référence forte vers l'objet en cours de suppression. Cela résulte en la suppression d'un objet qui est déjà en cours de déconstruction, conduisant à diverses erreurs d'exécution.

Définir l'attribut CppWeakPtr sur le champ Element.owner résout ce problème. Cependant, tant que les attributs ne sont pas placés, le débogage de telles applications est difficile en raison d'arrêts imprévisibles. Pour simplifier le dépannage, il existe un mode de compilation de débogage spécial où le compteur de références interne de l'objet est déplacé vers le tas et complété par un indicateur. Cet indicateur n'est défini qu'une fois l'objet entièrement construit – au niveau de la fonction MakeObject, après la sortie du constructeur. Si le pointeur est détruit avant que l'indicateur ne soit défini, l'objet n'est pas supprimé.

4. Suppression de chaînes d'objets

class Node {
    public Node next;
}
class Node : public Object {
public:
    SharedPtr<Node> next;
}

La suppression de chaînes d'objets se fait récursivement, ce qui peut entraîner un débordement de pile si la chaîne est longue – plusieurs milliers d'objets ou plus. Ce problème est résolu en ajoutant un finaliseur, traduit en destructeur, qui supprime la chaîne par itération.

Recherche des références circulaires

Corriger le problème des références circulaires est simple – ajouter un attribut au code C#. La mauvaise nouvelle est que le développeur responsable de la publication du produit pour C++ ne sait généralement pas quelle référence spécifique devrait être faible, ni même qu'un cycle existe.

Pour faciliter la recherche de cycles, nous avons développé un ensemble d'outils qui fonctionnent de manière similaire. Ils reposent sur deux mécanismes internes : un registre global des objets et l'extraction d'informations sur les champs de référence d'un objet.

Le registre global contient une liste des objets existant actuellement. Le constructeur de la classe System::Object place une référence à l'objet courant dans ce registre, et le destructeur l'en retire. Naturellement, le registre n'existe que dans un mode de compilation de débogage spécial, afin de ne pas affecter les performances du code converti en mode release.

Les informations sur les champs de référence d'un objet peuvent être extraites en appelant la fonction virtuelle GetSharedMembers(), déclarée au niveau de System::Object. Cette fonction retourne une liste complète des pointeurs intelligents détenus dans les champs de l'objet et leurs objets cibles. Dans le code de la bibliothèque, cette fonction est écrite manuellement, tandis que dans le code généré, elle est intégrée par le traducteur.

Il existe plusieurs façons de traiter les informations fournies par ces mécanismes. Basculer entre elles se fait en utilisant les options de traducteur appropriées et/ou des constantes de préprocesseur.

  1. Lorsque la fonction correspondante est appelée, le graphe complet des objets existant actuellement, y compris les informations sur les types, les champs et les relations, est enregistré dans un fichier. Ce graphe peut ensuite être visualisé à l'aide de l'utilitaire graphviz. Typiquement, ce fichier est créé après chaque test pour suivre facilement les fuites.
  2. Lorsque la fonction correspondante est appelée, un graphe des objets existant actuellement qui ont des dépendances circulaires – où toutes les références impliquées sont fortes – est enregistré dans un fichier. Ainsi, le graphe ne contient que des informations pertinentes. Les objets qui ont déjà été analysés sont exclus de l'analyse lors des appels ultérieurs à cette fonction. Cela rend beaucoup plus facile de voir exactement ce qui a fui lors d'un test spécifique.
  3. Lorsque la fonction correspondante est appelée, des informations sur les îlots d'isolement existant actuellement – des ensembles d'objets où toutes les références vers eux sont détenues par d'autres objets du même ensemble – sont affichées dans la console. Les objets référencés par des variables statiques ou locales ne sont pas inclus dans cette sortie. Les informations sur chaque type d'îlot d'isolement, c'est-à-dire l'ensemble des classes créant un îlot typique, ne sont affichées qu'une seule fois.
  4. Le destructeur de la classe SharedPtr parcourt les références entre les objets, en commençant par l'objet dont il gère la durée de vie, et affiche des informations sur tous les cycles détectés – tous les cas où l'objet de départ peut être atteint à nouveau en suivant les références fortes.

Un autre outil de débogage utile est la vérification qu'après l'appel du constructeur d'un objet par la fonction MakeObject, le compteur de références fortes pour cet objet est égal à zéro. Si ce n'est pas le cas, cela indique un problème potentiel – un cycle de références, un comportement indéfini si une exception est levée, etc.

Résumé

Malgré l'inadéquation fondamentale entre les systèmes de types C# et C++, nous avons réussi à construire un système de pointeurs intelligents qui permet au code converti de s'exécuter avec un comportement proche de l'original. En même temps, la tâche n'a pas été résolue de manière entièrement automatique. Nous avons créé des outils qui simplifient considérablement la recherche de problèmes potentiels.

Nouvelles connexes

Vidéos associées

Articles liés