RAII与垃圾收集器


75

我最近在CppCon 2016上观看了Herb Sutter关于“ Leak Free C ++ ...”的精彩演讲,他在演讲中谈到了使用智能指针实现RAII(资源获取是初始化)-概念以及它们如何解决大多数内存泄漏问题。

现在我在想。如果我严格遵守RAII规则,这似乎是一件好事,那为什么与C ++中的垃圾收集器有什么不同呢?我知道,使用RAII,程序员可以完全控制何时再次释放资源,但是在任何情况下,仅使用垃圾收集器都是有好处的吗?效率会降低吗?我什至听说有一个垃圾收集器可以提高效率,因为它可以一次释放更大的内存块,而不是在整个代码中释放小的内存块。


24
确定性资源管理在所有情况下都至关重要,尤其是在处理非托管资源(例如文件句柄,数据库等)时。除此之外,垃圾回收始终会产生某种开销,而RAII的开销仅比首先正确编写代码多。“释放整个代码中的小内存块”通常会更有效率,因为它对应用程序运行的破坏要小得多。
科迪·格雷

32
注意:您谈论的是资源,但是有不止一种资源。当需要释放一些内存时,将调用垃圾收集器,但在关闭文件时将不会调用该垃圾收集器。
所罗门慢速

20
任何事情都比垃圾回收更好
GuidoG

11
@Veedrac如果您完全致力于RAII并在各处使用智能指针,那么您也不应该有使用后释放错误。但是,即使GC(或引用计数的智能指针)可以使您免于使用后使用错误,也可能掩盖了您无意间将对资源的引用保留的时间超出预期的情况。
jamesdlin

7
@Veedrac:当然,这是不公平的。您给了我两个程序进行比较,一个程序释放内存,一个程序不释放内存。为了进行公平的比较,您需要运行一个实际的工作负载,实际上需要GC介入,而不是空转。而且,您需要有一个动态且切合实际的内存分配模式,而不是FIFO或LIFO或它的某种变体。声称永远不会释放内存的程序要比没有释放内存的程序快,或者为LIFO释放而优化的堆要比没有内存分配的程序快,这并不是完全令人神往。嗯,当然可以。
user541686

Answers:


62

如果我严格遵循RAII规则,这似乎是一件好事,那为什么与C ++中的垃圾回收器有什么不同呢?

两者都处理分配时,它们的处理方式完全不同。如果您喜欢使用Java中的GC,那将增加它自己的开销,从资源释放过程中消除一些确定性,并处理循环引用。

尽管对于特定情况,您可以实现GC,但性能特征却大不相同。我在高性能/高吞吐量的服务器中一次实现了关闭套接字连接的功能(只是调用套接字关闭API花费的时间太长,导致吞吐量性能降低)。这不涉及内存,但涉及网络连接,也不涉及循环依赖项处理。

我知道,使用RAII,程序员可以完全控制何时再次释放资源,但是无论如何,这对于拥有一个垃圾收集器有好处吗?

这种确定性是GC根本不允许的功能。有时您希望能够知道在某些时候执行了清除操作(删除临时文件,关闭网络连接等)。

在这种情况下,GC无法将其剪切掉,这就是C#(例如)具有IDisposable接口的原因。

我什至听说有一个垃圾收集器会更有效,因为它可以一次释放更大的内存块,而不是在整个代码中释放小的内存块。

可以...取决于执行情况。


6
请注意,还有一些算法依赖于GC,无法使用RAII实现。例如,一些并发的无锁算法中,您有多个线程竞相发布一些数据。例如,据我所知,没有Cliff的非阻塞哈希图的C ++实现。
Voo

3
加上它自己的开销-不用为malloc和free付钱。您基本上是在进行免费列表管理和活期扫描的参考计数交易。
the8472年

5
Java和.NET中的GC仅用于释放仍由无法访问的对象分配的内存。这并不是完全确定的,但是,资源,如文件句柄和网络连接是通过一个完全不同的机制关闭(在Java中,java.io.Closeable界面和“尝试-与资源”块),这完全确定的。因此,关于“清除操作”的确定性的答案部分是错误的。
罗杰里奥

3
在这种情况下,@ Voo可能会导致它实际上不是无锁的,因为垃圾收集器正在为您进行锁定。
user253751'17

2
@Voo您的算法是否依赖使用锁的线程调度程序?
user253751

38

垃圾收集解决了RAII无法解决的某些资源问题。基本上,它可以归结为循环依赖关系,您无需事先确定循环。

这有两个优点。首先,将存在RAII无法解决的某些类型的问题。根据我的经验,这些都是罕见的。

更大的问题是它让程序员变得懒惰,而不在乎内存资源的生存期以及您不介意延迟清除的某些其他资源。当您不必关心某些类型的问题时,您可以更多地关注其他问题。这使您可以专注于要解决的问题部分。

不利的一面是,如果没有RAII,很难管理要限制其寿命的资源。GC语言从根本上减少了您的范围绑定生存期,或者要求您手动执行资源管理(例如在C中),并手动声明已完成对资源的处理。他们的对象生命周期系统与GC紧密相关,对于大型复杂(迄今无周期)系统的严格生命周期管理来说,效果并不理想。

公平地讲,在这样的大型复杂(尚未循环)系统中,C ++中的资源管理需要大量工作才能正确完成。C#和类似的语言使操作变得更加困难,作为交换,它们使简单的案例变得容易。

大多数GC的实现还强制使用非本地完整类。创建通用对象的连续缓冲区,或将通用对象组成一个更大的对象,大多数GC实现都不容易。另一方面,C#允许您创建struct功能有限的值类型s。在当前的CPU体系结构时代,缓存的友好性是关键,而本地性GC力的缺乏则是沉重的负担。由于这些语言大部分时间都是字节码运行时,因此从理论上讲,JIT环境可以将常用数据移动到一起,但是与C ++相比,由于频繁的高速缓存未命中,您经常会因为统一的性能而损失性能。

GC的最后一个问题是重新分配是不确定的,有时会导致性能问题。与以往相比,现代GC使得此问题变得更少。


2
我不确定我是否理解您关于地点的争论。成熟环境中的大多数现代GC(Java,.Net)都执行压缩,并从分配给每个线程的连续内存块中创建新对象。因此,我希望大约在同一时间创建的对象将相对本地化。AFAIK在标准malloc实现中没有这样的东西。这种逻辑可能会导致错误的共享,这对于多线程环境是一个问题,但这是另一回事。在C语言中,您可以使用显式技巧来提高局部性,但是如果您不这样做,我希望GC会更好。我想念什么?
SergGr

8
@SergGr我可以在C ++中创建连续的非纯旧数据对象数组,并按顺序对其进行迭代。我可以明确地移动它们,使它们彼此相邻。当我遍历一个连续的值容器时,可以保证它们按顺序位于内存中。节点基础容器缺乏这种保证,并且gc语言统一支持仅基于节点的容器(充其量,您有一个连续的引用缓冲区,而不是对象缓冲区)。通过C ++的一些工作,我什至可以使用运行时多态值(虚拟方法等)来执行此操作。
Yakk-Adam Nevraumont

2
Yakk,您似乎在说非GC世界可以使您为本地而战,并取得比GC世界更好的结果。但这只是故事的一半,因为默认情况下,您可能会得到比GC世界更糟糕的结果。实际上malloc,迫使您必须与非本地性抗争,而不是与GC对抗,因此,我认为在您的回答中声称“大多数GC实现也迫使非本地性”并不是真的。
SergGr

2
@Rogério是的,这就是我所说的基于受限范围或C风格的对象生命周期管理。手动定义对象生命周期何时结束的位置,或者使用简单的作用域案例来定义。
Yakk-Adam Nevraumont

4
抱歉,但是不能,程序员不能对内存资源的寿命“懒惰”或“不在乎”。如果您有一个FooWidgetManager用于管理Foo对象的对象,则很有可能将已注册Foo的对象存储在一个无限增长的数据结构中。这样的“已注册Foo”对象超出了GC的范围,因为它FooWidgetManager的内部列表或任何引用它的对象。要释放此内存,您需要请求FooWidgetManager注销该对象。如果您忘记了,这实质上是“不删除的新内容”;只有名称已更改...而GC无法修复它
H Walters

14

注意,RAII是一种编程习惯,而GC是一种内存管理技术。因此,我们正在将苹果与橙子进行比较。

但是,我们可以限制RAII到它的内存管理方面唯一的和比较,为GC技术。

所谓的基于RAII内存管理技术之间的主要区别(这实际上意味着引用计数,在当你考虑内存资源,而忽略了其他的如文件至少)和真正的垃圾回收技术是处理循环引用(用于循环图) 。

使用引用计数时,您需要为它们专门编码(使用弱引用或其他内容)。

在许多有用的情况下(认为std::vector<std::map<std::string,int>>),引用计数是隐式的(因为它只能为0或1),实际上被省略了,但是构造函数和析构函数(对于RAII来说是必需的)的行为就像有一个引用计数位(实际上不存在)。在std::shared_ptr有确切的引用计数器。但内存仍然隐含 手动管理(与newdelete引发的内部构造和析构),但“隐性” delete(在析构函数)给出了自动内存管理的错觉。然而,电话newdelete还是发生了(而且花费的时间)。

顺便说一句,GC实现可能(并且经常)以某种特殊方式处理循环性,但是您将负担留给了GC(例如,阅读有关Cheney算法的信息)。

一些GC算法(尤其是代复制垃圾收集器)也懒得释放内存单独的对象,它是释放集体副本之后。实际上,Ocaml GC(或SBCL)比真正的C ++ RAII编程风格(对于某些(不是全部)算法)要快。

某些GC提供了终结处理(主要用于管理非内存外部资源,如文件),但是您很少使用它(因为大多数值仅消耗内存资源)。缺点是最终确定不提供任何时序保证。实际上,使用终结处理的程序将其作为最后的手段(例如,关闭文件仍应在终结处理之外或与它们明确地或多或少地明确地发生)。

使用GC(至少在使用不当时,至少在使用RAII时)仍然会出现内存泄漏,例如,某个值保留在某个变量或某个字段中,但以后再也不会使用。他们只是很少发生。

我建议阅读垃圾收集手册

在您的C ++代码中,您可以使用Boehm的GCRavenbrook的MPS或编写您自己的跟踪垃圾收集器。当然,使用GC是一个折衷(存在一些不便,例如不确定性,缺乏时序保证等)。

我不认为RAI​​I在所有情况下都是处理内存的最终方法。在很多情况下,比起用C ++ 17用花哨的RAII风格进行编码,用真正有效的GC实现(例如Ocaml或SBCL)对程序进行编码可能更容易(开发)和更快(执行)。在其他情况下则不是。YMMV。

例如,如果您以最奇特的RAII风格用C ++ 17编写Scheme解释器,您仍然需要在其中编码(或使用)显式GC(因为Scheme堆具有循环性)。而且,出于充分的理由,大多数证明助手都是用GC编码的语言编码的,通常是函数式的(我知道的唯一用C ++编码的就是Lean)。

顺便说一句,我感兴趣的是找到这样的Scheme的C ++ 17实现(但对自己编码不那么感兴趣),最好具有一些多线程能力。


16
RAII并不意味着引用计数,而只是std :: shared_ptr。在C ++中,当编译器有证据证明无法再访问变量时,即会插入对析构函数的调用,即。当变量超出范围时。
csiz

6
@BasileStarynkevitch大多数RAII都没有引用计数,因为该计数只会是1
Caleth

4
RAII绝对不是引用计数。
杰克·艾德利

1
@ csiz,@ JackAidley,我想您误解了Basile的观点。他说的是,任何类似引用计数的实现(即使shared_ptr是没有显式计数器的简单实现)也将难以处理涉及循环引用的方案。如果仅在简单的情况下讨论仅在单个方法内使用资源的情况,则甚至不需要,shared_ptr但这只是非常有限的子空间,基于GC的世界也使用类似的方法,例如C#using或Java try-with-resources。但是现实世界中也存在更为复杂的场景。
SergGr

3
@SergGr:谁曾说过unique_ptr处理循环引用?该答案明确声称“所谓的RAII技术”“确实意味着引用计数”。我们可以(而且我确实)拒绝该主张-因此对这个答案的大部分内容(无论是准确性还是相关性)都提出异议-不必拒绝这个答案中的每一个主张。(顺便说一句,现实世界中也存在不处理循环引用的垃圾收集器。)
ruakh

13

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

两者都解决了资源管理困难的问题。垃圾回收通过解决它来解决它,从而使开发人员无需花太多精力来管理那些资源。RAII通过使开发人员更容易关注其资源管理来解决此问题。任何说他们做同样事情的人都有卖给您的东西。

如果您查看语言的最新趋势,您会发现两种方法都在同一语言中使用,因为坦率地说,您确实需要解决这个难题的两个方面。您会看到很多使用各种垃圾收集的语言,因此您不必关注大多数对象,并且这些语言还提供了RAII解决方案(例如python的with运算符),以供您真正关注的时间他们。

  • C ++通过构造函数/析构函数提供了RAII,并且通过GC提供了RAII shared_ptr(如果我可以认为refcounting和GC属于同一类解决方案,因为它们都旨在帮助您不必关注寿命)
  • Pythonwith通过重新计数系统以及垃圾收集器提供RAII through和GC
  • C#通过世代垃圾收集器通过RAIIIDisposable和RA提供RAIIusing和GC

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


10

关于垃圾收集器的问题之一是很难预测程序性能。

使用RAII,您知道在确切的时间资源将超出范围,您将清除一些内存,这将需要一些时间。但是,如果您不是垃圾收集器设置的熟练者,则无法预测何时进行清理。

例如:使用GC可以更有效地清理一堆小对象,因为它可以释放大块数据,但是这不是快速的操作,而且很难预测何时会发生这种情况,并且由于“大块清理”它会花费一些处理器时间,可能会影响程序性能。


3
我不确定即使使用最强大的RAII方法也可以预测程序性能。Herb Sutter讲了一些有趣的视频,介绍了CPU缓存的重要性以及使性能出乎意料的变化。
巴西尔·斯塔林凯维奇

9
@BasileStarynkevitch GC停顿比高速缓存未命中大几个数量级。
Dan在火光旁摆弄

2
没有“大块清理”之类的东西。实际上,由于大多数实现都是“非垃圾收集器”,因此GC是用词不当。他们确定幸存者,将他们移到其他地方,更新指针,剩下的就是可用内存。当大多数对象在GC启动之前死亡时,此方法效果最佳。通常,它非常有效,但避免长时间停顿很困难。
maaartinus

1
请注意,确实存在并发和实时垃圾收集器,因此可以获得可预测的性能。尽管通常情况下,任何给定语言的“默认” GC都是为了提高效率而设计的。
8bittree '17

5
当保存了该图的最后一个RC达到零且所有解构函数运行时,引用计数的对象图也可能具有很长的重新分配时间。
the8472年

9

粗略地讲。RAII习惯对于延迟抖动可能更好。垃圾收集器对于系统的吞吐量可能更好。


1
为什么RAII与GC相比吞吐量会受到影响?
LyingOnTheSky

5

就开发工作而言,“有效”是一个非常宽泛的术语,RAII通常不如GC高效,但就性能而言,GC通常不如RAII高效。但是,有可能提供两种情况的对照示例。当您在托管语言中具有非常清晰的资源(取消)分配模式时,处理通用GC可能会很麻烦,就像使用RAII的代码在shared_ptr无缘无故地用于所有内容时效率低得令人惊讶。


5
“就开发工作而言,RAII通常不如GC高效”在用C#和C ++进行编程后,您可以很好地了解这两种策略,因此,我不得不强烈反对这种说法。当人们发现C ++的RAII模型效率较低时,这很有可能是因为他们没有正确使用它。严格来说,这并不是模型的错误。通常,这是人们使用C ++进行编程的标志,就好像它是Java或C#。创建临时对象并通过作用域自动释放它比等待GC更容易。
科迪·格雷

5

垃圾收集和RAII都支持一种通用的构造,而另一种则不太适合。

在垃圾收集系统中,代码可以有效地将对不可变对象(例如字符串)的引用视为其中包含的数据的代理。传递这样的引用几乎和传递“哑”指针一样便宜,并且比为每个所有者单独创建数据副本或尝试跟踪数据共享副本的所有权要快。此外,垃圾收集系统通过编写一个创建可变对象的类,根据需要填充它并提供访问器方法,使创建不可变对象类型变得容易,所有这些操作都避免了将泄漏的内容泄漏给一旦构造函数就可能对其进行更改的任何内容完成。如果需要广泛复制对不可变对象的引用,但是对象本身不需要复制,则GC会击败RAII。

另一方面,RAII在处理对象需要从外部实体获取专有服务的情况下非常出色。尽管许多GC系统允许对象定义“最终确定”方法并在发现它们被放弃时请求通知,并且这样的方法有时可以设法释放不再需要的外部服务,但是它们很少可靠,无法提供令人满意的方法。确保及时发布外部服务。为了管理不可替代的外部资源,RAII击败了GC。

GC胜出与RAII胜出之间的主要区别在于,GC擅长管理可替代内存,可根据需要将其释放,但在处理不可替代资源方面则较差。RAII擅长处理拥有明确所有权的对象,但擅长处理无所有者的不可变数据持有者,这些拥有者除了包含的数据外没有其他真实身份。

因为GC和RAII都不能很好地处理所有情况,所以对于语言而言,为它们提供良好的支持将很有帮助。不幸的是,专注于一种语言的语言倾向于将另一种语言视为事后想法。


5

如果不提供大量上下文和有关这些术语的定义的争论,就无法回答有关一个或另一个是“有益的”还是更“有效的”问题的主要部分。

除此之外,您基本上可以感受到古老的“ Java或C ++是更好的语言吗?”的张力。评论中的弗拉克瓦尔crack啪作响。我想知道这个问题的答案是“可接受的”,并为最终看到它感到好奇。

但是尚未指出关于可能的重要概念差异的一点:使用RAII,您将被绑定到调用析构函数的线程上。如果您的应用程序是单线程的(即使是Herb Sutter声明“免费午餐已结束:当今大多数软件实际上仍然单线程的”),那么单个内核可能正忙于处理那些没有不再与实际程序相关...

与此相反,垃圾收集器通常在其自己的线程甚至多个线程中运行,因此(在某种程度上)与其他部分的执行分离。

(注意:一些答案已经试图指出具有不同特征的应用程序模式,其中提到了效率,性能,延迟和吞吐量-但尚未提及该特定点)


4
好吧,如果您要限制环境,如果您的计算机在单核上运行或广泛使用多任务处理,那么主线程和GC线程都必须在同一核上运行,并且相信我,上下文切换将比清理资源有更多的开销。 :)
Abhinav Gauniyal

我试图强调的是@AbhinavGauniyal:这是概念上的差异。其他人已经指出了责任,但将其重点放在用户的观点上(“用户负责清理”)。我的观点是,这也带来了重要的技术差异:主程序是否负责清理,还是基础结构的一部分(独立)。但是,鉴于内核数量的增加(通常在单线程程序中处于休眠状态),我只是认为这可能值得一提。
Marco13年

是的,我也赞成你。我也只是在提出你观点的另一面。
Abhinav Gauniyal

@ Marco13:此外,RAII和GC之间的清理成本完全不同。在最坏的情况下,RAII意味着遍历刚刚释放的复杂的引用计数数据结构。在最坏的情况下,GC意味着要遍历所有有生命的物体,这是相反的事情。
ninjalj

@ninjalj我不是细节方面的专家-垃圾收集实际上是一个自己的研究分支。为了讨论成本,可能必须将关键字固定在一个特定的实现上(对于RAII,没有太多的选择余地,但是至少我知道有很多GC实现,策略也大不相同) 。
Marco13年

4

RAII统一处理任何可描述为资源的事物。动态分配就是这样一种资源,但是它们绝不是唯一的资源,可以说不是最重要的资源。文件,套接字,数据库连接,GUI反馈等等,都可以通过RAII确定性地进行管理。

GC仅处理动态分配,从而使程序员不必担心程序生命周期内已分配对象的总量(他们只需要关心峰值并发分配量的适合性)。


1

RAII和垃圾回收旨在解决不同的问题。

当您使用RAII时,您将一个对象留在堆栈上,其唯一目的是在离开方法范围时清理要管理的对象(套接字,内存,文件等)。这是为了例外安全,而不仅仅是垃圾回收,这就是为什么您会收到有关关闭套接字和释放互斥对象等的原因的原因。(好吧,所以除了我以外,没有人提到过互斥体。)如果抛出异常,堆栈展开自然会清除方法所使用的资源。

垃圾回收是内存的程序化管理,尽管您可以根据需要“垃圾回收”其他稀缺资源。明确地释放它们在99%的时间内更有意义。对文件或套接字之类的东西使用RAII的唯一原因是,您希望在方法返回时对资源的使用是完整的。

垃圾收集还处理 堆分配的例如,当工厂构造对象的实例并返回该对象时。在控制必须离开作用域的情况下拥有持久对象是使垃圾回收具有吸引力的原因。但是您可以在工厂中使用RAII,因此,如果在返回之前引发了异常,则不会浪费资源。


0

我什至听说有一个垃圾收集器会更有效,因为它可以一次释放更大的内存块,而不是在整个代码中释放小的内存块。

使用RAII(或使用简单的malloc / free)是完全可行的,而且实际上实际上是完成的。您会看到,您不一定总是使用默认分配器,该分配器仅逐个分配。在某些情况下,您可以使用具有不同功能的自定义分配器。一些分配器具有内置功能,可以一次释放所有分配器区域中的所有内容,而不必迭代各个分配的元素。

当然,然后您会遇到何时取消分配所有内容的问题-是否必须使用那些分配器(或与它们关联的内存块),以及如何进行分配。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.