RAII与垃圾收集器的比较

80
我最近观看了Herb Sutter在CppCon 2016上的“无泄漏C ++…”精彩讲座,他谈到使用智能指针实现RAII(资源获取即初始化)-概念以及它们如何解决大多数内存泄漏问题。现在我在想,如果我严格遵循RAII规则,这似乎是个好主意,那么这跟在C ++中使用垃圾回收器有什么不同呢?我知道使用RAII程序员完全掌控资源何时被释放,但与仅仅使用垃圾回收有什么好处呢?这是否会真正降低效率?我甚至听说使用垃圾收集器可能更高效,因为它可以一次性释放较大的内存块,而不是在代码中各处释放小的内存片段。

27
在各种情况下,确定性资源管理都非常关键,尤其是在处理非托管资源(例如文件句柄、数据库等)时。此外,垃圾回收总会有一些开销,而RAII的开销与一开始就正确编写代码一样少。在代码中“随处释放小内存块”通常更加高效,因为它对应用程序的运行干扰要小得多。 - Cody Gray
35
注意:你谈论了“资源”,但有不止一种类型的资源。当需要释放一些内存时,垃圾回收器会被调用,但在关闭文件时它不会被调用。 - Solomon Slow
24
任何东西都比垃圾收集好。 - GuidoG
12
如果您完全致力于RAII并在所有地方使用智能指针,那么您也不应该有使用后释放错误。但是,即使GC(或引用计数的智能指针)可以避免使用后释放错误,它可能掩盖了一个情况,即您无意中保留了对资源的引用时间比预期长。 - jamesdlin
7
@Veedrac:当然不公平。你给了我两个程序进行比较,一个释放内存,而另一个则不会。要进行公平比较,你需要运行一个真实的工作负载来实际需要GC启动。而不是闲置着。您需要具有动态和现实的内存分配模式,而不是FIFO或LIFO或其某些变体。声称从未取消分配内存的程序比执行此操作的程序快,或者为LIFO取消分配调整的堆将比没有这样做的堆更快,这并不是令人震惊的事情。嗯,当然会这样。 - user541686
显示剩余43条评论
12个回答

67
如果我严格遵循RAII规则,这似乎是件好事,那么这与在C ++中使用垃圾收集器有什么区别呢? 虽然两者都涉及到分配内存,但它们的方式完全不同。如果您指的是像Java中的GC,则会添加自己的开销,从资源释放过程中删除一些确定性并处理循环引用。 您可以针对特定情况实现GC,其性能特性大不相同。我曾经为关闭套接字连接在高性能/高吞吐量服务器上实现了一次,(仅)调用套接字关闭API太慢,并破坏了吞吐量性能。这涉及没有内存而是网络连接,并且没有循环依赖项处理。
我知道使用RAII程序员完全控制何时再次释放资源,但是这是否有益于仅具有垃圾收集器?这种确定性是GC根本不允许的。有时你想知道在某个时间点之后是否执行了清除操作(删除临时文件、关闭网络连接等)。 在这种情况下,GC是不足够的,这就是为什么在C#(例如)中有IDisposable接口的原因。
我甚至听说使用垃圾收集器可能更有效率,因为它可以一次性释放大块内存,而不是在代码中释放小内存块。这取决于实现方式。

8
请注意,还有一些依赖于垃圾回收(GC)的算法,无法使用RAII实现。例如一些并发无锁算法,其中有多个线程竞争发布某些数据。例如,据我所知,没有C++实现Cliff的非阻塞哈希映射 - Voo
3
添加了自己的开销 - 另一方面,您不需要支付malloc和free的成本。您基本上是将空闲列表管理和引用计数交换为存活扫描。 - the8472
6
Java和.NET中的GC仅涉及释放仍由不可访问对象分配的内存。然而,诸如文件句柄和网络连接等资源是通过完全不同的机制关闭的(在Java中,使用java.io.Closeable接口和“try-with-resources”块),这是完全确定的。因此,有关“清理操作”的确定性的部分答案是错误的。 - Rogério
3
在这种情况下,你可以说它实际上并不是无锁的,因为垃圾收集器正在替你进行加锁。 - user253751
2
@Voo你的算法是否依赖于线程调度器使用锁定? - user253751
显示剩余12条评论

39

垃圾回收可以解决RAII无法解决的某些资源问题。基本上,这归结为循环依赖性问题,在此之前你不能确定循环。

这给它带来了两个优点。首先,会有一些RAII无法解决的问题类型。但在我看来,这些情况很少见。

更重要的是,它让程序员变得懒惰,不需要关心内存资源生命周期和某些你不介意延迟清理的其他资源。当你不必关心某些类别的问题时,你就可以更多地关注其他问题。这使你能够专注于你想关注的问题部分。

缺点是没有RAII,管理希望受到约束的生命周期资源很难。GC语言基本上将你限制为具有极其简单的作用域绑定生命周期或需要手动执行资源管理(如在C中),手动声明你已经完成了一个资源。它们的对象生命周期系统与GC密切相关,并且对于大型复杂(但无循环)系统的紧密生命周期管理效果不佳。

公平地说,C++中的资源管理需要大量工作才能在大型复杂(但无循环)系统中正确执行。 C#和类似的语言只是增加了一点难度,以换取易于处理的简单情况。

大多数GC实现也强制执行非局部全面类;创建通用对象的连续缓冲区或将通用对象组合成一个较大的对象并不是大多数GC实现容易实现的。另一方面,C#允许您创建具有一定限制功能的值类型struct。在当前的CPU架构时代,高速缓存友好性至关重要,而GC导致的局部性缺失是沉重的负担。由于这些语言大部分采用字节码运行时,理论上JIT环境可以将常用数据移动在一起,但通常情况下,与C ++相比会不断发生缓存未命中,导致均匀的性能损失。

GC的最后一个问题是释放时间不确定,有时会导致性能问题。现代GC使得这种情况比过去少了很多问题。


2
我不确定我理解你关于局部性的论点。现代成熟环境中的大多数GC(Java,.Net)执行压缩并从分配给每个线程的连续内存块中创建新对象。因此,我期望同时创建的对象会相对地局部化。据我所知,在标准的malloc实现中没有这样的逻辑。这种逻辑可能会导致虚假共享,这是多线程环境下的一个问题,但这是另一回事了。在C语言中,您可以使用显式技巧来改善局部性,但如果您不这样做,我认为GC会更好。我错过了什么吗? - SergGr
8
我可以在C++中创建一个连续的非POD对象数组,并按顺序迭代它们。我可以显式地移动它们,使它们相邻。当我遍历一个连续的值容器时,它们保证在内存中按顺序排列。基于节点的容器缺乏这种保证,而垃圾回收语言仅统一支持基于节点的容器(最多只有引用的连续缓冲区,而不是对象)。通过在C++中进行一些工作,我甚至可以对运行时多态值(虚方法等)执行此操作。 - Yakk - Adam Nevraumont
2
Yakk,看起来你是在说非GC世界“允许”你为局部性而战,并取得比GC世界更好的结果。但这只是故事的一半,因为默认情况下,你可能会比在GC世界中获得更差的结果。实际上,是malloc强制了非局部性,而不是GC,因此我认为你在回答中声称“大多数GC实现也强制非局部性”的说法并不真实。 - SergGr
2
@Rogério 是的,这就是我所说的基于受限范围或C风格对象生命周期管理。在这种情况下,您手动定义对象生命周期何时结束,或者使用简单的作用域语句来实现。 - Yakk - Adam Nevraumont
4
抱歉,但是程序员不能“懒惰”并且不关心内存资源的生命周期。如果你有一个管理Foo对象的FooWidgetManager,它很可能会在一个无限增长的数据结构中存储已注册的Foo对象。这样一个“已注册的Foo”对象超出了垃圾回收的范围,因为FooWidgetManager的内部列表或其他东西包含对它的引用。要释放这块内存,你需要请求FooWidgetManager取消注册该对象。如果你忘记了,这基本上就像是“new without delete”,只是名称已更改...而垃圾回收器无法解决这个问题。 - H Walters
显示剩余8条评论

14
请注意,RAII 是一种编程习惯,而 GC 是一种内存管理技术。所以,我们是在比较不同的东西。
但是,我们可以仅将 RAII 限制在其内存管理方面,与 GC 技术进行比较。
所谓基于 RAII 的内存管理技术(这实际上意味着引用计数,至少在考虑内存资源并忽略其他资源,如文件时),与真正的 垃圾回收 技术之间的主要区别是处理循环图过程中的循环引用
使用引用计数,需要为它们编写特殊代码(使用弱引用或其他内容)。
在许多有用的情况下(例如std :: vector >),引用计数是隐式的(因为它只能为0或1),并且实际上被省略,但是构造函数和析构函数(对于RAII至关重要)的行为好像有一个引用计数位(几乎不存在)。在std :: shared_ptr中存在真正的引用计数器。但是内存仍然是 隐式地手动管理的(通过在构造函数和析构函数中触发newdelete),但是那个“隐含”的delete(在析构函数中)给了自动内存管理的假象。但是,调用newdelete仍然会发生(它们需要时间)。

顺便提一句,GC 实现 可以(并且经常)以某种特殊方式处理循环依赖,但您将这个负担留给GC(例如阅读 Cheney算法)。

一些GC算法(尤其是分代复制垃圾回收器)不会打扰释放单个对象的内存,而是在复制后集中释放。实际上,Ocaml GC(或SBCL GC)对于某些算法(而不是所有算法)可以比真正的C++ RAII编程风格更快。
一些GC提供终结操作(主要用于管理非内存外部资源,如文件),但你很少使用它(因为大多数值仅消耗内存资源)。缺点是终结操作不提供任何时序保证。实际上,使用终结操作的程序将其作为最后的手段(例如关闭文件应该在终结操作之外更明确地发生,并且与其一起)。
即使使用GC(并且使用RAII时也是如此,至少在使用不当时),仍然可能出现内存泄漏,例如当值被保留在某个变量或某个字段中但将来永远不会使用时。它们只是发生的频率较低。
我建议阅读垃圾回收手册
在你的C++代码中,你可能会使用Boehm's GCRavenbrook's MPS,或者编写自己的tracing garbage collector。当然,使用GC是一种权衡(存在一些不便之处,例如非确定性、缺乏时间保证等)。
我认为RAII并不是处理所有情况下内存的终极方式。在几种情况下,使用真正高效的GC实现(考虑Ocaml或SBCL)来编写程序可能比在C++17中使用花哨的RAII风格更简单(开发)和更快(执行)。在其他情况下则不然,具体情况因人而异。
作为一个例子,如果您使用最新的RAII风格在C++17中编写Scheme解释器,您仍然需要编写(或使用)显式的GC(因为Scheme堆具有循环引用)。而大多数证明助手都是使用带有GC的语言编写的,通常是函数式语言(我所知道的唯一使用C++编写的是Lean),这是有充分理由的。

顺便说一句,我对寻找这样的C++17 Scheme实现很感兴趣(但不太想自己编写),最好还具备一些多线程能力。


16
RAII并不意味着引用计数,那只是std::shared_ptr的功能。在C++中,编译器会在变量不再可达时插入调用析构函数的代码,也就是当变量超出其作用域时。 - csiz
6
大多数 RAII 并不使用引用计数,因为计数永远只会是 1。 - Caleth
4
RAII绝对不是引用计数。 - Jack Aidley
1
@csiz,@JackAidley,我认为你们误解了Basile的观点。他说的是任何类似于引用计数的实现(即使像shared_ptr这样简单的实现也没有显式计数器)都会在处理涉及循环引用的情况时遇到麻烦。如果你只谈论一个资源仅在单个方法内使用的简单情况,甚至不需要shared_ptr,但这只是非常有限的子空间,GC-based世界也使用类似的手段,如C#的“using”或Java的“try-with-resources”。但现实世界还有更复杂的情况。 - SergGr
3
谁说unique_ptr可以处理循环引用了?这个回答明确声称“所谓的RAII技术”“实际上是指引用计数”。我们可以(我也这么做)拒绝这种说法,因此就可以质疑这个回答的大部分内容(无论是准确性还是相关性),而不必否定其中每一项。 (顺便说一句,现实中也存在不能处理循环引用的垃圾收集器。) - ruakh
显示剩余8条评论

14

RAII和GC从完全不同的方向解决问题。尽管有些人会说它们是相同的,但它们完全不同。

两者都解决了资源管理的难题。垃圾回收通过使开发人员不需要太关注管理这些资源来解决此问题。 RAII通过使开发人员更容易关注其资源管理来解决此问题。任何声称它们做同样事情的人都在向你推销某种东西。

如果您观察语言中的最新趋势,您会看到同一语言中使用两种方法,因为您实际上需要拼图的两侧。您会看到许多语言使用某种垃圾回收,以便您不必过多关注大多数对象,并且这些语言还提供RAII解决方案(例如Python的with运算符),以便在您真正想关注它们时使用。

  • C ++通过构造函数/析构函数提供RAII,通过shared_ptr提供GC(如果我可以认为引用计数和GC属于同一类解决方案,因为它们都旨在帮助您不需要关注生命周期)
  • Python通过with提供RAII,通过引用计数系统和垃圾回收器提供GC
  • C#通过IDisposableusing提供RAII,通过分代垃圾收集器提供GC

这些模式在每种语言中都会出现。


11

垃圾回收机制面临的一个问题是很难预测程序的性能。

采用 RAII(资源获取即初始化)技术,你会知道在确切的时间资源将离开作用域,然后清除一些内存并且这需要一定的时间。但是如果你不是垃圾回收器设置方面的专家,你就无法预测何时进行清理操作。

例如:使用垃圾回收机制可以更有效地清理一堆小对象,因为它可以释放大块内存,但这是一个不快速的操作,而且很难预测何时会发生和由于“大块清理”需要一定的处理器时间,可能会影响您程序的性能。


3
即使采用最强的RAII方法,也不确定能够预测程序性能。Herb Sutter提供了一些有趣的视频,阐述了CPU缓存的影响,揭示了性能的出人意料的不可预测性。 - Basile Starynkevitch
9
@BasileStarynkevitch GC停顿的规模比缓存未命中大数个数量级。 - Dan Is Fiddling By Firelight
2
不存在所谓的“大块清理”。实际上,GC是一个误称,因为大多数实现都是“非垃圾收集器”。它们确定幸存者,将它们移动到其他地方,更新指针,剩下的就是自由内存。当大多数对象在GC启动之前死亡时,它的效果最好。通常情况下,它非常高效,但避免长时间暂停很困难。 - maaartinus
1
请注意,并发和实时垃圾收集器确实存在,因此可以实现可预测的性能。但是,通常情况下,任何给定语言的“默认”GC都是为了效率而非一致性而设计的。 - 8bittree
5
当最后一个保持图形存活的引用计数(RC)达到零并且所有析构函数运行时,引用计数对象图的释放时间也可能非常长。 - the8472

10
< p > 粗略地说,RAII习语对于延迟和抖动可能更好。垃圾收集器可能更适合系统的吞吐量。< /p >

2
为什么RAII相对于GC会在吞吐量方面受到影响? - LyingOnTheSky

5
"

“高效”是一个非常宽泛的术语,在开发工作中RAII通常比GC不够有效率,但就性能而言,GC通常比RAII不够高效。然而,对于这两种情况都可以提供反例。在使用托管语言时,如果您有非常明确的资源(释放)模式,则处理通用GC可能会相当麻烦,就像使用shared_ptr 没有理由地用于所有内容时,使用RAII的代码可能会出乎意料地低效。

"

6
从开发方面来说,RAII模型通常比垃圾回收(GC)不那么高效。我在C#和C++中都有编程经验,可以充分体验两种策略,因此我强烈反对这种说法。如果人们认为C++的RAII模型效率不高,很可能是因为他们没有正确使用它。严格来说,这并不是该模型的问题。更多时候,这是人们将C++当作Java或C#来编程的迹象。创建一个临时对象并让其在作用域结束时自动释放与等待GC相比,并不难做到。 - Cody Gray

5
关于哪个更“有益”或更“高效”的问题,主要取决于大量的上下文和对这些术语的定义进行争论,才能得出答案。
此外,在评论中,你可以感受到古老的“Java或C++哪种语言更好?”热战的紧张气氛。我想知道这个问题的“可接受”答案可能是什么样子,并且很好奇最终会得出什么结论。
但是一个可能重要的概念差异点还没有被指出:使用RAII时,你与调用析构函数的线程绑定在一起。如果你的应用程序是单线程的(即使是Herb Sutter声称“免费午餐结束”:大多数软件今天实际上仍然是单线程的),那么单个核心可能会忙于处理不再与实际程序相关的对象清理工作...
相比之下,垃圾收集器通常在其自己的线程中运行,甚至在多个线程中运行,因此在某种程度上与其他部分的执行解耦。
(注意:一些答案已经试图指出具有不同特征的应用程序模式,提到了效率、性能、延迟和吞吐量,但是这个特定点还没有被提到)

4
如果您限制了环境,比如您的机器只有单核或广泛使用多任务处理,那么您的主线程和垃圾回收线程都会运行在同一核心上,切换上下文会比清理资源更加耗时。 :) - Abhinav Gauniyal
正如我所强调的那样:这是一个概念上的区别。其他人已经指出了责任,但是将其聚焦在用户的角度上(~“用户负责清理”)。我的观点是,这也有一个重要的技术差异:主程序是否负责清理,或者是否有一个(独立的)基础设施部分来处理此事。然而,考虑到单线程程序中休眠的核心数量不断增加,我认为这可能值得一提。 - Marco13
是的,我已经为同样的事情给你点赞了。我只是想呈现你观点的另一面。 - Abhinav Gauniyal
@Marco13:此外,RAII 和 GC 之间的清理成本完全不同。在最坏的情况下,RAII 意味着遍历刚释放的复杂引用计数数据结构。在最坏的情况下,GC 意味着遍历所有活动对象,这有点相反。 - ninjalj
@ninjalj 我并不是一个关于细节方面的专家 - 垃圾回收实际上是一门独立的研究领域。为了讨论成本,可能需要将关键字固定在一个特定的实现上(对于 RAII 来说,没有太多不同的选择,但至少我知道有相当多的 GC 实现,采用截然不同的策略)。 - Marco13

5
垃圾回收和RAII每个都支持一种常见结构,另一种则不太适合。在垃圾回收系统中,代码可以有效地将对不可变对象(如字符串)的引用视为其中包含的数据的代理;传递这些引用几乎与传递“傻”指针一样便宜,并且比为每个所有者制作单独的数据副本或尝试跟踪共享数据副本的所有权更快。此外,垃圾回收系统通过编写创建可变对象的类并提供访问器方法来轻松创建不可变对象类型,而同时避免泄漏任何可能在构造函数完成后使其发生变异的引用。在需要广泛复制对不可变对象的引用但对象本身不需要的情况下,GC远胜于RAII。
另一方面,RAII非常擅长处理对象需要从外部实体获取排他服务的情况。虽然许多GC系统允许对象定义“Finalize”方法并在发现它们被抛弃时请求通知,这样的方法有时可能成功释放不再需要的外部服务,但是它们很少能够可靠地提供满意的方法以确保及时释放外部服务。对于管理不可替代的外部资源,RAII远胜于GC。
GC获胜的情况与RAII获胜的情况之间的关键区别在于,GC擅长管理可根据需要释放的可替换内存,但是不擅长处理不可替代资源。 RAII擅长处理具有明确所有权的对象,但不擅长处理无所有者的不可变数据持有者,其除所包含的数据外没有真正的身份。
因为GC和RAII都不能很好地处理所有情况,所以语言提供对它们两者的良好支持将非常有帮助。不幸的是,专注于一种语言的通常将另一种视为事后思考。

4
RAII(Resource Acquisition Is Initialization)是一种统一处理任何可描述为资源的技术。动态分配内存是其中之一,但它们并不是唯一的一种,也可以说不是最重要的一种。文件、套接字、数据库连接、GUI反馈等都是可以使用RAII进行确定性管理的东西。
垃圾收集器只能处理动态分配内存,使程序员无需担心程序生命周期内分配对象的总体积(他们只需要考虑并发分配量的峰值是否合适)。

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