16 April 2025
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.
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.
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.
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.
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.
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.
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.
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.