我最近在 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 架构时代,缓存友好性是关键,locality GC 力量的缺乏是沉重的负担。由于这些语言大部分都具有字节码运行时,理论上 JIT 环境可以将常用数据一起移动,但与 C++ 相比,由于频繁的缓存未命中,您通常只会获得统一的性能损失。
GC 的最后一个问题是释放是不确定的,有时会导致性能问题。与过去相比,现代 GC 减少了这个问题。
malloc
实现中没有这样的东西。这样的逻辑可能会导致错误共享,这对于多线程环境来说是一个问题,但这是另一回事。在 C 中,您可以使用显式技巧来改善局部性,但如果您不这样做,我希望 GC 会更好。我想念什么?
malloc
迫使您必须反对非本地性而不是 GC,因此我认为您的回答中声称“大多数 GC 实现也强制非本地性”并不是真的真的。
Foo
对象的 FooWidgetManager
,它很可能会将已注册的 Foo
存储在无限增长的数据结构中。这种“已注册的 Foo
”对象超出了 GC 的范围,因为 FooWidgetManager
的内部列表或任何持有对它的引用。要释放此内存,您需要要求 FooWidgetManager
取消注册该对象。如果你忘记了,这本质上是“没有删除的新”;只有名称发生了变化……而 GC 无法修复它。
RAII 和 GC 在完全不同的方向上解决问题。尽管有些人会说,但它们完全不同。
两者都解决了管理资源困难的问题。垃圾收集解决了这个问题,这样开发人员就不需要花太多精力来管理这些资源。 RAII 通过让开发人员更容易关注他们的资源管理来解决这个问题。任何说他们做同样事情的人都有东西可以卖给你。
如果您查看语言的最新趋势,您会看到两种方法都在同一种语言中使用,因为坦率地说,您确实需要拼图的两面。您会看到许多使用各种垃圾收集的语言,这样您就不必关注大多数对象,并且这些语言还为您真正想要的时间提供 RAII 解决方案(例如 python 的 with
运算符)注意他们。
C++ 通过构造函数/析构函数提供 RAII,通过 shared_ptr 提供 GC(如果我可以说 refcounting 和 GC 属于同一类解决方案,因为它们都旨在帮助您无需关注寿命)
Python 通过 with 提供 RAII,通过引用计数系统和垃圾收集器提供 GC
C# 通过 IDisposable 和 using 提供 RAII,并通过分代垃圾收集器提供 GC
这些模式在每种语言中都出现了。
请注意,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 算法(尤其是分代复制垃圾收集器)不会为单个对象释放内存,它是在复制后集体释放。在实践中,Ocaml GC(或 SBCL 之一)可能比真正的 C++ RAII 编程风格(对于某些而非所有算法)更快。
一些 GC 提供 finalization(主要用于管理 非内存 外部资源,如文件),但您很少使用它(因为大多数值仅消耗内存资源)。缺点是最终确定不提供任何时间保证。实际上,使用终结的程序将其用作最后的手段(例如,文件的关闭仍应在终结之外或多或少地明确发生,并且也与它们一起发生)。
您仍然可能会在使用 GC 时出现内存泄漏(以及使用 RAII,至少在使用不当时),例如,当某个值保存在某个变量或某个字段中但将来永远不会使用时。它们只是发生的频率较低。
我建议阅读garbage collection handbook。
在您的 C++ 代码中,您可以使用 Boehm's GC 或 Ravenbrook'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 实现(但对自己编码不太感兴趣),最好具有一些多线程能力。
shared_ptr
这样没有显式计数器的简单实现)在处理涉及循环引用的场景时都会遇到麻烦。如果您只讨论资源仅在单个方法中使用的简单情况,您甚至不需要 shared_ptr
但这只是非常有限的子空间,基于 GC 的世界也使用类似的方法,例如 C# using
或 Java try-with-resources
。但现实世界也有更复杂的场景。
unique_ptr
处理循环引用?这个答案明确声称“所谓的 RAII 技术”“真的意味着引用计数”。我们可以(而且我确实)拒绝该主张 - 因此对这个答案的大部分内容提出异议(无论是在准确性方面还是在相关性方面) - 而不必拒绝该答案中的每一个主张。 (顺便说一句,现实世界中也存在不处理循环引用的垃圾收集器。)
垃圾收集器的问题之一是很难预测程序性能。
使用 RAII,您知道资源将在准确的时间超出范围,您将清除一些内存,这将需要一些时间。但是,如果您不是垃圾收集器设置的大师,您将无法预测何时会发生清理。
例如:用 GC 清理一堆小对象可以更有效地完成,因为它可以释放大块,但它不会快速操作,而且很难预测什么时候会发生,因为“大块清理”它会占用一些处理器时间,并可能影响您的程序性能。
大致说来。 RAII 惯用语可能更适合延迟和抖动。垃圾收集器可能对系统的吞吐量更好。
“高效”是一个非常广泛的术语,在开发工作的意义上,RAII 通常不如 GC 效率高,但就性能而言,GC 通常不如 RAII 效率高。但是,可以为这两种情况提供反例。当您在托管语言中具有非常清晰的资源(取消)分配模式时处理通用 GC 可能会相当麻烦,就像使用 RAII 的代码在无缘无故地使用 shared_ptr
时可能会出人意料地低效一样。
垃圾收集和 RAII 各自支持一个共同的构造,而另一个并不真正适合。
在垃圾收集系统中,代码可以有效地将对不可变对象(例如字符串)的引用视为其中包含的数据的代理;传递此类引用几乎与传递“哑”指针一样便宜,并且比为每个所有者制作单独的数据副本或尝试跟踪数据共享副本的所有权更快。此外,垃圾收集系统通过编写一个创建可变对象的类,根据需要填充它并提供访问器方法,可以轻松创建不可变对象类型,同时避免泄漏对任何可能在构造函数后发生变异的任何内容的引用完成。在需要广泛复制对不可变对象的引用但对象本身不需要复制的情况下,GC 胜过 RAII。
另一方面,RAII 擅长处理对象需要从外部实体获取专有服务的情况。虽然许多 GC 系统允许对象定义“Finalize”方法并在发现它们被放弃时请求通知,并且这些方法有时可能会设法释放不再需要的外部服务,但它们很少可靠到足以提供令人满意的方法确保及时发布外部服务。对于不可替代的外部资源的管理,RAII 胜过 GC。
GC 胜出的情况与 RAII 胜出的情况之间的主要区别在于,GC 擅长管理可按需释放的可替代内存,但不擅长处理不可替代资源。 RAII 擅长处理具有明确所有权的对象,但不擅长处理除了它们包含的数据之外没有真实身份的无主不可变数据持有者。
因为 GC 和 RAII 都不能很好地处理所有场景,所以语言为它们提供良好的支持会很有帮助。不幸的是,专注于一种语言的语言倾向于将另一种语言视为事后的想法。
如果不提供大量上下文并争论这些术语的定义,就无法回答关于一个或另一个是“有益”还是更“有效”的问题的主要部分。
除此之外,您基本上可以感受到古老的“Java还是C++更好的语言?”的张力。评论中的火焰战争噼啪作响。我想知道这个问题的“可接受”答案是什么样的,并且很想最终看到它。
但是关于可能重要的概念差异的一点尚未被指出:使用 RAII,您被绑定到调用析构函数的线程。如果您的应用程序是单线程的(尽管 Herb Sutter 曾说过 The Free Lunch Is Over:当今的大多数软件实际上仍然是单线程的),那么单个内核可能会忙于处理清理工作不再与实际程序相关的对象...
与此相反,垃圾收集器通常在自己的线程中运行,甚至在多个线程中运行,因此(在某种程度上)与其他部分的执行解耦。
(注意:一些答案已经尝试指出具有不同特征的应用程序模式,提到了效率、性能、延迟和吞吐量——但尚未提到这一点)
RAII 统一处理任何可描述为资源的东西。动态分配就是这样一种资源,但它们绝不是唯一的资源,也可以说不是最重要的资源。文件、套接字、数据库连接、gui 反馈等等都是可以使用 RAII 确定性管理的东西。
GC 只处理动态分配,让程序员不必担心程序生命周期内分配对象的总量(他们只需要关心峰值并发分配量的拟合)
RAII 和垃圾收集旨在解决不同的问题。
当您使用 RAII 时,您会在堆栈上留下一个对象,其唯一目的是在离开方法范围时清理您想要管理的任何内容(套接字、内存、文件等)。这是为了异常安全,而不仅仅是垃圾收集,这就是为什么您会收到有关关闭套接字和释放互斥锁等的响应。 (好吧,除了我之外没有人提到互斥锁。)如果抛出异常,堆栈展开自然会清理方法使用的资源。
垃圾收集是内存的编程管理,但如果您愿意,您可以“垃圾收集”其他稀缺资源。在 99% 的情况下,明确地释放它们更有意义。将 RAII 用于文件或套接字之类的唯一原因是您希望在方法返回时完成对资源的使用。
垃圾收集还处理堆分配的对象,例如,当工厂构造对象的实例并返回它时。在控制必须离开范围的情况下拥有持久对象是垃圾收集有吸引力的原因。但是您可以在工厂中使用 RAII,因此如果在您返回之前抛出异常,您不会泄漏资源。
我什至听说有一个垃圾收集器可以更有效,因为它可以一次释放更大的内存块,而不是释放整个代码中的小内存块。
这是完全可行的——事实上,实际上已经完成了——使用 RAII(或普通的 malloc/free)。你看,你不一定总是使用默认的分配器,它只会零碎地释放。在某些情况下,您使用具有不同功能的自定义分配器。一些分配器具有内在的能力,可以一次性释放某个分配器区域中的所有内容,而无需迭代单个分配的元素。
当然,然后你会遇到什么时候释放所有东西的问题——这些分配器(或与它们相关联的内存板)的使用是否必须被 RAIIed,以及如何。
不定期副业成功案例分享
java.io.Closeable
接口和“try-with-resources”块)关闭的,是 i> 完全确定的。因此,关于“清理操作”的确定性的部分答案是错误的。