为什么没有引用计数+垃圾收集在C#中?

我来自C ++背景,我已经使用C#大约一年。 像许多其他人一样,为了确定性的资源pipe理为什么没有内置于语言中,我却不知所措。 而不是确定性的析构函数,我们有处理模式。 人们开始怀疑是否通过他们的代码传播一次性使用癌症是值得的。

在我的C ++偏见的大脑中,似乎使用引用计数智能指针与确定性析构函数是从垃圾回收器,要求您实现IDisposable和调用处置来清理您的非内存资源的重大一步。 诚然,我不是很聪明…所以我纯粹是从一个渴望更好地理解为什么事情是这样的欲望这个问。

如果C#被修改,那么:

对象是引用计数。 当对象的引用计数变为零时,将在对象上确定性地调用资源清理方法,然后将该对象标记为垃圾回收。 垃圾收集发生在未来的某个非确定性时间,回收内存。 在这种情况下,您不必实现IDisposable或记得调用Dispose。 如果要释放非内存资源,只需实施资源清理function即可。

  • 为什么这是一个坏主意?
  • 这会打败垃圾收集器的目的吗?
  • 实施这样的事情是否可行?

编辑:从目前为止,这是一个糟糕的主意,因为

  1. 无需参考计数,GC更快
  2. 在对象图中处理周期的问题

我认为第一是有效的,但是第二很容易处理使用弱引用。

那么速度优化大于你的缺点:

  1. 不能及时释放非内存资源
  2. 可能会很快释放一个非内存资源

如果你的资源清理机制是确定性的,并且内置在语言中,你可以消除这些可能性。

Brad Abrams发布了一个来自Brian Harry在.Net框架开发过程中编写的电子邮件 。 它详细介绍了许多没有使用引用计数的原因,即使早期的优先事项之一是与使用引用计数的VB6保持语义等价。 它研究了可能性,比如有一些typesref被计数而不是其他types( IRefCounted !),或者具体实例被计数,以及为什么这些解决scheme都不被认为是可接受的。

因为[资源pipe理和确定性定稿问题]是一个非常敏感的话题,所以我会尽量在我的解释中做到精确和完整。 我对邮件的长度表示抱歉。 这封邮件的前90%试图说服你,这个问题真的很难。 在最后一部分,我将讨论我们正在尝试做的事情,但是您需要第一部分来理解为什么我们要看这些选项。

我们最初开始时假定解决scheme将采用自动参考计数的forms (所以程序员不能忘记)加上一些其他的东西来自动检测和处理周期。 我们最终得出的结论是,这在一般情况下不起作用。

综上所述:

  • 我们觉得解决循环问题非常重要,不要强迫程序员理解,追踪和devise这些复杂的数据结构问题。
  • 我们要确保我们有一个高性能(速度和工作集)系统,我们的分析表明, 对系统中的每一个对象使用引用计数都不会使我们达到这个目标
  • 由于各种各样的原因,包括构成和演员的问题, 没有简单的透明的解决办法,只有那些需要它的对象被统计
  • 我们select不select为单一语言/上下文提供确定性定稿的解决scheme, 因为它禁止与其他语言互操作 ,并通过创build语言特定版本来引起类库的分叉。

垃圾收集器不要求您为每个定义的类/types编写一个Dispose方法。 你只需要定义一个,当你需要明确地做一些清理; 当你有明确的分配本地资源。 大多数情况下,GC只是回收内存,即使你只是做一些像new()这样的对象。

GC确实引用了计数 – 但是它通过每次执行集合时发现哪些对象“可达”( Ref Count > 0 )以不同的方式进行Ref Count > 0 ……它只是不以整数计数器方式执行。 。 无法到达的对象被收集( Ref Count = 0 )。 这样,运行时无需每次对象分配或释放时都需要执行pipe家/更新表…应该更快。

C ++(确定性)和C#(非确定性)之间唯一的主要区别是对象将被清理。 您无法预测在C#中收集对象的确切时间。

Umpteenth plug:我build议阅读Jeffrey Richter关于CLR中 GC的立法章节, 通过C#来说明 ,如果你真的对GC的工作感兴趣的话。

在C#中尝试引用计数。 我相信,发布了Rotor(一个CLR的参考实现,其源代码可用)的人们引用了基于计数的GC,只是为了看看它如何与世代相提并论。 结果令人惊讶 – “股票”GC是如此之快,它甚至不好笑。 我不记得在哪里听到这个,我认为这是Hanselmuntes播客之一。 如果你想看到C ++基本上与C#的性能比较 – 谷歌Raymond陈的中文字典应用程序崩溃。 他做了一个C ++版本,然后Rico Mariani做了一个C#版本。 我认为Raymond 6次迭代终于击败了C#版本,但那时他不得不放弃C ++的所有优秀的面向对象,进入win32 API级别。 整个事情变成了性能黑客。 C#程序在同一时间只进行了一次优化,最后仍然看起来像一个体面的面向对象项目

C ++风格的智能指针引用计数和引用计数垃圾收集之间是有区别的。 我还谈到了我博客上的差异,但是这里有一个简短的总结:

C ++风格参考计数:

  • 减less的无限成本:如果大数据结构的根减less到零,则释放所有数据的成本是无限的。

  • 手动循环收集:为防止循环数据结构泄漏内存,程序员必须用弱智能指针replace部分循环来手动中断任何潜在的结构。 这是潜在缺陷的另一个来源。

引用计数垃圾收集

  • 延期的RC:堆栈和寄存器引用忽略对对象引用计数的更改。 而当触发GC时,通过收集根集来保留这些对象。 引用计数的更改可以推迟并批量处理。 这导致更高的吞吐量 。

  • 合并:使用写入屏障,可以合并对引用计数的更改。 这样就可以忽略大多数对引用次数的改变,从而提高了频繁变异引用的RC性能。

  • 周期检测:对于完整的GC实现,还必须使用周期检测器。 但是可以以增量方式执行循环检测,这又意味着有限的GC时间。

基本上可以实现基于高性能RC的垃圾收集器,用于诸如Java的JVM和.net CLR运行时等运行时。

由于历史的原因,我认为跟踪收集器部分被使用:引用计数最近的许多改进都是在JVM和.net运行时间都被释放之后发生的。 研究工作也需要时间过渡到生产项目。

确定性的资源处置

这几乎是一个单独的问题。 .net运行时可以使用IDisposable接口,如下所示。 我也喜欢Gishu的回答。


@Skrymsli ,这是“ 使用 ”关键字的目的。 例如:

 public abstract class BaseCriticalResource : IDiposable { ~ BaseCriticalResource () { Dispose(false); } public void Dispose() { Dispose(true); GC.SuppressFinalize(this); // No need to call finalizer now } protected virtual void Dispose(bool disposing) { } } 

然后添加一个关键资源类:

 public class ComFileCritical : BaseCriticalResource { private IntPtr nativeResource; protected override Dispose(bool disposing) { // free native resources if there are any. if (nativeResource != IntPtr.Zero) { ComCallToFreeUnmangedPointer(nativeResource); nativeResource = IntPtr.Zero; } } } 

那么使用它就像这样简单:

 using (ComFileCritical fileResource = new ComFileCritical()) { // Some actions on fileResource } // fileResource's critical resources freed at this point 

另请参阅正确实施IDisposable 。

我知道垃圾收集的一些东西。 这是一个简短的总结,因为完整的解释超出了这个问题的范围。

.NET使用复制和压缩世代垃圾回收器。 这比引用计数更为先进,并且具有能够直接或通过链收集引用自身的对象的益处。

引用计数不会收集周期。 引用计数也有一个较低的吞吐量(总体较慢),但有一个更快的暂停(最大暂停较小)比跟踪collections家的好处。

我来自C ++背景,我已经使用C#约一年。 像许多其他人一样,为了确定性的资源pipe理为什么没有内置于语言中,我却不知所措。

using构造提供“确定性”的资源pipe理,并被构build到C#语言中。 请注意,“确定性”是指在using块开始执行之前的代码之前,保证已经调用了Dispose 。 还要注意,这不是“确定性”这个词的意思,但似乎每个人都以这种方式滥用了这个词,这很糟糕。

在我的C ++偏见的大脑中,似乎使用引用计数智能指针与确定性析构函数是从垃圾回收器,要求您实现IDisposable和调用处理来清理您的非内存资源的重大一步。

垃圾收集器不要求你实现IDisposable 。 确实,GC完全没有意识到这一点。

诚然,我不是很聪明…所以我纯粹是从一个渴望更好地理解为什么事情是这样的欲望这个问。

跟踪垃圾回收是模拟无限存储器的快速可靠的方法,使程序员免于手动内存pipe理的负担。 这消除了几类错误(摇晃指针,免费太快,双倍免费,忘了免费)。

如果C#被修改,那么:

对象是引用计数。 当对象的引用计数变为零时,资源清理方法在对象上被确定性地调用,

考虑两个线程之间共享的对象。 线程竞争将引用计数递减到零。 一个线程将赢得比赛,另一个将负责清理。 这是非确定性的。 引用计数本质上是确定性的信念是一个神话。

另一个常见的误解是引用计数在程序中尽可能早地释放对象。 它不。 总是推迟递减,通常是递减的范围。 这使得物体保持活动的时间长于所需的时间,留下所谓的“浮动垃圾”。 请注意,特别是,一些跟踪垃圾收集器可以并且比基于范围的引用计数实现更早地执行回收对象。

那么该对象被标记为垃圾收集。 垃圾收集发生在未来的某个非确定性时间,回收内存。 在这种情况下,您不必实现IDisposable或记得调用Dispose。

无论如何你都不必为垃圾收集对象实现IDisposable ,所以这是一个非利益。

如果要释放非内存资源,只需实施资源清理function即可。

为什么这是一个坏主意?

天真的引用计数是非常缓慢和泄漏周期。 例如, C ++中的Boost的shared_ptr比OCaml的跟踪GC慢10倍 。 即使是天真的基于范围的引用计数在multithreading程序(这几乎是所有现代程序)的存在下也是非确定性的。

这会打败垃圾收集器的目的吗?

一点也不,不。 事实上,这是一个在20世纪60年代发明的坏主意,并且在接下来的54年里经过了深入的学术研究,认为在一般情况下,参考数字很糟糕。

实施这样的事情是否可行?

绝对。 早期的原型.NET和JVM使用引用计数。 他们也发现,它吸引和放弃了有利于追踪GC。

编辑:从目前为止,这是一个糟糕的主意,因为

无需参考计数,GC更快

是。 请注意,通过推迟计数器增量和递减,可以使引用计数更快,但是牺牲了您非常需要的确定性,并且仍然比使用今天的堆大小来跟踪GC要慢。 然而,引用计数是渐近较快的,所以在未来的某个时刻,当堆变得非常大时,我们可能会开始在生产自动化内存pipe理解决scheme中使用RC。

在对象图中处理周期的问题

试验删除是专门devise用于在参考计数系统中检测和收集周期的algorithm。 然而,这是缓慢的和不确定的。

我认为第一是有效的,但是第二很容易处理使用弱引用。

把弱指称为“容易”是对现实的希望的胜利。 他们是一场噩梦。 它们不但难以预测,而且难以架构,但会污染API。

那么速度优化大于你的缺点:

不能及时释放非内存资源

不及时using免费的非内存资源?

可能会很快释放非内存资源如果您的资源清理机制是确定性的并且内置于该语言中,则可以消除这些可能性。

using构造是确定性的,并被构build到语言中。

我想你真正想问的问题是为什么不IDisposable使用引用计数。 我的回答是轶事:我已经使用垃圾收集语言18年,我从来没有需要诉诸参考计数。 因此,我更喜欢比较简单的API,这些API没有像弱引用那样偶然的复杂性。

这里有很多问题。 首先,您需要区分释放托pipe内存和清理其他资源。 前者可以非常快,而后者可能非常慢。 在.NET中,两者是分开的,这可以更快地清理托pipe内存。 这也意味着,只有当您拥有超出托pipe内存的内容才能清理时,才应该执行Dispose / Finalizer。

.NET使用标记和扫描技术,遍历堆寻找对象。 Rooted实例在垃圾收集中存活。 其他的东西都可以通过回收内存来清理。 GC必须时不时地压缩内存,但除了回收内存是一个简单的指针操作,即使回收多个实例。 将它与C ++中多个对析构函数的调用进行比较。

实现IDisposable的对象还必须实现GC调用的终结器,当用户不显式调用Dispose时 – 请参阅MSDN上的IDisposable.Dispose 。

IDisposable的重点在于GC在某些非确定性时间运行,并且实现了IDisposable,因为您拥有一个有价值的资源,并希望在确定的时间释放它。

所以你的build议不会改变IDisposable。

编辑:

抱歉。 没有正确阅读您的build议。 🙁

维基百科有一个简单的解释引用计数GC的缺点

引用计数

使用引用计数的成本是双重的:首先,每个对象都需要特殊引用计数字段。 通常,这意味着必须在每个对象中分配额外的存储字。 其次,每次将一个参考分配给另一个参考时,必须调整参考计数。 这大大增加了任务报表所花费的时间。

.NET中的垃圾回收

C#不使用对象的引用计数。 而是维护一个来自堆栈的对象引用的graphics,并从根目录导航来覆盖所有引用的对象。 图中所有引用的对象在堆中压缩,以便为将来的对象提供连续的内存。 所有不需要完成的未引用对象的内存都被回收。 那些未被引用但是有终结器在其上执行的被移动到一个单独的队列中,称为f-reachable队列,垃圾收集器在后台调用它们的终结器。

除了上面的GC,还使用了世代的概念来实现更高效的垃圾收集。 它基于以下概念:1.对托pipe堆的一部分内存进行压缩比对整个托pipe堆的压缩速度要快。2.较新的对象的寿命较短,较旧的对象的寿命较长。3.较新的对象倾向于彼此相关,并在同一时间由应用程序访问

托pipe堆分为三代:0,1和2.新的对象存储在第0代。不被GC循环回收的对象被提升到下一代。 因此,如果在第0代的新物体在GC第1周期中存活,则它们被提升为第1代。那些在GC周期2中幸存的物体被提升为第2代。由于垃圾收集器仅支持三代,所以第二代中的物体直到他们被确定为在未来的collections中无法获得的第二代collections仍然存在。

当第0代已满并且需要分配新对象的内存时,垃圾回收器执行一个集合。 如果第0代的集合没有回收足够的内存,则垃圾回收器可以执行第1代的集合,然后执行第0代。如果这不能回收足够的内存,则垃圾回收器可以执行第2代,第1代和第0代的集合。

因此,GC比参考计数更有效。

确定性的非内存资源pipe理是该语言的一部分,然而它不是用析构函数完成的。

您的意见在来自C ++背景的人士中很常见,试图使用RAIIdevise模式。 在C ++中,保证某些代码在某个作用域内运行的唯一方法,即使引发了一个exeption,也是在栈上分配一个对象,并将清理代码放在析构函数中。

在其他语言(C#,Java,Python,Ruby,Erlang等)中,您可以使用try-finally(或try-catch-finally)来确保清理代码始终运行。

 // Initialize some resource. try { // Use the resource. } finally { // Clean-up. // This code will always run, whether there was an exception or not. } 

IC#,你也可以使用使用构造:

 using (Foo foo = new Foo()) { // Do something with foo. } // foo.Dispose() will be called afterwards, even if there // was an exception. 

因此,对于一个C ++程序员来说,将“运行清理代码”和“释放内存”视为两个独立的东西可能会有所帮助。 把你的清理代码放在一个finally块中,留给GC去处理内存。