65.9K
CodeProject 正在变化。 阅读更多。
Home

C++ 的垃圾回收框架

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.75/5 (9投票s)

2001年1月18日

viewsIcon

224329

downloadIcon

2799

一篇关于通过智能指针在 C++ 中使用垃圾回收的文章。

  • 下载演示项目 - 6 Kb
  • 下载源代码 - 3 Kb

    引言

    对 C++ 最常被诟病的一点就是它缺乏垃圾回收机制。习惯了 Java 等更现代语言的程序员会发现,在 C++ 中编程困难且容易出错,因为需要手动管理内存。从某种程度上说,他们是对的,尽管大多数 C++ 铁杆程序员宁愿不完全放弃垃圾回收系统。

    垃圾回收的理念是,由运行时系统为你管理内存。当你需要一个新对象时,你显式地创建它,但无需担心销毁它。当对象不再有任何引用指向它时,它最终会被运行时系统自动销毁。这使得需要频繁动态创建对象的复杂系统更容易编写,因为你永远不必担心销毁对象。这听起来很诱人,那么为什么我说“大多数 C++ 铁杆程序员宁愿不完全放弃垃圾回收系统”呢?

    问题在于,内存只是资源的一种。还有许多其他类型的资源,例如线程或数据库锁、文件句柄、Win32 GDI 资源等。GC(垃圾回收)系统只处理内存,完全忽略了其他资源类型。在支持 GC 的大多数现代语言中,程序员仍然需要手动管理这些其他资源。相比之下,C++ 程序员可以通过将资源包装在一个使用 RAII(资源获取即初始化)惯用法的对象中来轻松管理这些其他资源。当资源包装器超出作用域时,析构函数将自动释放包装的资源。

    起初,你可能会认为在 GC 语言中为对象添加析构函数就足以获得两全其美。这几乎是正确的。大多数 GC 语言没有“栈数据”的概念,所以所有对象都在堆上创建,并且只有在收集器运行时才被销毁。这是非确定性的,意味着你不知道收集器 *何时* 会运行。结果就是你无法知道包装的资源何时会被释放,这对于许多资源类型来说至关重要。例如,线程锁应该尽可能短地持有。持有时间过长可能会使其他线程饿死,甚至理论上导致死锁。你根本不允许锁以非确定性的方式被释放。所以你真正想要的是一种支持 GC、栈数据和析构函数的语言。这就是为什么我说“大多数 C++ 铁杆程序员宁愿不完全放弃垃圾回收系统”。

    智能指针

    C++ 程序员已经习惯使用 RAII 惯用法来管理内存。他们将使用 operator new 创建的对象的指针包装在一个类中,该类通过定义某些运算符(如 '->' 和 '*')来模拟普通指针。这些包装器被称为“智能指针”,因为它们模拟了普通指针,同时增加了更智能的行为。这种智能指针的一个经典例子来自标准 C++ 库,名为 std::auto_ptr。这种智能指针专门设计用于确保在发生异常时内存会被释放,但它对于更复杂的内存管理来说并不真正可用。使用 std::auto_ptr 时,只能有一个“所有者”,即指向对象实例的指针。复制或赋值 std::auto_ptr 会转移所有权,并将原始 std::auto_ptr 设置为 NULL。这使得 std::auto_ptr 无法用于标准容器,例如 std::vector

    许多其他智能指针实现试图通过引用计数来允许多个“所有者”,即指向同一对象实例的指针。维护一个引用计数,当智能指针被复制时增加,当智能指针被销毁时减少。如果引用计数达到零,则对象最终被销毁。CodeProject 包含了一些使用此想法的智能指针实现。1,2 Boost 库中也有一个经过严格同行评审的版本。3

    引用计数智能指针非常方便,并且适用于许多情况,但它们不像真正的垃圾回收那样灵活。问题在于循环引用。如果对象 A 拥有一个指向对象 B 的引用计数指针,而对象 B 拥有一个指向对象 A 的引用计数指针,那么这两个对象都永远不会被销毁,因为它们的引用计数永远不会达到零。防止此类循环引用可能很困难。

    解决此问题的一种方法是使用“弱指针”。弱指针是指向对象的指针,但它不增加或减少引用计数。普通的 C++ 指针是“弱指针”,但使用它有一个缺点。当引用计数变为零时,对象将被删除,这可能会使用作弱指针的普通指针“悬空”。因此,通常使用另一种智能指针类型来充当弱指针。这种智能指针可以与引用计数智能指针协同工作,以便在对象被删除时,“弱指针”会自动设置为 NULL,从而防止悬空指针。

    这可以解决问题,但仍然需要程序员负责发现循环引用并纠正它们。这并不总是容易,甚至可能无法做到。因此,在某些情况下,C++ 程序员仍然希望 C++ 拥有一个 GC 系统,在其已有的其他功能之上。

    保守 GC 库

    有几种免费和商业的 C/C++ 垃圾回收器实现,它们分别替换 malloc 和 new。所有这些收集器都被称为“保守”收集器。这意味着在尝试确定是否有任何剩余指针指向某个对象时,它们会谨慎行事。原因是,在 C 和 C++ 中,可以通过将指针存储在其他数据类型(如联合或整数类型)中来操作指针。4 不幸的是,这意味着有时它们会未能收集到实际上不再使用的对象。

    这类库的另一个问题是,它们只适用于使用特殊形式的 malloc 和/或 new 创建的对象。例如,由第三方库分配的内存将不会被收集。

    gc_ptr

    那么,C++ 是否有更好的解决方案?本文及配套代码试图通过融合智能指针的概念和传统垃圾回收的概念来定义一个更好的解决方案。结果是一个名为 gc_ptr 的类模板。

    gc_ptr 类解决了传统 GC 库的第一个问题,即能够做到非保守。唯一需要被评估为可能指向对象的候选类型是 gc_ptr 本身。所有其他类型的对象引用都被视为“弱指针”,不被考虑。智能指针方法在此处的另一个好处是,实现更加简单,因为收集器不必试图查找对象的引用,因为所有这些引用都可以在创建时向系统注册。

    第二个问题更难处理。gc_ptr 的实现最初基于 Thant Tessman 提交给 Boost 的类似类 circ_ptr 的实现。3,5 最初的实现允许 circ_ptr 使用指向 operator new 分配的任何对象的指针进行创建。不幸的是,这个实现留下了一个严重的漏洞,容易被滥用。对象的大小是在第一个引用该对象的 circ_ptr 创建时注册的。这个大小用于确定对象是否包含其他 circ_ptr 以查找循环引用。如果 circ_ptr<base> 使用派生类型的指针进行初始化,因为它将大小注册为 sizeof(base),代码可能无法识别对象包含另一个 circ_ptr,从而过早地收集对象。在原始实现中使用模板构造函数会降低发生这种情况的可能性,因为大小可以从传递给构造函数的指针类型计算得出,但这并不能消除出错的可能性,如以下代码所示

    base* factory(int i)
    {
    	switch (i)
    	{
    	case 1:
    		return new derived1;
    	case 2:
    		return new derived2;
    	default:
    		return 0;
    	}
    }
    
    circ_ptr<base> ptr(factory(1));
    

    为了真正消除这种可能性,我们需要提供一种自定义的内存分配方式来分配需要被垃圾回收的对象。gc_ptr 的实现通过提供一个重载的 new 运算符来实现,该运算符除了 size_t 之外,还接受一个 gc_detail::gc_t 实例。在此重载中,分配对象的大小会被存储起来,以便正确检测对象是否包含另一个 gc_ptr。使用此重载,上述示例可以安全地进行编码(注意 new 的不寻常语法)

    base* factory(int i)
    {
    	switch (i)
    	{
    	case 1:
    		return new(gc) derived1;
    	case 2:
    		return new(gc) derived2;
    	default:
    		return 0;
    	}
    }
    
    gc_ptr<base> ptr(factory(1));
    

    现在,gc_ptr 指向的对象只能通过语法 "new(gc)" 来分配。使用此语法传递给 operator new 的变量 "gc " 在库中声明为匿名命名空间,以便于使用并防止违反“单一定义规则”。通过标准 new 运算符分配并用于构造 gc_ptr 的对象将立即抛出 std::invalid_argument 异常。这使得在运行时更容易发现此类错误并防止滥用。由于这个要求,gc_ptr 不能直接替换普通指针,但它仍然是一个相对简单的替代品。只需更改分配对象的代码即可。

    接口

    使用 gc_ptr 的接口相对简单。有两个全局方法用于控制垃圾回收和 gc_ptr 类本身。

    void gc_collect()

    这是收集器的核心。调用时会运行一个标记-清除算法来查找当前不再使用的所有对象,然后销毁它们。程序员可以自由地在任何时候调用此方法来触发垃圾回收。但是,程序员并不需要调用此方法。new(gc) 运算符如果分配的内存量达到程序员指定的阈值(见下文)或内存耗尽,就会自动调用此方法。在大多数情况下,这种自动收集应该足够了。

    void gc_set_threshold(size_t bytes)

    此方法设置 gc_collectnew(gc) 运算符自动调用的阈值。最初,阈值设置为 1024 字节,但可以通过此方法进行更改以优化性能。在此方法的调用中有两个有趣的可能值。传递零将导致每次调用 new(gc) 时都进行内存收集。这将降低性能,但能确保内存的最佳利用。传递 std::numeric_limits<size_t>::max() 将产生相反的效果,完全关闭自动收集,但如果内存耗尽,仍然会调用 collect。这将提供最佳的性能,但会导致最差的内存利用。

    通过向此方法传递 std::numeric_limits<size_t>::max() 来关闭自动收集,然后编写一个定期调用 gc_collect 的线程,可以应用一种有趣的技术来创建“并行垃圾回收器”。这可能在多线程程序中实现最佳的性能和内存利用。

    gc_ptr::gc_ptr()

    构造一个指向 NULL 引用的 gc_ptr

    explicit gc_ptr::gc_ptr(T* p)

    构造一个指向使用 operator new(gc) 创建的对象的 gc_ptr

    template <typename U> explicit gc_ptr::gc_ptr(const gc_ptr<U>& other)

    从另一个 gc_ptr 构造一个 gc_ptr。模板定义允许复制相关类型,但对于单继承关系来说是安全的。如果能找到多重继承的解决方案,未来版本将会实现。目前,你应该避免在 gc_ptr 中使用多重继承的类型。

    gc_ptr<T> gc_ptr::operator= (T* p)

    gc_ptr 指向由 operator new(gc) 分配的新对象。

    template <typename U> gc_ptr<T> gc_ptr::operator= (const gc_ptr<U>& other)

    gc_ptr 指向另一个 gc_ptr 指向的对象。同样,模板定义允许复制相关类型,但对于多重继承的类型来说是不安全的。

    T* gc_ptr::get() const

    返回指向此 gc_ptr 所指向对象的真实 C++ 指针。这是一个显式转换。出于安全原因,没有隐式转换为真实指针。

    T& gc_ptr::operator*() const

    允许以与真实 C++ 指针相同的方式解引用 gc_ptr

    T*gc_ptr::operator->() const

    允许使用与真实 C++ 指针相同的“箭头表示法”来使用 gc_ptr

    优点

    为 C++ 程序员提供简单的垃圾回收。实现 gc_ptr 的代码简短,仅包含在两个文件中,可以轻松地包含在你的项目中。

    消除了传统引用计数智能指针在遇到循环引用时出现的内存管理错误。

    提供了一个安全且不受限于“保守垃圾回收”的实现。

    提供了一个灵活的接口,允许程序员控制何时进行垃圾回收,包括实现“并行垃圾回收”的简单方法。

    易于使用。

    线程安全。在多线程程序中使用时,对 gc_collect 的调用,以及因此对 operator new(gc) 的调用,可能会被阻塞,如果另一线程正在进行垃圾回收。实际上,这不应该导致任何明显的性能差异,因为两者操作(相对而言)都比较慢。

    缺点

    不如手动内存管理或引用计数智能指针高效。

    需要使用重载的 operator new(gc) 来分配要进行垃圾回收的对象。这种语法是非标准的,但实现符合 C++ 标准。

    不能与使用多重继承的类型正常工作。这是实现中最严重的问题,只会导致未定义行为,而不是编译时错误。这个问题需要一个解决方案,所以如果任何程序员知道解决方案,我将非常乐意听到。

    从未被 gc_ptr 指向的由 operator new(gc) 返回的对象指针将会泄露。收集器无法知道如何删除这些对象,因为对象类型直到 gc_ptr 首次赋值给对象时才被注册。

    未来的可能增强功能

    下一个版本可能提供的一些增强功能包括支持使用多重继承类型以及包含引用计数。

    多重继承问题很难解决。垃圾回收器必须保存对象的 void 指针,而 gc_ptr 仅在内部使用此 void 指针。使用 static_cast<> 将 void 指针转换为模板指针类型对于不使用继承或仅使用单继承的类型是安全的,但对于使用多重继承的类型则不安全。如果任何程序员知道此问题的解决方案,我将非常乐意听到。

    引用计数可以通过立即销毁不参与循环引用的对象来优化内存使用,而不是等到 gc_collect 被调用。将此添加到此实现中应该不难,并且之所以省略它,仅仅是因为此类用途应该使用引用计数智能指针而不是 gc_ptr 来编码,以优化两者的性能。因此,在 gc_ptr 中使用引用计数只是一个次要的好处。

    本文的第二部分 现已发布。

    特别鸣谢

    此处提供的代码基于 Thant Tessman 编写并提交给 Boost 的 circ_ptr 的实现。53 该实现经过重构,以最大限度地减少编译时依赖,解决导致 new(gc) 的使用问题,并提供更有效的收集算法。我非常感谢 Thant 不仅提供了原始的 circ_ptr,还在我重构实现时提供了宝贵的意见。此处提供的代码是原创的,但如果没有他最初的努力和后来的输入,它将不存在。

    脚注

    1 一个简单的智能指针,CodeProject 上的一篇文章。

    2 一个能够进行对象级线程同步和引用计数垃圾回收的智能指针,CodeProject 上的一篇文章。

    3 Boost,一个程序员组织,致力于生产经过广泛同行评审的优质库供公众使用。其中许多库有望被纳入后来的 C++ 标准。

    4 标准不保证指针可以存放在任何整数类型中,但实际上大多数平台都允许这样做,并且这是一种常用的技术。

    5 circ_ptr.zip。此链接需要您成为 Boost eGroup 邮件列表的成员。请参阅 Boost 3 网站了解加入说明。

  • © . All rights reserved.