ChatGPT解决这个技术问题 Extra ChatGPT

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

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

确定性资源管理在各种情况下都至关重要,尤其是在处理非托管资源(例如,文件句柄、数据库等)时。除此之外,垃圾收集总是有某种开销,而 RAII 的开销并不比一开始就正确编写代码多。 “在整个代码中释放小块内存”通常效率更高,因为它对应用程序运行的破坏性要小得多。
注意:你说的是资源,但资源不止一种。当需要释放一些内存时会调用垃圾收集器,但在需要关闭文件时不会调用它。
什么都比垃圾收集好
@Veedrac 如果您完全致力于 RAII 并在任何地方使用智能指针,那么您也不应该出现 use-after-free 错误。但是,即使 GC(或 ref-counted smart pointers)可以使您免于 use-after-free 错误,它也可能掩盖了您在不知不觉中保留对资源的引用比您预期更长的情况。
@Veedrac:这当然不公平。你给了我两个程序来比较,一个释放内存,一个不释放。为了进行公平的比较,您需要运行一个实际需要 GC 的实际工作负载,您知道,启动它。而不是闲置。你需要有一个动态和现实的内存分配模式,而不是先进先出或后进先出或其他一些变体。声称从不释放内存的程序比不释放内存的程序快,或者为 LIFO 释放调整的堆比不释放内存的程序快,这并不完全令人兴奋。嗯,嗯,当然会。

D
Daniel Kamil Kozar

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

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

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

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

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

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

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

可以...取决于实现。


请注意,还有一些算法依赖于 GC,无法使用 RAII 实现。例如,一些并发无锁算法,您有多个线程竞相发布一些数据。例如,据我所知,没有 Cliff's non-blocking hashmap 的 C++ 实现。
增加了它自己的开销 - otoh 你没有为 malloc 和免费支付费用。您基本上是在交易免费列表管理和引用计数来进行活性扫描。
Java 和 .NET 中的 GC用于释放仍由无法访问的对象分配的内存。这不是完全确定的,但是,文件句柄和网络连接等资源是通过完全不同的机制(在 Java 中,java.io.Closeable 接口和“try-with-resources”块)关闭的, 完全确定的。因此,关于“清理操作”的确定性的部分答案是错误的。
@Voo 在这种情况下,您可能会争辩说它实际上并不是无锁的,因为垃圾收集器正在为您进行锁定。
@Voo您的算法是否依赖于使用锁的线程调度程序?
Y
Yakk - Adam Nevraumont

垃圾收集解决了 RAII 无法解决的某些类别的资源问题。基本上,它归结为循环依赖,您没有事先确定循环。

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

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

不利的一面是,如果没有 RAII,管理您想要限制其生命周期的资源是很困难的。 GC 语言基本上将您减少到具有极其简单的范围绑定生命周期或要求您手动进行资源管理,如在 C 中,手动声明您已完成资源。他们的对象生命周期系统与 GC 紧密相关,并且不适用于大型复杂(但无循环)系统的严格生命周期管理。

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

大多数 GC 实现还强制使用非局部性的完整类;创建通用对象的连续缓冲区,或者将通用对象组合成一个更大的对象,这并不是大多数 GC 实现容易做到的事情。另一方面,C# 允许您创建功能有限的值类型 struct。在当前 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 无法修复它
C
Cort Ammon

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

两者都解决了管理资源困难的问题。垃圾收集解决了这个问题,这样开发人员就不需要花太多精力来管理这些资源。 RAII 通过让开发人员更容易关注他们的资源管理来解决这个问题。任何说他们做同样事情的人都有东西可以卖给你。

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

C++ 通过构造函数/析构函数提供 RAII,通过 shared_ptr 提供 GC(如果我可以说 refcounting 和 GC 属于同一类解决方案,因为它们都旨在帮助您无需关注寿命)

Python 通过 with 提供 RAII,通过引用计数系统和垃圾收集器提供 GC

C# 通过 IDisposable 和 using 提供 RAII,并通过分代垃圾收集器提供 GC

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


B
Basile Starynkevitch

请注意,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(在构造函数和析构函数中触发了 newdelete),但是“隐式”delete(在析构函数中)给出了自动内存管理的错觉.但是,对 newdelete 的调用仍然会发生(并且它们会花费时间)。

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

一些 GC 算法(尤其是分代复制垃圾收集器)不会为单个对象释放内存,它是在复制后集体释放。在实践中,Ocaml GC(或 SBCL 之一)可能比真正的 C++ RAII 编程风格(对于某些而非所有算法)更快。

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

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

我建议阅读garbage collection handbook

在您的 C++ 代码中,您可以使用 Boehm's GCRavenbrook'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 技术”“真的意味着引用计数”。我们可以(而且我确实)拒绝该主张 - 因此对这个答案的大部分内容提出异议(无论是在准确性方面还是在相关性方面) - 而不必拒绝该答案中的每一个主张。 (顺便说一句,现实世界中也存在不处理循环引用的垃圾收集器。)
N
NathanOliver

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

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

例如:用 GC 清理一堆小对象可以更有效地完成,因为它可以释放大块,但它不会快速操作,而且很难预测什么时候会发生,因为“大块清理”它会占用一些处理器时间,并可能影响您的程序性能。


我不确定即使使用最强的 RAII 方法也可以预测程序性能。 Herb Sutter 提供了一些有趣的视频,介绍了 CPU 缓存的重要性以及如何使性能出人意料地不可预测。
@BasileStarynkevitch GC 停顿比缓存未命中大几个数量级。
没有“大块清理”之类的东西。实际上,GC 是用词不当,因为大多数实现都是“非垃圾收集器”。他们确定幸存者,将它们移动到别处,更新指针,剩下的就是空闲内存。当大多数对象在 GC 启动之前死亡时,它的效果最好。通常,它非常有效,但避免长时间的停顿是很困难的。
请注意,concurrent and real-time garbage collectors 确实存在,因此可以获得可预测的性能。不过,通常情况下,任何给定语言的“默认”GC 都是为了提高效率而不是一致性而设计的。
当最后一个 RC 保持该图处于活动状态并且所有解构器都运行时,引用计数的对象图也可能具有非常长的释放时间。
ネロク

大致说来。 RAII 惯用语可能更适合延迟和抖动。垃圾收集器可能对系统的吞吐量更好。


与 GC 相比,为什么 RAII 会受到吞吐量的影响?
u
user7860670

“高效”是一个非常广泛的术语,在开发工作的意义上,RAII 通常不如 GC 效率高,但就性能而言,GC 通常不如 RAII 效率高。但是,可以为这两种情况提供反例。当您在托管语言中具有非常清晰的资源(取消)分配模式时处理通用 GC 可能会相当麻烦,就像使用 RAII 的代码在无缘无故地使用 shared_ptr 时可能会出人意料地低效一样。


“在开发工作的意义上,RAII 通常比 GC 效率低” 在使用 C# 和 C++ 编程后,您可以对这两种策略进行很好的采样,但我不得不强烈反对这种说法。当人们发现 C++ 的 RAII 模型效率较低时,很可能是因为他们没有正确使用它。严格来说,这不是模型的错误。通常情况下,这是人们使用 C++ 编程的标志,就好像它是 Java 或 C#。创建一个临时对象并通过作用域自动释放它并不比等待 GC 更难。
s
supercat

垃圾收集和 RAII 各自支持一个共同的构造,而另一个并不真正适合。

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

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

GC 胜出的情况与 RAII 胜出的情况之间的主要区别在于,GC 擅长管理可按需释放的可替代内存,但不擅长处理不可替代资源。 RAII 擅长处理具有明确所有权的对象,但不擅长处理除了它们包含的数据之外没有真实身份的无主不可变数据持有者。

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


S
SongWithoutWords

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

除此之外,您基本上可以感受到古老的“Java还是C++更好的语言?”的张力。评论中的火焰战争噼啪作响。我想知道这个问题的“可接受”答案是什么样的,并且很想最终看到它。

但是关于可能重要的概念差异的一点尚未被指出:使用 RAII,您被绑定到调用析构函数的线程。如果您的应用程序是单线程的(尽管 Herb Sutter 曾说过 The Free Lunch Is Over:当今的大多数软件实际上仍然单线程的),那么单个内核可能会忙于处理清理工作不再与实际程序相关的对象...

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

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


好吧,如果您限制环境,如果您的机器在单核上运行或广泛使用多任务处理,那么您的主线程和 GC 线程必然会在同一个核心上运行,相信我,上下文切换将比清理您的资源产生更多开销:)
@AbhinavGauniyal 正如我试图强调的那样:这是一个概念上的差异。其他人已经指出了责任,但将其集中在用户的角度(~“用户负责清理”)。我的观点是,这也产生了重要的技术差异:主程序是否负责清理,或者是否有基础设施的(独立)部分为此。但是,我只是认为这可能值得一提,因为内核数量不断增加(在单线程程序中通常处于休眠状态)。
是的,我也支持你。我也只是提出了你观点的另一面。
@Marco13:此外,RAII 和 GC 之间的清理成本完全不同。最坏情况下的 RAII 意味着遍历刚刚释放的复杂引用计数数据结构。在最坏的情况下,GC 意味着遍历所有活动对象,这是一种相反的事情。
@ninjalj 我不是细节专家 - 垃圾收集实际上是一个自己的研究分支。为了争论成本,人们可能不得不将关键字固定到一个特定的实现(对于 RAII,不同选项的空间不大,但至少我知道有相当多的 GC 实现,具有截然不同的策略) .
C
Caleth

RAII 统一处理任何可描述为资源的东西。动态分配就是这样一种资源,但它们绝不是唯一的资源,也可以说不是最重要的资源。文件、套接字、数据库连接、gui 反馈等等都是可以使用 RAII 确定性管理的东西。

GC 只处理动态分配,让程序员不必担心程序生命周期内分配对象的总量(他们只需要关心峰值并发分配量的拟合)


c
cahuson

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

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

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

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


e
einpoklum

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

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

当然,然后你会遇到什么时候释放所有东西的问题——这些分配器(或与它们相关联的内存板)的使用是否必须被 RAIIed,以及如何。