RAII 与垃圾收集器
Posted
技术标签:
【中文标题】RAII 与垃圾收集器【英文标题】:RAII vs. Garbage Collector 【发布时间】:2017-11-03 15:19:01 【问题描述】:我最近在 CppCon 2016 上观看了 Herb Sutter 关于“Leak Free C++...”的精彩演讲,他谈到了使用智能指针来实现 RAII(资源获取即初始化)- 概念以及它们如何解决大部分内存问题泄漏问题。
现在我想知道。如果我严格遵守 RAII 规则,这似乎是一件好事,那为什么与在 C++ 中使用垃圾收集器有什么不同呢?我知道使用 RAII,程序员可以完全控制何时再次释放资源,但是在任何情况下,这对仅仅拥有一个垃圾收集器有好处吗?效率真的会低吗?我什至听说有一个垃圾收集器可以更有效,因为它可以一次释放更大的内存块,而不是在整个代码中释放小块内存。
【问题讨论】:
确定性资源管理在各种情况下都至关重要,尤其是在处理非托管资源(例如,文件句柄、数据库等)时。除此之外,垃圾收集总是有某种开销,而 RAII 的开销并不比一开始就正确编写代码多。 “在整个代码中释放小块内存”通常效率更高,因为它对应用程序运行的破坏性要小得多。 注意:你说的是resources,但资源不止一种。当需要释放一些内存时会调用垃圾收集器,但在需要关闭文件时不会调用它。 任何事情都比垃圾收集好 @Veedrac 如果您完全致力于 RAII 并在任何地方使用智能指针,那么您也不应该出现 use-after-free 错误。但是,即使 GC(或引用计数的智能指针)可以使您免于使用后释放错误,它也可能掩盖了一种情况,即您在不知不觉中保留对资源的引用比您预期的要长。 @Veedrac:当然这是不公平的。你给了我两个程序来比较,一个释放内存,一个不释放。为了进行公平的比较,您需要运行一个实际需要 GC 的实际工作负载,您知道,启动它。而不是闲置。你需要有一个动态和现实的内存分配模式,而不是先进先出或后进先出或其他一些变体。声称从不释放内存的程序比不释放内存的程序快,或者为 LIFO 释放调整的堆比不释放内存的程序快,这并不完全令人兴奋。嗯,嗯,当然会。 【参考方案1】:垃圾收集器的一个问题是很难预测程序性能。
使用 RAII,您知道资源将在准确的时间超出范围,您将清除一些内存,这将需要一些时间。但是,如果您不是垃圾收集器设置大师,您将无法预测何时进行清理。
例如:用 GC 清理一堆小对象可以更有效地完成,因为它可以释放大块,但它不会快速操作,并且很难预测什么时候会发生,因为“大块清理” " 这将需要一些处理器时间,并且会影响您的程序性能。
【讨论】:
我不确定即使使用最强的 RAII 方法也可以预测程序性能。 Herb Sutter 提供了一些有趣的视频,说明 CPU 缓存的重要性以及如何使性能出人意料地不可预测。 @BasileStarynkevitch GC 停顿比缓存未命中要大几个数量级。 没有“大块清理”这样的东西。实际上,GC 是用词不当,因为大多数实现都是“非垃圾收集器”。他们确定幸存者,将它们移动到别处,更新指针,剩下的就是空闲内存。当大多数对象在 GC 启动之前死亡时,它的效果最好。通常,它非常有效,但很难避免长时间的停顿。 请注意,concurrent and real-time garbage collectors 确实存在,因此可以获得可预测的性能。不过,通常情况下,任何给定语言的“默认”GC 都是为了提高效率而不是一致性而设计的。 当最后一个持有图的 RC 达到零并且所有解构器都运行时,引用计数的对象图也可能有很长的释放时间。【参考方案2】:粗略地说。对于latency 和jitter,RAII 习惯用法可能更好。对于系统的吞吐量,垃圾收集器可能会更好。
【讨论】:
与 GC 相比,为什么 RAII 会受到吞吐量的影响?【参考方案3】:注意RAII 是一种编程习惯,而GC 是一种内存管理技术。因此,我们将苹果与橙子进行比较。
但我们可以将 RAII 限制在其内存管理方面仅,并将其与 GC 技术进行比较。
所谓的基于 RAII 的内存管理技术(实际上是指 reference counting,至少当您考虑内存资源并忽略文件等其他资源时)和真正的 garbage collection 技术之间的主要区别在于 处理循环引用(对于cyclic graphs)。
使用引用计数,您需要专门为它们编写代码(使用weak references 或其他东西)。
在许多有用的情况下(想想std::vector<std::map<std::string,int>>
),引用计数是隐式的(因为它只能是 0 或 1)并且实际上被省略了,但构造函数和析构函数(对 RAII 必不可少)表现得好像存在一个引用计数位(实际上不存在)。在std::shared_ptr
中有一个真正的参考计数器。但是内存仍然隐式 manually managed(new
和 delete
在构造函数和析构函数中触发),但是“隐式”delete
(在析构函数中)给出了自动内存管理的错觉.但是,对new
和delete
的调用仍然会发生(而且它们会耗费时间)。
顺便说一句,GC 实现可能(并且经常这样做)以某种特殊方式处理循环性,但您将这个负担留给了 GC(例如阅读Cheney's algorithm)。
一些 GC 算法(尤其是分代复制垃圾收集器)不会为单个对象释放内存,它是在复制后en masse释放。在实践中,Ocaml GC(或 SBCL 的一种)可能比真正的 C++ RAII 编程风格更快(对于一些,不是所有的算法)。
一些 GC 提供finalization(主要用于管理非内存文件等外部资源),但您很少使用它(因为大多数值只消耗内存资源)。缺点是最终确定不提供任何时间保证。实际上,使用终结的程序将其用作最后的手段(例如,文件的关闭仍应在终结之外或多或少明确地发生,并且也与它们一起发生)。
您仍然可以使用 GC(以及 RAII,至少在使用不当时)出现内存泄漏,例如当一个值保存在某个变量或某个字段中但将来永远不会使用时。它们只是发生的频率较低。
我推荐阅读garbage collection handbook。
在您的 C++ 代码中,您可以使用 Boehm's GC 或 Raven***'s MPS 或编写您自己的 tracing garbage collector。当然,使用 GC 是一种权衡(存在一些不便,例如不确定性、缺乏时间保证等......)。
我不认为 RAII 在所有情况下都是处理内存的终极方式。在某些情况下,在真正有效的 GC 实现(想想 Ocaml 或 SBCL)中对程序进行编码比在 C++17 中使用花哨的 RAII 样式进行编码更简单(开发)和更快(执行)。在其他情况下,它不是。 YMMV。
例如,如果您在 C++17 中使用最华丽的 RAII 样式编写 Scheme 解释器,您仍然需要在其中编写(或使用)explicit GC(因为 Scheme堆有循环性)。而且大多数proof assistants 都是用 GC 语言编写的,通常是函数式语言(我知道的唯一一种用 C++ 编写的语言是 Lean),这是有充分理由的。
顺便说一句,我有兴趣找到 Scheme 的这种 C++17 实现(但对自己编码不太感兴趣),最好具有一些多线程能力。
【讨论】:
RAII 并不意味着引用计数,它只是 std::shared_ptr。在 C++ 中,编译器在证明无法再访问变量时插入对析构函数的调用,即。当变量超出范围时。 @BasileStarynkevitch 大多数 RAII 不引用计数,因为计数只会是 1 RAII 绝对不是引用计数。 @csiz,@JackAidley,我认为你误解了 Basile 的观点。他所说的是任何类似引用计数的实现(即使是像shared_ptr
这样没有显式计数器的简单实现)在处理涉及循环引用的场景时都会遇到麻烦。如果您只讨论资源仅在单个方法中使用的简单情况,您甚至不需要 shared_ptr
但这只是非常有限的子空间,基于 GC 的世界也使用类似的方法,例如 C# using
或 Java try-with-resources
。但现实世界也有更复杂的场景。
@SergGr:谁说过unique_ptr
处理循环引用?这个答案明确声称“所谓的 RAII 技术”“真的意味着引用计数”。我们可以(而且我确实)拒绝该主张——因此对这个答案的大部分内容提出异议(无论是在准确性方面还是在相关性方面)——而不必拒绝该答案中的每一个主张。 (顺便说一下,现实世界中也存在不处理循环引用的垃圾收集器。)【参考方案4】:
“高效”是一个非常广泛的术语,在开发工作的意义上,RAII 通常不如 GC 效率高,但就性能而言,GC 通常不如 RAII 效率高。但是,可以为这两种情况提供对比示例。当您在托管语言中具有非常明确的资源(取消)分配模式时处理通用 GC 可能会相当麻烦,就像使用 RAII 的代码在无缘无故地使用 shared_ptr
时可能会出人意料地低效。
【讨论】:
“在开发工作的意义上,RAII 通常比 GC 效率低” 使用 C# 和 C++ 编程后,您可以很好地了解这两种策略,我有强烈反对这种说法。当人们发现 C++ 的 RAII 模型效率较低时,很可能是因为他们没有正确使用它。严格来说,这不是模型的错误。通常情况下,这是人们使用 C++ 编程的标志,就好像它是 Java 或 C#。创建一个临时对象并通过作用域自动释放它并不比等待 GC 更难。【参考方案5】:如果我严格遵守 RAII 规则,这似乎是件好事,那为什么与在 C++ 中使用垃圾收集器有什么不同?
虽然两者都处理分配,但它们以完全不同的方式处理。如果您要引用 Java 中的 GC,则会增加其自身的开销,从资源释放过程中移除一些确定性并处理循环引用。
您可以在特定情况下实现 GC,但性能特征会有很大不同。我在高性能/高吞吐量服务器中实现了一次关闭套接字连接(仅调用套接字关闭 API 花费了太长时间并且降低了吞吐量性能)。这不涉及内存,只涉及网络连接,并且不涉及循环依赖处理。
我知道,使用 RAII 时,程序员可以完全控制何时再次释放资源,但是在任何情况下,仅使用垃圾收集器是否有益?
这种确定性是 GC 根本不允许的特性。有时您希望能够知道在某个时间点之后,已执行清理操作(删除临时文件、关闭网络连接等)。
在这种情况下,GC 不会削减它,这就是在 C# 中(例如)你有 IDisposable
接口的原因。
我什至听说拥有垃圾收集器会更高效,因为它可以一次释放更大的内存块,而不是在整个代码中释放小块内存。
可以...取决于实现。
【讨论】:
请注意,还有一些算法依赖于 GC,无法使用 RAII 实现。例如,一些并发无锁算法,您有多个线程竞相发布一些数据。例如。据我所知,Cliff's non-blocking hashmap 没有 C++ 实现。 添加它自己的开销 - 你不需要为 malloc 和免费支付费用。你基本上是在用免费列表管理和引用计数来进行活性扫描。 Java 和 .NET 中的 GC仅 用于释放仍由无法访问的对象分配的内存。这不是完全确定的,但是,文件句柄和网络连接等资源是通过完全不同的机制关闭的(在 Java 中,java.io.Closeable
接口和“try-with-resources”块),是 i> 完全确定性。因此,关于“清理操作”的确定性的部分答案是错误的。
@Voo 在这种情况下,您可能会争辩说它实际上并不是无锁的,因为垃圾收集器正在为您进行锁定。
@Voo 您的算法是否依赖使用锁的线程调度程序?【参考方案6】:
垃圾收集解决了 RAII 无法解决的某些类别的资源问题。基本上,它归结为循环依赖,您没有事先确定循环。
这有两个优点。首先,会有某些类型的问题是 RAII 无法解决的。根据我的经验,这种情况很少见。
更大的一点是它让程序员变得懒惰并且不关心内存资源的生命周期以及你不介意延迟清理的某些其他资源。当您不必关心某些类型的问题时,您可以更多地关心其他问题。这让您可以专注于您想要关注的问题部分。
缺点是,如果没有 RAII,管理您想要限制其生命周期的资源是很困难的。 GC 语言基本上将您减少到具有极其简单的范围绑定生命周期或要求您手动进行资源管理,如在 C 中,手动声明您已完成资源。他们的对象生命周期系统与 GC 密切相关,并且不适用于大型复杂(但无循环)系统的严格生命周期管理。
公平地说,C++ 中的资源管理需要大量工作才能在如此大型复杂(但无循环)的系统中正确完成。 C# 和类似的语言只是让它变得更难,作为交换,它们让简单的案例变得简单。
大多数 GC 实现还强制使用非局部性的完整类;创建通用对象的连续缓冲区,或者将通用对象组合成一个更大的对象,这并不是大多数 GC 实现容易做到的事情。另一方面,C# 允许您创建功能有限的值类型struct
s。在当前 CPU 架构时代,缓存友好性是关键,locality GC 力量的缺乏是沉重的负担。由于这些语言大部分都具有字节码运行时,理论上 JIT 环境可以将常用数据一起移动,但与 C++ 相比,由于频繁的缓存未命中,您通常只会获得统一的性能损失。
GC 的最后一个问题是释放是不确定的,有时会导致性能问题。与过去相比,现代 GC 减少了这个问题。
【讨论】:
我不确定我是否理解你关于地方性的论点。成熟环境(Java、.Net)中的大多数现代 GC 执行压缩并从分配给每个线程的连续内存块中创建新对象。所以我希望大约在同一时间创建的对象将是相对本地的。 AFAIK 在标准的malloc
实现中没有这样的东西。这样的逻辑可能会导致错误共享,这对于多线程环境来说是一个问题,但这是另一回事。在 C 中,您可以使用显式技巧来改善局部性,但如果您不这样做,我希望 GC 会更好。我错过了什么?
@SergGr 我可以在 C++ 中创建一个连续的非普通旧数据对象数组并按顺序迭代它们。我可以明确地移动它们,使它们彼此相邻。当我遍历一个连续的值容器时,它们保证在内存中按顺序定位。基于节点的容器缺乏这种保证,并且 gc 语言统一支持仅基于节点的容器(充其量,您有一个连续的引用缓冲区,而不是对象缓冲区)。通过在 C++ 中的一些工作,我什至可以使用运行时多态值(虚拟方法等)来做到这一点。
Yakk,看来您是在说非 GC 世界允许您争取局部性并取得比 GC 世界更好的结果。但这只是故事的一半,因为默认情况下,您可能会得到比在 GC 世界中更糟糕的结果。实际上是malloc
迫使您必须对抗非本地性而不是 GC,因此我认为在您的回答中声称“大多数 GC 实现也强制非本地性”并不是真的是的。
@Rogério 是的,这就是我所说的基于受限范围或 C 风格的对象生命周期管理。您在哪里手动定义对象生命周期结束的时间,或者使用简单的范围案例。
抱歉,不,程序员不能“懒惰”和“不在乎”内存资源的生命周期。如果您有一个管理您的Foo
对象的FooWidgetManager
,它很可能将注册的Foo
存储在一个无限增长的数据结构 中。这样一个“注册的Foo
”对象超出了您的GC 的范围,因为FooWidgetManager
的内部列表或任何持有对它的引用。要释放此内存,您需要要求FooWidgetManager
取消注册该对象。如果你忘记了,这本质上是“没有删除的新”;只是名称变了……而 GC 无法修复它。【参考方案7】:
RAII 统一处理任何可描述为资源的内容。动态分配就是这样一种资源,但它们绝不是唯一的一种,并且可以说不是最重要的一种。文件、套接字、数据库连接、gui 反馈等等都是可以使用 RAII 确定性管理的东西。
GC 只处理动态分配,让程序员不必担心程序生命周期内分配对象的总量(他们只需要关心峰值并发分配量的拟合)
【讨论】:
【参考方案8】:RAII 和 GC 在完全不同的方向上解决问题。尽管有些人会说,但它们完全不同。
两者都解决了管理资源困难的问题。 Garbage Collection 解决了这个问题,使开发人员无需过多关注管理这些资源。 RAII 通过让开发人员更容易关注他们的资源管理来解决这个问题。任何说他们做同样事情的人都有东西可以卖给你。
如果您查看语言的最新趋势,您会发现这两种方法都在同一种语言中使用,因为坦率地说,您确实需要拼图的两面。您会看到许多使用各种垃圾收集的语言,这样您就不必关注大多数对象,并且这些语言还为您真正想要的时间提供 RAII 解决方案(例如 python 的 with
运算符)注意他们。
shared_ptr
提供 GC(如果我可以论证 refcounting 和 GC 属于同一类解决方案,因为它们都旨在帮助您无需关注寿命)
Python 通过 with
提供 RAII,并通过引用计数系统和垃圾收集器提供 GC
C# 通过 IDisposable
和 using
提供 RAII,并通过分代垃圾收集器提供 GC
模式在每种语言中都出现了。
【讨论】:
【参考方案9】:如果不提供大量上下文并争论这些术语的定义,就无法回答关于其中一个或另一个是“有益”还是更“有效”的问题的主要部分。
除此之外,你基本上可以感受到古人“Java还是C++更好的语言?”的张力。在 cmets 中噼啪作响。我想知道这个问题的“可接受”答案是什么样的,我很想最终看到它。
但是关于可能重要的概念差异的一点尚未被指出:使用 RAII,您被绑定到调用析构函数的线程。如果您的应用程序是单线程的(尽管 Herb Sutter 说The Free Lunch Is Over:当今大多数软件实际上仍然是单线程的),那么单个内核可能会忙于处理清理工作不再与实际程序相关的对象...
与此相反,垃圾收集器通常在自己的线程中运行,甚至在多个线程中运行,因此(在某种程度上)与其他部分的执行解耦。
(注意:一些答案已经尝试指出具有不同特征的应用程序模式,提到了效率、性能、延迟和吞吐量 - 但尚未提及这一点)
【讨论】:
好吧,如果您限制环境,如果您的机器在单核上运行或广泛使用多任务处理,那么您的主线程和 GC 线程必然会在同一个核心上运行,相信我,上下文切换将会比清理资源更多的开销:) @AbhinavGauniyal 正如我试图强调的那样:这是一个概念上的差异。其他人已经指出了责任,但将其集中在用户的观点上(~“用户负责清理”)。我的观点是,这也产生了一个重要的技术差异:主程序是否负责清理,或者基础设施是否有一个(独立的)部分来解决这个问题。但是,我只是认为这可能值得一提,因为内核数量不断增加(在单线程程序中通常处于休眠状态)。 是的,我也支持你。我也只是提出了你观点的另一面。 @Marco13:另外,RAII 和 GC 的清理成本完全不同。最坏情况下的 RAII 意味着遍历刚刚释放的复杂引用计数数据结构。在最坏的情况下,GC 意味着遍历所有活动对象,这是一种相反的事情。 @ninjalj 我不是细节方面的专家——垃圾收集实际上是一个自己的研究分支。为了讨论成本,人们可能不得不将关键字固定到一个特定的实现(对于 RAII,不同选项的空间不大,但至少我知道有相当多的 GC 实现,具有截然不同的策略) .【参考方案10】:垃圾收集和 RAII 各自支持一种通用构造,而另一种并不真正适合。
在垃圾回收系统中,代码可以有效地将对不可变对象(例如字符串)的引用视为其中包含的数据的代理;传递此类引用几乎与传递“哑”指针一样便宜,并且比为每个所有者制作单独的数据副本或尝试跟踪数据共享副本的所有权更快。此外,垃圾收集系统通过编写一个创建可变对象的类,根据需要填充它并提供访问器方法,可以轻松创建不可变对象类型,同时避免泄漏对任何可能在构造函数后发生变异的任何内容的引用完成。在需要广泛复制对不可变对象的引用但对象本身不需要复制的情况下,GC 胜过 RAII。
另一方面,RAII 擅长处理对象需要从外部实体获取专有服务的情况。虽然许多 GC 系统允许对象定义“Finalize”方法并在发现它们被放弃时请求通知,并且这些方法有时可能会设法释放不再需要的外部服务,但它们很少可靠到足以提供令人满意的方法确保及时发布外部服务。在管理不可替代的外部资源方面,RAII 胜过 GC。
GC 胜出的情况与 RAII 胜出的情况之间的主要区别在于,GC 擅长管理可按需释放的可替代内存,但不擅长处理不可替代的资源。 RAII 擅长处理具有明确所有权的对象,但不擅长处理除了它们包含的数据之外没有真实身份的无主不可变数据持有者。
因为 GC 和 RAII 都不能很好地处理所有场景,所以语言为它们提供良好的支持会很有帮助。不幸的是,专注于一种语言的语言倾向于将另一种语言视为事后的想法。
【讨论】:
【参考方案11】:RAII 和垃圾回收旨在解决不同的问题。
当您使用 RAII 时,您会在堆栈上留下一个对象,其唯一目的是在离开方法范围时清理您想要管理的任何内容(套接字、内存、文件等)。这是为了异常安全,而不仅仅是垃圾收集,这就是为什么您会收到有关关闭套接字和释放互斥锁等的响应。 (好吧,除了我之外没有人提到互斥锁。)如果抛出异常,堆栈展开自然会清理方法使用的资源。
垃圾收集是内存的编程管理,但如果您愿意,您可以“垃圾收集”其他稀缺资源。在 99% 的情况下,明确地释放它们更有意义。将 RAII 用于文件或套接字之类的唯一原因是您希望资源的使用在方法返回时完成。
垃圾收集还处理堆分配的对象,例如,当工厂构造对象的实例并返回它时。在控制必须离开范围的情况下拥有持久对象是垃圾收集有吸引力的原因。但是您可以在工厂中使用 RAII,因此如果在返回之前抛出异常,您不会泄漏资源。
【讨论】:
【参考方案12】:我什至听说有一个垃圾收集器可以更高效,因为它可以一次释放更大的内存块,而不是在整个代码中释放小的内存块。
使用 RAII(或普通的 malloc/free),这是完全可行的 - 事实上,实际上已经完成了。你看,你不一定总是使用默认的分配器,它只会零碎地释放。在某些情况下,您使用具有不同功能的自定义分配器。一些分配器具有内置的能力,可以一次性释放某个分配器区域中的所有内容,而无需迭代单个分配的元素。
当然,然后您会遇到什么时候释放所有内容的问题 - 是否必须对这些分配器(或与它们关联的内存板)的使用进行 RAIIed,以及如何释放。
【讨论】:
以上是关于RAII 与垃圾收集器的主要内容,如果未能解决你的问题,请参考以下文章