繁体   English   中英

RAII与垃圾收集器

[英]RAII vs. Garbage Collector

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

请注意, 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和GC在完全不同的方向上解决问题。 尽管有些人会说,但它们完全不同。

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

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

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

每种语言都出现了模式。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

GC只处理动态分配,减轻了程序员在程序生命周期内担心分配对象总量的问题(他们只需关心峰值并发分配量拟合)

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

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

垃圾收集是对内存的程序化管理,但如果您愿意,可以“垃圾收集”其他稀缺资源。 明确地释放它们在99%的时间里更有意义。 将RAII用于类似文件或套接字的唯一原因是您希望在方法返回时使用资源。

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

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

这是完全可行的 - 实际上,实际上已完成 - 使用RAII(或使用普通的malloc / free)。 你看,你不一定总是使用默认的分配器,它只能零碎地分配。 在某些上下文中,您使用具有不同类型功能的自定义分配器。 一些分配器具有内置的能力,可以同时释放某些分配器区域中的所有内容,而无需迭代单个分配的元素。

当然,然后你会遇到什么时候解除所有内容的问题 - 是否使用那些分配器(或者与它们相关联的内存块是否必须是RAII,以及如何。

暂无
暂无

声明:本站的技术帖子网页,遵循CC BY-SA 4.0协议,如果您需要转载,请注明本站网址或者原文地址。任何问题请咨询:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM