11.2. 延迟回收的核心概念

为什么要使用延迟回收(deferred reclamation)?这确实是一个很好问题。

有一些程序中,在使用端代码不再引用某个对象之后,立即回收该对象并不合适,或者在某些情况下,无法确定这些对象是否可以安全回收,直到确认所有可能使用它们的代码已经执行完毕后,才能进行回收。这类问题在 C++ 中相对少见,这是因为通常以特定的方式(如 RAII、手动管理生命周期)来思考和设计代码。但在整个编程世界中,它们并不少见。举个例子:一个函数中存在局部对象之间的循环引用;或者一个树结构,可以从根节点遍历到叶子节点,而叶子节点又持有对根节点的引用。

某些情况下,可以明确如何销毁这些对象:比如在树结构中,可以从根节点开始,依次销毁各个子节点。但在其他情况下,如果知道一组对象的生命周期不会超出某个函数的作用域,那么就可以利用这一信息,在函数结束时统一回收这些对象,而不是逐个跟踪销毁。这种“延迟到合适时机统一回收”的策略,正是延迟回收所要解决的问题。

如果熟悉垃圾回收语言,可能知道在大多数这类语言中,回收器只会"回收字节" —— 释放回收对象占用的底层存储空间(有时还会在此过程中压缩内存),但不会执行对象的终结操作。这样做的一个重要原因是:在这类语言中,对象很难(有时甚至不可能)知道程序运行时还存在哪些其他对象,语言本身不提供终结顺序的保证...试想当垃圾回收器需要处理相互引用的对象循环时,又怎么可能保证终结顺序呢?当一个对象走到生命尽头时,由于无法获知哪些其他对象仍然存在,这极大地限制了终结代码所能实现的功能。

许多语言中,内存回收并不等同于对象终结 —— 这简化了垃圾回收的过程:从概念上讲,回收器只需调用类似std::free()的函数释放内存即可,无需关心其中包含的对象。而在那些保证先终结再回收的语言中,通常会看到以单一基类(通常名为object或Object)为根的类层次结构,这使得回收器能够通过虚函数机制,调用每个对象的等效析构函数,实现多态终结。当然,由于对象终结顺序通常不可预知,这种情况下终结代码能实现的功能仍然非常有限。

在现代垃圾回收语言中,更常见的做法是将终结操作的职责交给使用端代码,而语言本身仅负责内存回收。这类语言通常会使用特殊接口(例如C#的IDisposable和Java的Closeable),由需要重要终结操作的类(通常是管理外部资源的类)实现,使用端代码则需显式建立有序终结对象的机制。这种做法将部分资源管理职责从对象本身(如第4章介绍的C++ RAII惯用法)转移到了使用对象的代码上。这也提醒我们:垃圾回收机制虽然简化了内存管理,但往往会使其他资源的管理变得更加复杂。

这类使用端代码驱动的资源管理典型范例包括:搭配finally块的try代码块 —— 无论try块正常结束还是进入catch块,其中的清理代码都会执行。此外还存在更简洁的语法结构,能以更轻量的方式实现相同功能。例如:Java采用try-with-resources语法,在作用域结束时自动对特定Closeable对象调用close();C#则通过using语句块,隐式对特定IDisposable对象调用Dispose()。

C++既没有finally代码块,也不采用特殊接口或统一基类等侵入式设计。在C++中,对象通常通过RAII惯用法自行管理资源 —— 这种设计理念催生了与其他主流语言截然不同的编程范式。

在本章中,将面对一个与垃圾回收语言中类似但又不同的场景:如果希望使用延迟回收对象的机制,则无法保证在对象销毁过程中,那些回收对象能够安全地访问同一组中被回收的其他对象。另一方面,由于选择仅对特定对象应用延迟回收(而不是对所有对象都这么做),所以那些不属于该回收组、并且已知会在该回收操作中存活下来的对象,在被回收对象的终结过程中仍然可以访问。这其实是“没有一刀切的解决方案”所带来的一个优势:C++ 的本质就是灵活性极强。

由于 C++ 中没有所有类型都继承自一个公共基类,我们不得不采取以下两种策略之一:放弃终结机制(这在我们只分配“简单可析构类型(trivially destructible types)”的情况下是可行的),我们可以在编译时验证这一点;或者,找到其他方式来记录我们所分配对象的类型信息,以便在适当的时候调用正确的析构函数。在本章中,我们将展示如何实现这两种方法。

与普遍认知不同的是,一些垃圾回收器其实已经被实现用于 C++。其中最著名的之一是 Boehm-Demers-Weiser 垃圾回收器(由 Hans Boehm、Alan Demers 和 Mark Weiser 开发)。这个回收器通常不会自动调用对象的终结函数,但允许用户通过 GC_register_finalizer 这一机制注册自定义的终结函数。但作者们也警告使用这一功能的用户:这种终结器能做的事情有限。

扩展阅读

欲知详情,请访问 https://www.hboehm.info/gc/

在本章中,将采用其他技术来实现延迟回收。与本书一贯的做法一样,目的是展示一些可以在此基础上进行实验并构建适合自己代码需求的解决方案。

我们将展示三个不同的示例:

对于这三种情况,将采用不同的实现方式,以展示更广泛的实现可能性。这三种情况中,将把指针存储在一个全局可访问的对象中。我们会使用一个单例(singleton),在这里它正是合适的工具,因为我们讨论的是一个影响整个程序的功能。准备好了吗?那我们开始吧!

为了使示例更易于理解,有时会做一些简化……

接下来章节中的代码可能会让某些读者觉得有些奇怪。为了让重点放在代码的延迟回收机制上,并保持整体表达的清晰性,我有意省略了线程安全相关的细节,尽管在现代代码中线程安全是非常关键的。

不过,在本章对应的 GitHub库中,你不仅可以找到本书中展示的代码,还可以找到每个示例对应的线程安全版本。