RAII与垃圾收集器

Jid*_*doo 74 c++ garbage-collection memory-leaks smart-pointers

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

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

utn*_*tim 61

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

虽然两者都涉及分配,但它们以完全不同的方式进行.如果您正在考虑使用Java中的GC,那会增加自己的开销,从资源释放过程中删除一些确定性并处理循环引用.

对于特定情况,您可以实现GC,具有不同的性能特征.我在高性能/高吞吐量服务器中实现了一次关闭套接字连接(仅调用套接字关闭API花了太长时间并且提高了吞吐量性能).这不涉及内存,而是网络连接,也没有循环依赖性处理.

我知道使用RAII,程序员可以完全控制何时再次释放资源,但是在任何情况下都只对垃圾收集器有益吗?

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

在这种情况下,GC不会削减它,这就是C#(例如)你有IDisposable接口的原因.

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

可以......取决于实施.

  • 请注意,还有依赖GC的算法,无法使用RAII实现.例如,一些并发无锁算法,其中有多个线程竞相发布一些数据.例如,据我所知,没有[Cliff的非阻塞hashmap](https://github.com/boundary/high-scale-lib)的C++实现. (6认同)
  • Java和.NET中的GC仅*关于释放仍由无法访问的对象分配的内存.这不是完全确定的,但是,文件句柄和网络连接等资源是通过完全不同的机制(在Java中,`java.io.Closeable`接口和"try-with-resources"块)关闭的,*是*完全确定.因此,关于"清理操作"的确定性的部分答案是错误的. (5认同)
  • *增加它自己的开销* - otoh你没有支付malloc和免费的费用.你基本上是交易免费列表管理和活动扫描的引用计数. (3认同)
  • @Voo在这种情况下你可以说它实际上并不是无锁的,因为垃圾收集器正在为你做锁定. (3认同)
  • @Voo您的算法*是否依赖于使用锁的线程调度程序? (2认同)

Yak*_*ont 38

垃圾收集解决了RAII无法解决的某些类别的资源问题.基本上,它归结为循环依赖关系,您不会事先识别循环.

这给它带来了两个好处.首先,RAII无法解决某些类型的问题.根据我的经验,这些是罕见的.

更大的一点是它让程序员变得懒惰而不关心内存资源的生命周期以及你不介意延迟清理的某些其他资源.当您不必关心某些类型的问题时,您可以关心其他问题.这使您可以专注于您想要关注的问题部分.

不利的一面是,如果没有RAII,那么管理您希望受限制的资源很难.GC语言基本上可以将您简化为具有极其简单的范围限制生命周期,或者要求您手动执行资源管理(如C语言),并手动声明您已完成资源.它们的对象生命周期系统与GC密切相关,并且不适用于大型复杂(无循环)系统的严格生命周期管理.

公平地说,C++中的资源管理需要大量工作才能在如此大的复杂(无循环)系统中正常完成.C#和类似的语言只是让它变得更加难以接受,作为交换,它们使简单易用.

大多数GC实现也会强制非本地化的完整类; 创建一般对象的连续缓冲区,或将一般对象组合成一个更大的对象,并不是大多数GC实现变得容易的事情.另一方面,C#允许您创建struct具有有限功能的值类型.在当前的CPU架构时代,缓存友好性是关键,而GC部队缺乏局部性是一个沉重的负担.由于这些语言大部分都具有字节码运行时,理论上JIT环境可以将常用数据一起移动,但是与C++相比,由于频繁的缓存未命中,您通常会获得统一的性能损失.

GC的最后一个问题是解除分配是不确定的,有时会导致性能问题.与过去相比,现代地理信息系统使问题变得更少.

  • @SergGr我可以在C++中创建一个连续的非普通旧数据对象数组,并按顺序迭代它们.我可以明确地移动它们,使它们彼此相邻.当我迭代一个重要的值容器时,它们可以保证按顺序放在memrory中.节点基础容器缺乏此保证,并且gc语言统一支持仅基于节点的容器(充其量,您有一个连续的引用缓冲区,而不是对象的缓冲区).通过C++中的一些工作,我甚至可以使用运行时多态值(虚拟方法等)来完成这项工作. (8认同)
  • 对不起,但不,程序员不能"懒惰"和"不关心"内存资源的生命周期.如果你有一个管理你的`Foo`对象的`FooWidgetManager`,它很可能将已注册的`Foo`存储在一个无限增长的_data结构中.这样一个"注册的`Foo`"对象超出了GC的范围,因为`FooWidgetManager`的内部列表或者其他任何__对它的引用_.要释放此内存,您需要让`FooWidgetManager`取消注册该对象.如果你忘记了,这基本上是"新的没有删除"; 只有名字已经改变......而且GC _无法修复它. (4认同)
  • 我不确定我理解你关于地方的论点.成熟环境中的大多数现代GC(Java,.Net)执行压缩并从分配给每个线程的连续内存块创建新对象.所以我希望在同一时间创建的对象将是相对本地的.AFAIK在标准的`malloc`实现中没有这样的东西.这种逻辑可能导致错误共享,这对于多线程环境来说是一个问题,但它是一个不同的故事.在C中,您可以使用明确的技巧来改善局部性,但如果不这样做,我希望GC更好.我错过了什么? (2认同)
  • Yakk,看起来你说非GC世界_允许你为地方争取并取得比GC世界更好的结果.但这仅仅是故事的一半,因为默认情况下你可能会比GC世界更糟糕.它实际上是"malloc"强制你必须与之抗争的非本地性而不是GC,因此我认为在你的答案中声称"_Most GC实施也会强制非本地化"并不是真的. (2认同)
  • @Rogério是的,这就是我所说的基于限制范围或C风格的对象生命周期管理.您手动定义对象生命周期何时结束的位置,或者使用简单的范围大小写. (2认同)

Bas*_*tch 14

请注意,RAII是一种编程习惯,而GC是一种内存管理技术.所以我们将苹果与橙子进行比较.

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

所谓的基于RAII的内存管理技术(实际上意味着引用计数,至少在您考虑内存资源并忽略其他文件如文件时)和真正的垃圾收集技术之间的主要区别在于循环引用处理(对于循环图) .

使用引用计数,您需要专门为它们编码(使用弱引用或其他东西).

在许多有用的情况下(想到std::vector<std::map<std::string,int>>),引用计数是隐式的(因为它只能是0或1)并且实际上是省略的,但是构造函数和析构函数(对RAII必不可少)的行为就好像有一个引用计数位(几乎没有).在std::shared_ptr那里有一个真正的参考柜台.但内存仍然是隐式 手动管理(在构造函数和析构函数中使用newdelete触发),但"隐式" delete(在析构函数中)给出了自动内存管理的错觉.然而,调用newdelete还是发生了(而且花费的时间).

BTW GC 实现可能(并且经常)以某种特殊方式处理循环,但是你将这个负担留给GC(例如,阅读切尼的算法).

一些GC算法(尤其是代复制垃圾收集器)也懒得释放内存单独的对象,它是释放集体副本之后.在实践中,Ocaml GC(或SBCL)可以比真正的C++ RAII编程风格更快(对于某些,而不是所有类型的算法).

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

您仍然可以使用GC(以及RAII,至少在使用不当时)内存泄漏,例如,当某个值保留在某个变量或某个字段中但将来永远不会使用时.它们发生的频率较低.

我建议阅读垃圾收集手册.

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

我不认为RAI​​I是在所有情况下处理记忆的最终方式.在一些情况下,在真正有效的GC实现中编写程序(想想Ocaml或SBCL)可以比在C++ 17中使用花哨的RAII样式编码更简单(开发)和更快(执行).在其他情况下,它不是.因人而异.

例如,如果您使用最高级的RAII样式在C++ 17中编写Scheme解释器,您仍然需要在其中编码(或使用)显式 GC(因为Scheme堆具有圆形).大多数证明助手都是用GC编辑的语言编写的,通常是功能性的语言(我知道唯一一个用C++编写的语言是精益的),原因很充分.

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

  • RAII并不意味着引用计数,那只是std :: shared_ptr.在C++中,编译器在证明无法再访问变量时插入对析构函数的调用,即.当变量超出范围时. (15认同)
  • @BasileStarynkevitch大多数RAII都没有引用计数,因为计数只会是1 (6认同)
  • RAII绝对不是引用计数. (3认同)
  • @SergGr:曾经说过`unique_ptr`处理循环引用?这个答案明确声称"所谓的RAII技术""实际上意味着参考计数".我们可以(而且我确实)拒绝这种说法 - 因此对这个答案的大部分内容(无论是在准确性还是在相关性方面)都存在争议 - 并不一定会拒绝这个答案中的每一个主张.(顺便提一下,存在不处理循环引用的真实垃圾收集器.) (2认同)

Cor*_*ica 13

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

两者都解决了管理资源困难的问题.垃圾收集通过制作它来解决它,以便开发人员不需要像管理这些资源那样多关注.RAII通过使开发人员更容易关注他们的资源管理来解决它.任何说他们做同样事情的人都有卖给你的东西.

如果你看看最近的语言趋势,你会发现这两种方法都使用相同的语言,因为坦白说,你真的需要这两个方面.你会看到许多使用各种垃圾收集的语言,这样你就不必关注大多数对象,而且这些语言也提供RAII解决方案(比如python的with运算符),这是你真正想要关注的时候.他们.

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

每种语言都出现了模式.


tty*_*ty6 10

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

使用RAII,您知道在准确的时间资源将超出范围,您将清除一些内存,这将需要一些时间.但是,如果您不是垃圾收集器设置的主人,则无法预测清理何时会发生.

例如:使用GC可以更有效地清理一堆小对象,因为它可以释放大块,但它不会快速运行,并且很难预测何时会发生并且由于"大块清理"它将占用一些处理器时间会影响程序性能.

  • @BasileStarynkevitch GC停顿比缓存未命中大几个数量级. (9认同)
  • 当持有图表的最后一个RC达到零并且所有解构器都运行时,引用计数的对象图也可能具有非常长的释放时间. (5认同)
  • 我不确定即使使用最强的RAII方法也可以预测程序性能.Herb Sutter提供了一些有趣的视频,讲述了CPU缓存如何重要,并使性能出乎意料地难以预测. (3认同)
  • 没有"大块清理"这样的东西.实际上,GC是用词不当,因为大多数实现都是"非垃圾收集器".他们确定幸存者,将他们移动到其他地方,更新指针以及剩下的是免费记忆.当大多数对象在GC启动之前死亡时,它的效果最佳.通常,它非常有效,但避免长时间停顿很难. (2认同)

眠りネ*_*ネロク 9

粗略地说.RAII习惯用于延迟抖动可能更好.垃圾收集器可能更适合系统的吞吐量.

  • 与 GC 相比,为什么 RAII 的吞吐量会受到影响? (2认同)

use*_*670 5

"高效"是一个非常广泛的术语,在开发意义上,RAII通常效率低于GC,但就性能而言,GC通常比RAII效率低.但是,可以为这两种情况提供控制例子.当您在托管语言中拥有非常清晰的资源(de)分配模式时处理通用GC可能相当麻烦,就像使用RAII的代码在shared_ptr无缘无故地用于所有内容时可能会出乎意料地效率低下.

  • *"从发展的角度来看,RAII的效率通常低于GC"*在C#和C++编程中,您可以很好地对两种策略进行抽样,我不得不强烈反对这种说法.当人们发现C++的RAII模型效率较低时,很可能是因为他们没有正确使用它.严格来说,这不是模型的错误.通常情况下,这是人们用C++编程的标志,就好像它是Java或C#一样.创建临时对象并让它通过作用域而不是等待GC自动释放它并不困难. (5认同)

Mar*_*o13 5

关于一个或另一个是"有益的"还是更"有效"的问题的主要部分在没有提供大量背景和争论这些术语的定义的情况下无法回答.

除此之外,你基本上可以感受到古代"Java或C++是更好的语言"的张力吗?在评论中发出嘶嘶声.我想知道这个问题的"可接受"答案是什么样的,并且很想最终看到它.

但是有一点关于可能重要的概念差异还没有被指出:使用RAII,你被绑定到调用析构函数的线程.如果您的应用程序是单线程的(尽管Herb Sutter表示免费午餐已经结束:今天大多数软件仍然单线程的),那么单个核心可能正忙着处理没有的对象的清理与实际计划相关的时间更长......

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

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

  • 好吧,如果你在限制环境,如果你的机器运行在单核上或广泛使用多任务处理,你的主线程和GC线程必然会在同一个核心上运行并相信我,上下文切换将比清理资源带来更多的开销:) (4认同)

sup*_*cat 5

垃圾收集和RAII每个都支持一个共同的构造,另一个不适合.

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

另一方面,RAII非常适合处理对象需要从外部实体获取专有服务的情况.虽然许多GC系统允许对象定义"Finalize"方法并在发现它们被放弃时请求通知,并且这些方法有时可能设法释放不再需要的外部服务,但它们很少可靠,无法提供令人满意的方式.确保及时发布外部服务.为了管理不可替代的外部资源,RAII击败了GC.

GC获胜的案例与RAII获胜的案例之间的主要区别在于GC擅长管理可根据需要释放的可替代内存,但处理不可替代的资源却很差.RAII擅长处理具有明确所有权的对象,但不善于处理除了包含数据之外没有真正身份的无主不可变数据持有者.

由于GC和RAII都不能很好地处理所有场景,因此语言可以为这两种场景提供良好的支持.不幸的是,专注于一个语言的语言倾向于将另一个视为事后的想法.