16 四月 2025

循环引用与内存泄漏:如何将 C# 代码移植到 C++

代码成功转换并编译后,我们经常会遇到运行时问题,特别是与内存管理相关的问题,这在拥有垃圾回收器的 C# 环境中并不常见。在本文中,我们将深入探讨特定的内存管理问题,例如循环引用和对象过早删除,并展示我们的方法如何帮助检测和解决这些问题。

内存管理问题

1. 循环强引用

在 C# 中,垃圾回收器可以通过检测并移除不可达的对象组来正确处理循环引用。然而,在 C++ 中,智能指针使用引用计数。如果两个对象通过强引用(SharedPtr)相互引用,即使程序其余部分不再有对它们的外部引用,它们的引用计数也永远不会达到零。这会导致内存泄漏,因为这些对象占用的资源永远不会被释放。

考虑一个典型的例子:

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

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

此代码被转换为以下内容:

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

class Element {
    SharedPtr<Document> owner; // 强引用
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

在这里,Document 对象包含一个指向 ElementSharedPtr,而 Element 对象包含一个指向 DocumentSharedPtr。这就创建了一个强引用循环。即使最初持有 Document 指针的变量超出作用域,由于相互引用,这两个对象的引用计数仍将保持为 1。这些对象将永远不会被删除。

通过在参与循环的其中一个字段(例如 Element.owner 字段)上设置 CppWeakPtr 特性可以解决此问题。该特性指示转换器对此字段使用弱引用(WeakPtr),弱引用不会增加强引用计数。

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

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

生成的 C++ 代码:

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

class Element {
    WeakPtr<Document> owner; // 现在这是弱引用
public:
    Element(SharedPtr<Document> doc)
    {
        owner = doc;
    }
}

现在 Element 持有对 Document 的弱引用,打破了循环。当最后一个外部 SharedPtr<Document> 消失时,Document 对象被删除。这会触发 root 字段(SharedPtr<Element>)的删除,从而减少 Element 的引用计数。如果没有其他对 Element 的强引用,它也会被删除。

2. 构造期间的对象删除

如果在对象的构造期间,在为其建立“永久”强引用之前,通过 SharedPtr 将该对象传递给另一个对象或方法,则会发生此问题。在这种情况下,构造函数调用期间创建的临时 SharedPtr 可能是唯一的引用。如果它在调用完成后被销毁,引用计数将达到零,导致立即调用析构函数并删除尚未完全构造的对象。

考虑一个例子:

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

转换器输出以下内容:

class Document : public Object {
    SharedPtr<Element> root;
public:
    Document()
    {
        ThisProtector guard(this); // 防止过早删除的保护
        root = MakeObject<Element>(this);
    }
    void Prepare(SharedPtr<Element> elm)
    {
        ...
    }
}

class Element {
public:
    Element(SharedPtr<Document> doc)
    {
        ThisProtector guard(this); // 防止过早删除的保护
        doc->Prepare(this);
    }
}

进入 Document::Prepare 方法时,会创建一个临时的 SharedPtr 对象,由于没有其他强引用指向 Element 对象,该临时 SharedPtr 随后可能会删除未完全构造的 Element 对象。如前一篇文章所示,通过向 Element 构造函数代码添加局部 ThisProtector guard 变量可以解决此问题。转换器会自动执行此操作。guard 对象的构造函数将 this 的强引用计数加一,其析构函数会将其减一,但不会导致对象删除。

3. 构造函数抛出异常时对象的双重删除

考虑这样一种情况:对象的构造函数在某些字段已经创建并初始化后抛出异常,而这些字段可能又包含对正在构造的对象的强引用。

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

转换后,我们得到:

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

在文档构造函数中抛出异常并且执行退出构造函数代码后,栈展开开始,包括删除未完全构造的 Document 对象的字段。这又会导致删除 Element::owner 字段,该字段包含对正在被删除的对象的强引用。这导致删除一个已经在进行析构的对象,从而引发各种运行时错误。

Element.owner 字段上设置 CppWeakPtr 特性可以解决此问题。然而,在放置特性之前,由于不可预测的终止,调试此类应用程序很困难。为了简化问题排查,提供了一种特殊的调试构建模式,其中内部对象引用计数器移至堆中,并补充了一个标志。此标志仅在对象完全构造之后——在 MakeObject 函数级别,退出构造函数之后——才被设置。如果指针在标志设置之前被销毁,则不会删除该对象。

4. 删除对象链

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

删除对象链是递归完成的,如果链很长(几千个或更多对象),可能会导致栈溢出。通过添加终结器(转换为析构函数)来解决此问题,该终结器通过迭代删除链。

查找循环引用

修复循环引用问题很简单——向 C# 代码添加一个特性。坏消息是,负责为 C++ 发布产品的开发人员通常不知道哪个特定引用应该是弱引用,甚至不知道循环的存在。

为了便于查找循环,我们开发了一套运行方式类似的工具。它们依赖于两个内部机制:全局对象注册表和对象引用字段信息的提取。

全局注册表包含当前存在的对象的列表。System::Object 类的构造函数将对当前对象的引用放入此注册表,析构函数将其移除。当然,该注册表仅存在于特殊的调试构建模式中,以免影响转换后代码在发布模式下的性能。

可以通过调用在 System::Object 级别声明的虚函数 GetSharedMembers() 来提取对象的引用字段信息。此函数返回对象字段中保存的智能指针及其目标对象的完整列表。在库代码中,此函数是手动编写的,而在生成的代码中,它由转换器嵌入。

有几种方法可以处理这些机制提供的信息。通过使用适当的转换器选项和/或预处理器常量在它们之间切换。

  1. 调用相应函数时,将当前存在对象的完整图(包括类型、字段和关系信息)保存到文件中。然后可以使用 graphviz 工具将此图可视化。通常,在每次测试后创建此文件,以方便跟踪泄漏。
  2. 调用相应函数时,将当前存在的具有循环依赖关系(其中涉及的所有引用都是强引用)的对象图保存到文件中。因此,该图仅包含相关信息。已分析的对象在后续调用此函数时将从分析中排除。这使得更容易准确地查看特定测试泄漏了什么。
  3. 调用相应函数时,将有关当前存在的隔离岛(一组对象,其中对它们的所有引用都由同一集合内的其他对象持有)的信息输出到控制台。静态或局部变量引用的对象不包含在此输出中。每种类型的隔离岛(即创建典型隔离岛的类集)的信息仅输出一次。
  4. SharedPtr 类的析构函数遍历对象之间的引用,从其管理生命周期的对象开始,并输出所有检测到的循环信息——即所有通过跟踪强引用可以再次到达起始对象的情况。

另一个有用的调试工具是检查在 MakeObject 函数调用对象构造函数后,该对象的强引用计数是否为零。如果不是,则表示存在潜在问题——引用循环、抛出异常时的未定义行为等。

总结

尽管 C# 和 C++ 类型系统之间存在根本性不匹配,但我们设法构建了一个智能指针系统,允许转换后的代码以接近原始行为的方式运行。与此同时,该任务并未在完全自动化的模式下解决。我们创建了一些工具,可以显著简化查找潜在问题的过程。

相关新闻

相关视频

相关文章