C++中的垃圾回收 -- 为什么?

49
我听到很多人抱怨C++没有垃圾回收机制。我也听说C++标准委员会正在考虑将其添加到语言中。但我不明白它的意义...使用带有智能指针的RAII就可以消除对垃圾回收机制的需求,对吗?
我唯一接触过垃圾回收机制是在几台廉价的80年代家用电脑上,它会导致系统每隔一段时间卡顿几秒钟。我相信它现在已经得到改进,但你可以想象,那并没有给我留下很高的评价。
对于有经验的C++开发者,垃圾回收机制可以提供哪些优势呢?

你能描述一下什么是“使用智能指针的RAII”吗? - Craig Day
14
这是一个强大的C++习语,在C++界广为人知。如果你不了解,建议提出问题(或搜索,也许已经有答案了)。 - coppro
他的意思是,如果你严格遵循面向对象的编程思想,当对象超出作用域或没有更多引用时,你可以依赖于delete[]被调用,这应该会释放对象所持有的任何内存和资源。 - Matt J
16个回答

5
垃圾回收使得RCU无锁同步更易于正确高效地实现。

实际上,许多无锁和无等待数据结构和算法在自动内存管理下更容易实现。但是,可以说,除非分配器是无锁或无等待的(它通常不是),否则这是毫无意义的... - J D
只有在假设所有线程共享一个单一分配器成为瓶颈的情况下才如此。 大多数高性能分配方案,尽管它们可能有锁定,但使用细粒度锁定。 只要在无锁/等待自由算法的热路径中没有分配,那么这并不重要。 - Ben Voigt

3
垃圾回收实际上是自动资源管理的基础。使用GC会改变解决问题的方式,这种方式很难量化。例如,当您进行手动资源管理时,您需要考虑以下问题:
  • 考虑何时可以释放项目(所有模块/类是否都已完成?)
  • 考虑在资源准备好释放时谁负责释放资源(哪个类/模块应该释放此项?)
在简单情况下,没有复杂度。例如,在方法开始时打开文件并在结束时关闭它。或者调用者必须释放返回的内存块。
当您有多个与资源交互的模块且不清楚谁需要清除时,事情很快变得复杂起来。最终结果是,解决问题的整个方法包括某些编程和设计模式,这是一种折衷。
在具有垃圾回收的语言中,您可以使用一次性模式,以便您可以释放您已经完成的资源,但如果您未能释放它们,则GC将拯救您的一天。
智能指针实际上是我提到的妥协的完美例子。除非你有备份机制,否则智能指针无法防止您泄漏循环数据结构。为了避免这个问题,您通常会妥协并避免使用循环结构,即使它可能是最合适的选择。

1
问题在于可消耗模式并不在所有情况下都能救你。在C#中,正确实现可消耗模式很麻烦(由于终结器可能会被不同的线程多次调用等),而在Java中,“可消耗”模式则是个玩笑。 - paercebal
2
再次强调,恰当使用智能指针可以消除您提到的这两个问题。 - Head Geek
2
适当使用Boost::weak_ptr可以消除循环数据结构的问题。这需要对代码的工作原理有全面的了解,但无论如何,您都应该具备这种理解能力。 - Head Geek
1
@Head Geek:有时候,你只是不想关心代码的某些部分,就像你只是不想关心std::string如何分配/释放其内部字符串一样。你希望数据在你使用它的同时始终存在,无论如何,当不再使用时就清除掉。 - paercebal
1
最终,应该由类设计者决定如何释放它,而不是让用户检查类的实现/文档。 - Arafangion
@HeadGeek “智能指针的正确使用”。实际上,不存在“正确使用”智能指针的概念。考虑将可变图形表示为数据结构并自动回收不可达子图的问题。使用智能指针解决这个问题没有所谓的“正确”方式。 - J D

2
在支持GC的框架中,对于像字符串这样的不可变对象的引用可以和原始值一样被传递。考虑下面的类(C#或Java):
public class MaximumItemFinder
{
  String maxItemName = "";
  int maxItemValue = -2147483647 - 1;

  public void AddAnother(int itemValue, String itemName)
  {
    if (itemValue >= maxItemValue)
    {
      maxItemValue = itemValue;
      maxItemName = itemName;
    }
  }
  public String getMaxItemName() { return maxItemName; }
  public int getMaxItemValue() { return maxItemValue; }
}

请注意,此代码与任何字符串的内容都没有关系,可以将它们视为基元。例如,像maxItemName = itemName;这样的语句可能会生成两条指令:一个寄存器加载后跟着一个寄存器存储。 MaximumItemFinder 不知道调用者是否会保留传入字符串的任何引用,而调用者也无法知道 MaximumItemFinder 将保留对它们的引用多长时间。调用getMaxItemName 的调用者无法知道 MaximumItemFinder 和返回的字符串的原始供应商是否已放弃所有对它的引用。但是,由于代码可以像传递原始值一样传递字符串引用,因此这些事情都不重要
请注意,虽然上面的类在同时调用AddAnother时不是线程安全的,但是任何对GetMaxItemName的调用都保证返回一个有效的引用,可以是空字符串或已传递给AddAnother的字符串之一。如果想确保最大项名称和其值之间的任何关系,则需要进行线程同步,但是即使没有线程同步,内存安全性也得到了保证
我认为没有办法编写像上面那样的方法,在任意多线程使用的情况下保持内存安全性,而不使用线程同步或者要求每个字符串变量都有自己内容的副本,保存在自己的存储空间中,在变量的生命周期内可能不会被释放或重定位。当然,也不可能定义一个字符串引用类型,它可以像int那样便宜地定义、赋值和传递。

我喜欢这个例子。顺便问一下,你确定引用的赋值是原子性的吗?(注:经过一些调研,似乎在C#中是这样的,我认为Java也是如此:为了使您的示例在C++中工作,还需要使指针的赋值具有原子性,无论是通过语言 - 我不认为会有 - 还是使用std::atomic) - paercebal
@paercebal:在Java和.NET中引用的赋值与int的赋值一样原子化 [即a=b执行了对a的原子读取和对b的原子写入,尽管整个操作可能不是原子的]。一个关键要求是GC必须知道在赋值期间b被读入的寄存器(如果执行赋值的线程在此期间被交换出去,b被覆盖并且需要进行GC循环,那么该线程的寄存器可能是宇宙中唯一对b的引用)。 - supercat
@paercebal:我认为在C++中,如果对象太大而无法高效复制,那么像上面的代码实际和高效的唯一方法就是GCreference类具有一些快速访问每个线程存储的功能;在这种情况下,“a=b”可以转换为“currentThreadTempRef=b.data; a.data=currentThreadTempRef; currentThreadTempRef=null;”,如果GC在该过程的任何时候触发,它可以查看每个线程的“currentThreadTempRef”,并知道由此标识的对象必须被固定。 - supercat
这假设赋值本身是原子的。例如,假设一个线程执行 b = c ;,而另一个线程在同一时间执行 a = b ;,其中 b 是两个线程共享的原始指针。此时,b 可能被线程 1 部分写入,被线程 2 完全读取,然后再被线程 1 最后部分写入(我不认为我们在标准、可移植的 C++ 中自动具有原子赋值指针)。这意味着线程 2 具有不正确的指针值。我的理解正确吗? - paercebal
@paercebal:您说得对,除了提供一种线程本地存储的形式之外,平台还必须保证指针写入后跟随读操作时总是会看到新值或旧值中的一个;此外,如果想要使用“普通”的存储方式,就必须有一种方法使得GC能够强制其他线程刷新其缓存,并在引用赋值过程中添加if(gcBusy)gcWait();检查。如果假设GC具有这些功能,那么要求非切片指针分配相比之下就微不足道了。 - supercat
@paercebal:无论如何,我的主要观点是上述方法代表了一种常见、高效、在Java和.NET中100%内存安全的模式,但在C++中以内存安全的方式支持将非常困难,即使有高效的线程本地存储实现可用(这是我希望被广泛支持的概念,以便时间关键代码不必避免它)。 - supercat

2

垃圾回收可能会使泄漏成为你最可怕的噩梦

处理循环引用等事项的完整GC在ref-counted shared_ptr方面会有所升级。我在C++中也欢迎这种方式,但不是在语言层面上。

C++的美妙之一在于它不会强制进行垃圾回收。

我想纠正一个常见的误解:垃圾回收神话,认为它可以消除泄漏。根据我的经验,在与通过资源密集型主机应用程序使用垃圾回收等语言编写的代码进行调试时,最令人头痛的是逻辑泄漏。

谈论GC等主题时,有理论和实践之分。理论上,它很棒并且可以防止泄漏。然而,在理论层面上,每种语言都是美好的且无泄漏的,因为在理论上,每个人都会编写完全正确的代码,并测试每种可能出现问题的情况。

垃圾回收与不太理想的团队协作相结合,在我们的情况下导致了最糟糕、最难以调试的泄漏。

问题仍然与资源所有权有关。当涉及到持久对象时,您必须在这里做出明确的设计决策,而垃圾回收使人们很容易认为不需要这样做。

在开发人员没有始终进行交流和仔细审查彼此代码的情况下(在我的经验中非常普遍),在团队环境中给定某些资源R,开发人员A很容易存储对该资源的句柄。开发人员B也是如此,可能以间接方式将R添加到某个数据结构中。C也是如此。在垃圾回收的系统中,这已经创建了3个R的所有者。

因为开发人员A最初创建了该资源并认为自己是其所有者,所以当用户表示不再需要使用它时,他记得释放对R的引用。毕竟,如果他未能这样做,什么都不会发生,并且从测试中可以明显看出用户端移除逻辑未起作用。因此,他记得释放它,就像任何合理的开发人员一样。这触发了一个事件,B处理它并且也记得释放对R的引用。

然而, C 忘记了。他不是团队中更强的开发人员之一:一个相对新的招募者,只在系统中工作了一年。或者他甚至不在团队中,只是一个受欢迎的第三方开发人员,为我们的产品编写插件,许多用户将其添加到软件中。使用垃圾回收时,这就是我们获得那些静默逻辑资源泄漏的时候。它们是最糟糕的类型:它们不一定会在软件的用户可见侧面表现为明显的错误,除了在运行程序的持续时间内,内存使用量只是继续上升,目的不明。试图用调试器缩小这些问题可能会像调试时间敏感的竞争条件一样有趣。
如果没有垃圾回收,开发人员C将创建一个悬空指针。他可能会尝试在某个时候访问它,并导致软件崩溃。现在那是一个测试/用户可见的错误。 C 有点尴尬,并纠正了他的错误。在GC场景下,仅仅尝试找出系统在哪里泄漏可能是如此困难,以至于其中一些泄漏永远不会被纠正。这些不是valgrind-类型的物理泄漏,可以很容易地检测到,并指向特定的代码行。
使用垃圾回收,开发人员C创建了一个非常神秘的泄漏。他的代码可能会继续访问现在只是软件中一些不可见的实体的R,此时与用户无关,但仍处于有效状态。随着C的代码创建更多的泄漏,他正在创建更多的隐藏处理不相关资源的过程,软件不仅泄漏内存,而且每次都变得越来越慢。
因此,垃圾回收并不一定可以缓解逻辑资源泄漏。在不理想的情况下,它可能使泄漏变得更加容易而不被察觉,并保留在软件中。开发人员可能会因为尝试追踪他们的GC逻辑泄漏而感到沮丧,以至于他们只是告诉他们的用户定期重新启动软件作为解决方法。它确实消除了悬空指针,在安全至上的软件中,在任何情况下崩溃都是完全不可接受的,那么我更喜欢GC。但我经常在不那么安全关键但资源密集、性能关键的产品中工作,在那里,可以迅速修复的崩溃比真正晦涩和神秘的静默错误更可取,而且资源泄漏并不是微不足道的错误。
在这两种情况下,我们谈论的是不驻留在堆栈上的持久对象,例如3D软件中的场景图、合成器中可用的视频剪辑或游戏世界中的敌人。当资源将它们的生命周期绑定到堆栈时,无论是C++还是任何其他GC语言,都很容易管理资源。真正的困难在于持久资源引用其他资源。
在C或C++中,如果未明确指

2

我也怀疑C++委员会是否会将全面的垃圾回收机制添加到标准中。

但是我认为,现代语言中添加/拥有垃圾回收的主要原因是,反对垃圾回收的好理由太少了。自80年代以来,内存管理和垃圾回收领域取得了几项重大进展,我相信甚至有一些垃圾回收策略可以给您提供软实时保障(例如,“在最坏情况下GC不会超过...”)。


4
实时性的争论是无意义的,因为malloc/free也没有最坏情况的保证。 - David Cournapeau
我认为你正在做互相排斥的假设。对于先进的垃圾回收器,很少有反对意见,但它们需要编译代码和垃圾回收器完美协同工作,这在像C++这样的语言中几乎是不可能的。 - J D

2
使用RAII和智能指针可以消除它的需要,对吗?
智能指针可以用于在C++中实现引用计数,这是一种垃圾回收(自动内存管理)的形式,但生产环境下的GC不再使用引用计数,因为它有一些重要的缺陷。
参考计数会泄露循环。考虑A↔B,两个对象A和B互相引用,因此它们的引用计数都为1,都不会被回收,但它们应该都被回收。高级算法如试验删除可以解决这个问题,但会增加很多复杂性。使用weak_ptr作为解决方法是退回到手动内存管理。
天真的参考计数由于几个原因而变慢。首先,它需要经常增加缓存外的引用计数(参见Boost's shared_ptr up to 10× slower than OCaml's garbage collection)。其次,在作用域末尾注入的析构函数可能会导致不必要且昂贵的虚函数调用,并阻止优化,例如尾调用消除。
基于作用域的参考计数会保留浮动垃圾,因为对象直到作用域结束才被回收,而跟踪GC可以在它们变得不可达时立即回收它们,例如一个在循环之前分配的局部变量是否可以在循环期间被回收?
一个有经验的C++开发者可以从垃圾回收中获得哪些优势?
生产力和可靠性是主要的好处。对于许多应用程序来说,手动内存管理需要程序员付出大量的努力。通过模拟无限内存机器,垃圾回收使程序员摆脱了这种负担,使他们能够专注于问题解决,并避免了一些重要的错误类别(悬空指针、缺失的free、双重free)。此外,垃圾回收还促进了其他形式的编程,例如通过解决上行funarg问题 (1970)

我认为(3)不是问题。关于循环:是的,作用域在循环结束时结束,因此在循环内声明的变量的存储不会累积。实际上,我认为基于作用域的生命周期管理非常实用,在大多数情况下运行得非常出色。当然,在某些情况下它会失败(例如向上funargs、复杂的多线程数据共享等)。 - Konrad Rudolph
不考虑 unique_ptr -> 失败。你在作用域结束时使用 vcall 的例子是 boost::shared_ptr 特有的事情,根本不需要这样做。此外,GC 比 RAII 保留更多的垃圾。更不用说 GC 有一些非常严重的缺点,比如资源的非确定性销毁,无法与非 GC 内存/资源一起使用等等。此外,你的帖子是误导性的,因为它暗示了 RAII 用户面临悬空指针、内存泄漏和双重删除的问题,而实际上他们并没有。简而言之,你是错误的。 - Puppy
@DeadMG:“一个boost::shared_ptr特定的事情”。不,那只适用于虚析构函数。“垃圾回收器比RAII保留更多的垃圾”。我测量了一个用C++和OCaml编写的矢量图形引擎的内存需求,并发现由于这个基于作用域的收集问题,C++需要比垃圾收集的OCaml多5倍的内存。RAII用户面临悬空指针、内存泄漏和双重删除的问题,而他们则没有。当应该有两个所有者时使用unique_ptr会导致悬空指针。引用计数泄漏。线程不安全的shared_ptr可能会导致双重删除。 - J D
@JonHarrop:shared_ptr 不意味着或要求虚拟析构函数。使用类型擦除是用户的选择。你的内存轶事是一种逻辑谬误(奇怪的是,是个轶事)。显然,垃圾收集器总会有比在不可引用时立即释放它的系统中更多的垃圾。没有有意义的非线程安全的 shared_ptr 实现,unique_ptr 的接口不允许双重所有权,除非你真的很努力,而且说实话,引用循环从来没有出现过。 - Puppy
1
“垃圾收集器显然会有比在对象不可引用时立即释放它的系统更多的垃圾。”你正在延续一种常见的内存管理神话。基于作用域的引用计数并不能在最早的时间点释放。 “没有有意义的非线程安全shared_ptr实现”。请参阅BOOST_SP_DISABLE_THREADS。“循环引用永远不会出现”。因为您正在做出牺牲,以适应糟糕的内存管理策略。在JVM和.NET上,循环引用是无处不在的。 - J D

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接