基于作用域的内存管理的缺点


38

我真的很喜欢基于作用域的内存管理(SBMM)或RAII,因为它在C ++社区中更为常见(令人困惑?)。据我所知,除C ++(和C)外,当今没有其他主流语言使SBMM / RAII成为主要的内存管理机制,相反,他们更喜欢使用垃圾回收(GC)。

我觉得这很令人困惑,因为

  1. SBMM使程序更具确定性(您可以确切地说出销毁对象的时间);
  2. 在使用GC的语言中,您通常必须执行手动资源管理(例如,参见关闭Java中的文件),这在一定程度上违背了GC的目的,并且也容易出错。
  3. 堆内存还可以(非常优雅地称为imo)是作用域绑定的(请参见std::shared_ptrC ++)。

为什么SBMM没有得到更广泛的使用?它有什么缺点?


1
Wikipedia中讨论了一些缺点(尤其是关于速度的缺点):en.wikipedia.org/wiki/…–
Philipp

2
Java的手动资源管理问题是不保证finalize()在垃圾回收之前将调用对象的方法的副作用。实际上,这产生了垃圾回收应该解决的同一类问题。
Blrfl 2014年

7
@Blrfl废话。即使不存在“问题”,“手动”资源管理(显然对于内存以外的资源)也是可取的,因为GC可以在资源变得闲置后运行很长时间,甚至根本不运行。对于内存而言,这没有问题,而内存管理正是垃圾收集所要解决的全部。

4
顺便说一句 我喜欢将其称为SBRM,因为您可以使用相同的机制来管理一般的资源,而不仅仅是内存。
PlasmaHH 2014年

Answers:


27

让我们首先假设内存比所有其他资源的总和(几十,数百甚至数千次)更为普遍。每个单个变量,对象,对象成员都需要分配一些内存,并在以后释放。对于打开的每个文件,您都会创建数十至数百万个对象来存储从文件中拉出的数据。每个TCP流与无限多个临时字节字符串一起创建,这些字符串将被写入流中。我们在同一页面上吗?大。

为了使RAII正常工作(即使您在阳光下也能为每个用例准备现成的智能指针),也需要正确拥有所有权。您需要分析谁应该拥有这个或那个对象,谁不应该拥有,以及何时应该将所有权从A转移到B。当然,您可以对所有内容使用共享所有权,但是随后您将通过智能指针模拟GC。到那时,将GC内置到语言中变得更加容易快捷。

垃圾收集使您摆脱了对目前最常用的资源即内存的关注。当然,您仍然需要对其他资源做出相同的决定,但是这些资源很少见(见上文),复杂的(例如共享)所有权也很少见。精神负担大大减轻。

现在,您列举了一些缺点,使所有值都被垃圾回收。但是,将具有内存安全性的GC 值类型与RAII集成到一种语言中是非常困难的,因此也许最好通过其他方法来减少这些折衷?

实际上,确定性的丧失并没有那么糟糕,因为它只会影响确定性对象的生存期。正如在下段中描述,大多数资源(除了存储器,这是丰富和可相当懒惰地再循环)的绑定到在这些语言对象的寿命。还有其他一些用例,但在我的经验中很少见。

如今,您的第二点是手动资源管理,该声明通过执行基于作用域的清除,但并未将此清除与对象生命周期耦合在一起(因此不会与GC和内存安全性交互)而得到解决。在最新的Java版本中using,这是C#,withPython中的try-with-resources。


1
这并不能解释为什么像GC这样的非确定性模型应该优于确定性模型的原因。 using语句只能在本地使用。这样就不可能清除成员变量中保存的资源。
菲利普2014年

8
@Philipp所有内容的共享所有权一个GC,只是一个非常差的一个。如果您使用“共享所有权”来暗示引用计数,请这样说,但请在有关amon答案的评论中保留有关周期的讨论。我也不确定ref计数是否在OP感兴趣的意义上是确定性的(对象尽可能早地释放,打折周期,​​但是您通常无法通过查看程序来知道何时释放)。而且,对所有内容的引用计数都很慢,比现代跟踪GC慢得多。

16
using与RAII相比,这是个玩笑。
DeadMG 2014年

3
@Philipp请描述您的“高级”指标。的确,手动内存管理在运行时可以更快地处理内存管理。但是,不能仅根据仅在内存管理上花费的CPU时间来判断软件的成本。
ArT 2014年

2
@ ArTs:我什至不一定同意这一点。RAII要求对象在离开范围时必须销毁。然后,需要循环来进行n个对象销毁。在现代的一代GC下,这些销毁可以推迟到循环结束甚至更晚,然后只执行一个操作就销毁数百个值得记忆的迭代。在良好情况下,GC 可以非常非常快。
Phoshi 2014年

14

RAII也遵循自动引用计数内存管理,例如Perl所使用的。尽管引用计数很容易实现,确定性和相当好的性能,但它不能处理循环引用(它们会导致泄漏),这就是为什么它不常用的原因。

垃圾收集的语言不能直接使用RAII,但通常会提供具有同等效果的语法。在Java中,我们有try-with-source语句

try (BufferedReader br = new BufferedReader(new FileReader(path))) { ... }

.close()在块退出时自动调用资源。C#具有IDisposable接口,可以.Dispose()在离开using (...) { ... }语句时调用该接口。Python具有以下with语句:

with open(filename) as f:
    ...

以类似的方式工作。有趣的是,Ruby的文件打开方法获得了回调。执行回调后,将关闭文件。

File.open(name, mode) do |f|
    ...
end

我认为Node.js使用相同的策略。


4
使用高阶函数进行资源管理的历史可以追溯到Ruby之前。例如,在Lisps中,拥有with-open-filehandle打开文件,将其产生为函数并在函数返回时再次关闭文件的函数是很常见的。
约尔格W¯¯米塔格

4
循环引用参数很常见,但是它到底有多重要?如果所有权明确,则可以使用弱指针来缓解循环引用。
菲利普2014年

2
@Philipp当您使用裁判计数,所有权一般是不明确的。另外,此答案还讨论了专门和自动使用引用计数的语言,因此弱引用要么不存在,要么比强引用难使用。

3
@Philipp循环数据结构非常少见,除非您仍然使用复杂的图形。弱指针在一般循环对象图中没有帮助,尽管它们在更常见的情况下(例如树中的父指针)有帮助。一个好的解决方法是保留一个上下文对象,该对象代表对整个图形的引用,并管理销毁。刷新不是一个麻烦的问题,但是它要求程序员非常了解其限制。也就是说,它的认知成本略高于GC。
阿蒙2014年

1
为什么很少使用引用计数的一个重要原因是,尽管它很简单,但它通常比GC慢。
Rufflewind 2014年

14

在我看来,垃圾收集最令人信服的优点是它允许可组合性。内存管理的正确性是垃圾回收环境中的本地属性。您可以单独查看每个部分,并确定它是否会泄漏内存。组合任意数量的内存正确零件,它们将保持正确状态。

当您依靠引用计数时,您将失去该属性。您的应用程序是否可以泄漏内存成为整个应用程序的全局属性(具有引用计数)。零件之间的每个新交互都有可能使用错误的所有权并破坏内存管理。

它对使用不同语言的程序设计有非常明显的影响。用GC语言编写的程序往往是对象的交互作用更多的对象,而在用无GC语言编写的程序中,人们倾向于选择结构化的部件,它们之间具有严格控制和有限的交互作用。


1
仅当对象仅代表自己而不是代表这些引用的目标持有引用时,正确性才是可组合的。一旦有诸如通知之类的东西混在一起(Bob拥有对Joe的引用,因为当某件事发生时Joe要求Bob通知Bob,而Bob承诺这样做,但Bob并不关心Joe),GC正确性通常需要范围内的资源管理[由于GC系统缺乏C ++的自动化,因此在许多情况下可以手动执行]。
超级猫

@supercat:“ GC正确性通常需要范围内的资源管理”。??作用域仅存在于源代码中,而GC仅在运行时存在(因此,完全不考虑作用域的存在)。
乔恩·哈罗普

@JonHarrop:我使用术语“作用域”的含义与C ++“作用域指针”的含义相同(对象的生存期应该是保存它的容器的生存期),因为这是原始问题所隐含的用法。我的观点是,出于诸如接收事件之类的目的,对象可能创建对它们自己的潜在长期引用,而这在纯GC系统中可能是不可组合的。为了正确起见,某些引用必须是强引用,某些引用需要是弱引用,并且哪些引用必须是强引用,这将取决于如何使用对象。例如...
supercat

...假设只要某个目录中的任何内容被修改,对象Fred和Barney都会注册通知。弗雷德的处理程序不执行任何操作,只是增加一个计数器,该计数器可以根据请求报告其值,但没有其他用途。如果修改了某个文件,Barney的处理程序将弹出一个新窗口。为了正确起见,Fred应该订阅一个弱事件,而Barney应该订阅一个弱事件,但是timer对象将无法得知。
超级猫

@supercat:对。我不会说这种情况经常发生。我在编程的30年中只遇到过一次。
乔恩·哈罗普

7

闭包是几乎所有现代语言的基本功能。它们非常容易用GC来实现,很难(尽管不是不可能)来使用RAII,因为它们的主要特征之一是它们允许您在变量的整个生命周期中进行抽象!

C ++仅在其他所有人都获得40年后才得到它们,并且许多聪明人花了很多辛苦才能使它们正确。相反,许多由在设计和实现编程语言方面知识为零的人设计和实现的脚本语言拥有它们。


9
我认为C ++中的闭包不是一个很好的例子。C ++ 11中的lambda只是函子类的语法糖(显着早于C ++ 11),并且同样是内存不安全的:如果您通过引用捕获某些内容,并在该内容失效后调用闭包,您只需得到UB,就像持有参考的时间长于有效时间一样。它们迟到了40年是由于人们对FP的迟来认可,而不是因为弄不清如何使它们安全。尽管设计它们肯定是一项艰巨的任务,但我怀疑大多数努力都涉及到生命周期方面的考虑。

我同意delnan的观点:C ++没有正确地使用闭包:如果您不想在调用它们时获得核心转储,则必须非常仔细地编写它们。
Giorgio 2014年

2
@delnan:按引用捕获lambda非常有意使用该[&]语法。任何C ++程序员都已经将&符号与引用相关联,并且了解过时的引用。
MSalters 2014年

2
@MSalters你有什么意思?我自己绘制了参考连接。我并不是说C ++ lambda非常不安全,而是说它们和引用一样不安全。我并没有说C ++ lambda是不好的,我反对这个答案的主张(C ++闭包很晚了,因为他们必须弄清楚如何正确地做)。

5
  1. SBMM使程序更具确定性(您可以确切地说出销毁对象的时间);

对于大多数程序员而言,操作系统是不确定的,其内存分配器是不确定的,并且他们编写的大多数程序都是并发的,因此固有地是不确定的。对于绝大多数程序员而言,添加一个约束,要求在析构函数的确切位置而不是紧接在范围的末尾调用析构函数并不是一个重大的实际好处。

  1. 在使用GC的语言中,您通常必须执行手动资源管理(例如,参见关闭Java中的文件),这在一定程度上违背了GC的目的,并且也容易出错。

参见usingC#和useF#。

  1. 堆内存也可以(很优雅地称为imo)是作用域绑定的(请参阅C ++中的std :: shared_ptr)。

换句话说,您可以采用作为通用解决方案的堆并将其更改为仅在严重限制的特定情况下才能使用。当然是这样,但是没有用。

为什么SBMM没有得到更广泛的使用?它有什么缺点?

SBMM限制了您可以执行的操作:

  1. SBMM 使用一流的词法闭包创建了向上的funarg问题,这就是为什么闭包在C#等语言中很流行并且易于使用,而在C ++中却很少且棘手的原因。请注意,在编程中普遍存在使用功能构造的趋势。

  2. SBMM需要析构函数,并且它们通过在函数返回之前增加更多工作来阻止尾部调用。尾部调用对于可扩展状态机很有用,并且由.NET等提供。

  3. 众所周知,某些数据结构和算法很难使用SBMM来实现。基本上任何自然发生周期的地方。最值得注意的是图算法。您实际上可以最终编写自己的GC。

  4. 并发编程比较困难,因为此处的控制流和对象生存期本质上是不确定的。消息传递系统中的实际解决方案往往是对消息进行深度复制以及使用过长的生命周期。

  5. SBMM使对象保持活动状态,直到它们在源代码中的作用域结束为止,这通常比必要的时间更长,并且可以远远超过必要的时间。这增加了浮动垃圾(等待回收的无法到达的对象)的数量。相反,跟踪垃圾回收趋向于在最后一次引用对象消失之后不久释放对象,这可能会更快。请参阅内存管理神话:及时性

SBMM的局限性如此之大,以至于程序员在无法进行生命周期嵌套的情况下需要一条逃生路线。在C ++中,shared_ptr提供了转义路径,但是它比跟踪垃圾回收慢约10倍。因此,使用SBMM而不是GC会使大多数人在大多数情况下处于错误的境地。但这并不是说它没有用。在资源有限的系统和嵌入式编程的背景下,SBMM仍然很有价值。

FWIW您可能想看看Forth和Ada,并继续阅读Nicolas Wirth的工作。


1
如果您说些什么,我也许可以阐述或引用文章。
乔恩·哈罗普

2
与在所有用例中无所不在相比,在少数几种用例中,慢10倍有多重要?C ++具有unique_ptr,在大多数情况下,它已经足够。紧接着,如果不打算攻击RAII槽C ++(一种很多人都讨厌作为一种古老的语言而讨厌的语言),那么如果您想攻击RAII槽来攻击一种语言,请尝试使用RAII家族的一个较年轻的兄弟姐妹,例如Rust。Rust基本上可以解决C ++出错的所有问题,同时也可以解决大多数C ++出错的问题。进一步的“使用”会给您带来非常有限的用例集,并且会忽略组合。
user1703394 2015年

2
“在一些罕见的用例中,慢速10倍而不是在所有用例中无所不在是多么相关?” 首先,这是一个循环参数:shared_ptr仅在C ++中很少见,因为它是如此缓慢。其次,这是苹果和橙子的比较(正如我引用的文章已经显示的那样),因为shared_ptr它比生产GC慢许多倍。第三,GC不存在,在诸如LMax和Rapid Addition的FIX引擎之类的软件中应避免使用。
乔恩·哈罗普

1
@乔恩·哈罗普(Jon Harrop),如果您不愿意,请启迪我。您使用了30多年的低谷期的哪些神奇配方来减轻使用深层资源的传递性影响?30多年后,如果没有这样一个神奇的食谱,我只能得出结论:您一定被误咬归因于其他原因。
user1703394 2015年

1
@Jon Harrop,shared_ptr并不罕见,因为它运行缓慢,它之所以罕见,是因为在设计合理的系统中,很少需要“共享所有权”。
user1703394

4

查看诸如TIOBE(当然可以争论,但是我想您可以使用它的问题)这种流行指数,您首先会发现前20名中约有50%是“脚本语言”或“ SQL方言” ”,其中“易用性”和抽象手段比确定性行为更为重要。在其余的“已编译”语言中,大约有50%的具有SBMM的语言和约50%的没有SBMM的语言。因此,当从计算中删除脚本语言时,我会说您的假设是错误的,在已编译的语言中,具有SBMM的语言与没有SBMM的语言一样受欢迎。


1
“易用性”与确定性有何不同?确定性语言不应该被认为比非确定性语言更容易使用吗?
菲利普2014年

2
@Philipp只有确定性的或实际上不重要的东西。对象生存时间本身并不重要(尽管C ++和朋友将许多与对象生存时间相关的事情联系在一起,因为它们可以)。当一个可达对象被释放并不重要,因为根据定义,你不使用它了。

另外,诸如Perl和Python之类的各种“脚本语言”也使用引用计数作为内存管理的主要手段。
菲利普2014年

1
@Philipp至少在Python世界中,这被视为CPython的实现细节,而不是语言的属性(并且几乎所有其他实现都避免引用)。此外,我认为使用备用循环GC的所有未选择退出参考计数都不符合SBMM或RAII的条件。实际上,您很难找到认为这种内存管理风格可与RAII媲美的RAII支持者(主要是因为事实并非如此,任何地方的循环都会阻止程序中其他任何地方的立即释放)。

3

尚未有人提及的GC系统的主要优点之一是,只要存在引用就可以保证保留其身份。如果在存在引用副本的情况下在对象上调用IDisposable.Dispose(.NET)或AutoCloseable.Close(Java),则这些副本将继续引用同一对象。该对象将不再有用,但是尝试使用该对象将具有可预测的行为,该行为由对象本身控制。相比之下,在C ++中,如果代码调用delete一个对象,然后又尝试使用它,则系统的整个状态将变得完全不确定。

还要注意的另一件事是,基于范围的内存管理对于拥有明确定义的所有权的对象非常有效。对于没有定义所有权的对象,它的效果不佳,有时甚至很糟糕。通常,可变对象应该具有所有者,而不可变对象则不需要所有者,但存在一个折衷:代码通过使用可变类型的实例来保存不可变数据是很常见的,这是通过确保不会暴露任何引用来实现的。可能会使实例变异的代码。在这种情况下,可变类的实例可能在多个不可变对象之间共享,因此没有明确的所有权。


4
您在上半年中提到的属性是内存安全性。尽管GC是实现内存安全的一种非常简单的方法,但GC并不是必需的:在Rust上做一个很好的例子。

@delnan:当我查看rust-lang.org时,我的浏览器似乎无法从那里导航。我应该在哪里寻找更多信息?我的印象是,没有GC的内存安全性对数据结构施加了某些限制,这些限制可能无法很好地满足应用程序可能需要做的所有事情,但是我很乐意被证明是错误的。
2014年

1
我不知道有一个(或什至少量)好的参考资料。我的Rust知识已经在阅读邮件列表(以及邮件中链接的所有内容,包括各种教程,语言设计博客文章,github问题,ThisWeekInRust等)上积累了一两年了。简要说明一下您的印象:是的,每个安全构造(必须)都施加了限制,但是对于几乎任何内存安全的代码段,都存在或可以编写适当的安全构造。到目前为止,最常见的语言和语言已经存在于stdlib中,所有其他语言都可以用用户代码编写。

@delnan:Rust是否需要对引用计数器进行互锁更新,或者它具有其他一些方法来处理没有明确所有权的不可变对象(或不可变地包装的可变对象)?Rust是否具有“对象拥有”和“非拥有”指针的概念?我回想起一篇关于“ Xonor”指针的论文,该论文讨论了对象具有“拥有”它们的单个引用以及其他不拥有它们的引用的想法。当“所有者”引用超出范围时,所有非所有者引用都将成为对死对象的引用,并且可以这样标识...
supercat

1
我认为Stack Exchange评论不是通过语言进行导览的正确媒介。如果您仍然感兴趣,可以直接转到源(#rust IRC,rust-dev邮件列表等)和/或在聊天室打我(您应该可以创建一个聊天室)。

-2

首先,认识到将RAII等同于SBMM非常重要。甚至是SBRM。RAII的最基本(也是鲜为人知的或最受赞赏的)质量之一是,它使“成为一种资源”成为一种不影响合成的特性。

以下博客文章讨论了RAII的这一重要方面,并将其与使用非确定性GC的GC语言中的资源分配进行了对比。

http://minorfs.wordpress.com/2011/04/29/why-garbage-collection-is-anti-productive/

需要注意的重要一点是,尽管RAII主要用在C ++中,但是Python(最后是非基于VM的版本)具有析构函数和确定性GC,可以将RAII与GC一起使用。如果是的话,两全其美。


1
-1那是我读过的最糟糕的文章之一。
乔恩·哈罗普

1
语言的问题不是它们支持GC,而是它们放弃了RAII。没有理由语言/框架不能同时支持这两种语言。
超级猫

1
@乔恩·哈罗普(Jon Harrop),请您详细说明。在文章中提出的主张中,前三项主张中是否甚至没有一个成立?我认为您可能不同意生产率方面的主张,但其他3项主张绝对有效。最重要的是,第一个关于作为资源的可传递性。
user1703394 2015年

2
@ user1703394:首先,整篇文章都是基于一个“ GCed语言”的稻草人,而实际上,它与垃圾回收没有任何关系。其次,当错误实际上在于面向对象的编程时,他归咎于垃圾回收。最后,他的论证为时10年,为时已晚。绝大多数程序员已经现代化了垃圾收集语言,这恰恰是因为它们提供了更高的生产率。
乔恩·哈罗普

1
他的具体示例(RAM,打开文件句柄,锁,线程)非常有说服力。我很难回想起我上一次不得不编写直接处理这些代码的代码。借助RAM,GC可自动执行所有操作。使用文件句柄,我可以编写代码,就像File.ReadLines file |> Seq.length抽象句柄对我来说关闭一样。我已用.NET Task和F#替换的锁和线程MailboxProcessor。这整个“我们分解了手动资源管理的数量”简直是胡说八道。
乔恩·哈罗普
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.