16 April 2025

Zirkuläre Referenzen und Speicherlecks: Wie man C#-Code nach C++ portiert

Nachdem der Code erfolgreich übersetzt und kompiliert wurde, treten häufig Laufzeitprobleme auf, insbesondere im Zusammenhang mit der Speicherverwaltung, die für die C#-Umgebung mit ihrem Garbage Collector untypisch sind. In diesem Artikel werden wir auf spezifische Speicherverwaltungsprobleme wie zirkuläre Referenzen und vorzeitige Objektlöschung eingehen und zeigen, wie unser Ansatz hilft, diese zu erkennen und zu beheben.

Probleme bei der Speicherverwaltung

1. Zirkuläre starke Referenzen

In C# kann der Garbage Collector zirkuläre Referenzen korrekt handhaben, indem er Gruppen von unerreichbaren Objekten erkennt und entfernt. In C++ verwenden intelligente Zeiger jedoch die Referenzzählung. Wenn zwei Objekte sich gegenseitig mit starken Referenzen (SharedPtr) referenzieren, erreichen ihre Referenzzähler niemals den Wert Null, selbst wenn keine externen Referenzen mehr aus dem restlichen Programm auf sie verweisen. Dies führt zu einem Speicherleck, da die von diesen Objekten belegten Ressourcen niemals freigegeben werden.

Betrachten Sie ein typisches Beispiel:

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

class Element {
    private Document owner;
    public Element(Document doc)
    {
        owner = doc; // Element verweist zurück auf Document
    }
}

Dieser Code wird in Folgendes konvertiert:

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

class Element {
    SharedPtr<Document> owner; // Starke Referenz
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Hier enthält das Document-Objekt einen SharedPtr auf Element, und das Element-Objekt enthält einen SharedPtr auf Document. Es entsteht ein Zyklus starker Referenzen. Selbst wenn die Variable, die ursprünglich den Zeiger auf Document hielt, aus dem Gültigkeitsbereich verschwindet, bleiben die Referenzzähler für beide Objekte aufgrund der gegenseitigen Verweise bei 1. Die Objekte werden niemals gelöscht.

Dies wird behoben, indem das Attribut CppWeakPtr auf einem der am Zyklus beteiligten Felder gesetzt wird, zum Beispiel auf dem Feld Element.owner. Dieses Attribut weist den Übersetzer an, eine schwache Referenz (WeakPtr) für dieses Feld zu verwenden, die den Zähler für starke Referenzen nicht erhöht.

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

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

Der resultierende C++-Code:

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

class Element {
    WeakPtr<Document> owner; // Jetzt ist dies eine schwache Referenz
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

Jetzt hält Element eine schwache Referenz auf Document, wodurch der Zyklus unterbrochen wird. Wenn der letzte externe SharedPtr<Document> verschwindet, wird das Document-Objekt gelöscht. Dies löst die Löschung des root-Feldes (SharedPtr<Element>) aus, was den Referenzzähler von Element dekrementiert. Wenn keine anderen starken Referenzen auf Element vorhanden waren, wird es ebenfalls gelöscht.

2. Objektlöschung während der Konstruktion

Dieses Problem tritt auf, wenn ein Objekt während seiner Konstruktion über SharedPtr an ein anderes Objekt oder eine Methode übergeben wird, bevor eine “permanente” starke Referenz darauf etabliert wird. In diesem Fall könnte der temporäre SharedPtr, der während des Konstruktoraufrufs erstellt wird, die einzige Referenz sein. Wenn er nach Abschluss des Aufrufs zerstört wird, erreicht der Referenzzähler Null, was zu einem sofortigen Aufruf des Destruktors und zur Löschung des noch nicht vollständig konstruierten Objekts führt.

Betrachten Sie ein Beispiel:

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);
    }
}

Der Übersetzer gibt Folgendes aus:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this); // Schutz vor vorzeitiger Löschung
        root = MakeObject<Element>(this);
    }
    void Prepare(SharedPtr<Element> elm)
    {
        ...
    }
}

class Element {
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this); // Schutz vor vorzeitiger Löschung
        doc->Prepare(this);
    }
}

Beim Eintritt in die Document::Prepare-Methode wird ein temporäres SharedPtr-Objekt erstellt, das dann das unvollständig konstruierte Element-Objekt löschen könnte, da keine starken Referenzen mehr darauf verbleiben. Wie im vorherigen Artikel gezeigt, wird dieses Problem durch Hinzufügen einer lokalen ThisProtector guard-Variable zum Konstruktorcode von Element gelöst. Der Übersetzer tut dies automatisch. Der Konstruktor des guard-Objekts erhöht den Zähler für starke Referenzen für this um eins, und sein Destruktor dekrementiert ihn wieder, ohne eine Objektlöschung zu verursachen.

3. Doppelte Löschung eines Objekts, wenn ein Konstruktor eine Ausnahme auslöst

Betrachten Sie eine Situation, in der der Konstruktor eines Objekts eine Ausnahme auslöst, nachdem einige seiner Felder bereits erstellt und initialisiert wurden, die ihrerseits starke Referenzen zurück auf das zu konstruierende Objekt enthalten könnten.

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;
    }
}

Nach der Konvertierung erhalten wir:

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;
    }
}

Nachdem die Ausnahme im Konstruktor des Dokuments ausgelöst wurde und die Ausführung den Konstruktorcode verlässt, beginnt die Stack-Entwicklung (Stack Unwinding), einschließlich der Löschung von Feldern des unvollständig konstruierten Document-Objekts. Dies führt wiederum zur Löschung des Element::owner-Feldes, das eine starke Referenz auf das zu löschende Objekt enthält. Dies resultiert in der Löschung eines Objekts, das sich bereits im Dekonstruktionsprozess befindet, was zu verschiedenen Laufzeitfehlern führt.

Das Setzen des CppWeakPtr-Attributs auf dem Element.owner-Feld löst dieses Problem. Bis die Attribute jedoch platziert sind, ist das Debuggen solcher Anwendungen aufgrund unvorhersehbarer Beendigungen schwierig. Um die Fehlersuche zu vereinfachen, gibt es einen speziellen Debug-Build-Modus, bei dem der interne Objektreferenzzähler auf den Heap verschoben und um ein Flag ergänzt wird. Dieses Flag wird erst gesetzt, nachdem das Objekt vollständig konstruiert ist – auf der Ebene der MakeObject-Funktion, nach Verlassen des Konstruktors. Wenn der Zeiger zerstört wird, bevor das Flag gesetzt ist, wird das Objekt nicht gelöscht.

4. Löschen von Objektketten

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

Das Löschen von Objektketten erfolgt rekursiv, was zu einem Stapelüberlauf (Stack Overflow) führen kann, wenn die Kette lang ist – mehrere tausend Objekte oder mehr. Dieses Problem wird durch Hinzufügen eines Finalizers gelöst, der in einen Destruktor übersetzt wird, welcher die Kette durch Iteration löscht.

Finden von zirkulären Referenzen

Das Problem der zirkulären Referenzen zu beheben ist einfach – fügen Sie dem C#-Code ein Attribut hinzu. Die schlechte Nachricht ist, dass der Entwickler, der für die Freigabe des Produkts für C++ verantwortlich ist, normalerweise nicht weiß, welche spezifische Referenz schwach sein sollte, noch dass überhaupt ein Zyklus existiert.

Um die Suche nach Zyklen zu erleichtern, haben wir eine Reihe von Werkzeugen entwickelt, die ähnlich funktionieren. Sie basieren auf zwei internen Mechanismen: einer globalen Objektregistrierung und der Extraktion von Informationen über die Referenzfelder eines Objekts.

Die globale Registrierung enthält eine Liste der aktuell existierenden Objekte. Der Konstruktor der System::Object-Klasse legt eine Referenz auf das aktuelle Objekt in dieser Registrierung ab, und der Destruktor entfernt sie. Natürlich existiert die Registrierung nur in einem speziellen Debug-Build-Modus, um die Leistung des konvertierten Codes im Release-Modus nicht zu beeinträchtigen.

Informationen über die Referenzfelder eines Objekts können durch Aufruf der virtuellen Funktion GetSharedMembers() extrahiert werden, die auf der Ebene von System::Object deklariert ist. Diese Funktion gibt eine vollständige Liste der intelligenten Zeiger zurück, die in den Feldern des Objekts gehalten werden, sowie deren Zielobjekte. Im Bibliotheks-Code wird diese Funktion manuell geschrieben, während sie im generierten Code vom Übersetzer eingebettet wird.

Es gibt mehrere Möglichkeiten, die von diesen Mechanismen bereitgestellten Informationen zu verarbeiten. Das Umschalten zwischen ihnen erfolgt durch die Verwendung entsprechender Übersetzeroptionen und/oder Präprozessorkonstanten.

  1. Wenn die entsprechende Funktion aufgerufen wird, wird der vollständige Graph der aktuell existierenden Objekte, einschließlich Informationen über Typen, Felder und Beziehungen, in eine Datei gespeichert. Dieser Graph kann dann mit dem graphviz-Dienstprogramm visualisiert werden. Typischerweise wird diese Datei nach jedem Test erstellt, um Lecks bequem nachverfolgen zu können.
  2. Wenn die entsprechende Funktion aufgerufen wird, wird ein Graph der aktuell existierenden Objekte, die zirkuläre Abhängigkeiten aufweisen – bei denen alle beteiligten Referenzen stark sind – in eine Datei gespeichert. Somit enthält der Graph nur relevante Informationen. Objekte, die bereits analysiert wurden, werden bei nachfolgenden Aufrufen dieser Funktion von der Analyse ausgeschlossen. Dies erleichtert es erheblich zu erkennen, was genau bei einem bestimmten Test verloren gegangen ist (geleakt hat).
  3. Wenn die entsprechende Funktion aufgerufen wird, werden Informationen über aktuell existierende Isolationsinseln – Mengen von Objekten, bei denen alle Referenzen auf sie von anderen Objekten innerhalb derselben Menge gehalten werden – auf der Konsole ausgegeben. Objekte, auf die durch statische oder lokale Variablen verwiesen wird, sind in dieser Ausgabe nicht enthalten. Informationen über jeden Typ von Isolationsinsel, d.h. die Menge der Klassen, die eine typische Insel bilden, werden nur einmal ausgegeben.
  4. Der Destruktor der SharedPtr-Klasse durchläuft die Referenzen zwischen Objekten, beginnend mit dem Objekt, dessen Lebensdauer er verwaltet, und gibt Informationen über alle erkannten Zyklen aus – alle Fälle, in denen das Startobjekt durch Verfolgung starker Referenzen wieder erreicht werden kann.

Ein weiteres nützliches Debugging-Werkzeug ist die Überprüfung, ob nach dem Aufruf des Konstruktors eines Objekts durch die MakeObject-Funktion der Zähler für starke Referenzen für dieses Objekt Null ist. Ist dies nicht der Fall, deutet dies auf ein potenzielles Problem hin – einen Referenzzyklus, undefiniertes Verhalten, wenn eine Ausnahme ausgelöst wird, und so weiter.

Zusammenfassung

Trotz der fundamentalen Nichtübereinstimmung zwischen den C#- und C++-Typsystemen ist es uns gelungen, ein System intelligenter Zeiger zu entwickeln, das es ermöglicht, den konvertierten Code mit einem Verhalten auszuführen, das dem Original nahekommt. Gleichzeitig wurde die Aufgabe nicht in einem vollautomatischen Modus gelöst. Wir haben Werkzeuge geschaffen, die die Suche nach potenziellen Problemen erheblich vereinfachen.

Verwandte Nachrichten

Verwandte Videos

In Verbindung stehende Artikel