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

时间:2009-05-15 05:40:47

标签: c# garbage-collection reference-counting

我来自C ++背景,我和C#一起工作了大约一年。像许多其他人一样,我对于为什么确定性资源管理不是内置于语言中而感到困惑。我们没有确定性的析构函数,而是具有处置模式。 People start to wonder是否通过他们的代码传播IDisposable癌症是值得的。

在我的C ++偏见的大脑中,似乎使用具有确定性析构函数的引用计数智能指针是垃圾收集器的一个主要步骤,它需要您实现IDisposable并调用dispose来清理非内存资源。不可否认,我不是很聪明......所以我纯粹是想要更好地理解为什么事情就是这样。

如果C#被修改为:

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

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

编辑: 从目前为止的评论来看,这是个坏主意,因为

  1. 没有引用计数的GC速度更快
  2. 处理对象图中的循环的问题
  3. 我认为第一名是有效的,但使用弱引用很容易处理第二名。

    速度优化也超过你的缺点:

    1. 可能无法及时释放非内存资源
    2. 可能会过早释放非内存资源
    3. 如果您的资源清理机制具有确定性并且内置于该语言中,则可以消除这些可能性。

10 个答案:

答案 0 :(得分:49)

Brad Abrams在开发.Net框架期间发表了an e-mail from Brian Harry。它详细说明了未使用引用计数的许多原因,即使其中一个早期优先事项是使用引用计数的VB6保持语义等效。它研究了一些可能性,例如将某些类型引用计数而不是其他类型(IRefCounted!),或者重新计算特定实例,以及为什么这些解决方案都不被认为是可接受的。

  

因为[资源问题   管理和确定性   最终确定]就是这样一个   我要尝试的敏感话题   在我的精神和完整   尽我所能解释。我为。。。道歉   邮件的长度。前90%   这封邮件试图说服你   问题真的很难。在   最后一部分,我会谈论事情   我们正在努力,但你需要   第一部分,了解我们为什么   看看这些选项。

     

...

     

我们最初是以   假设解决方案   采取自动参考的形式   计数(所以程序员不能   忘了)加上其他一些东西   检测和处理周期   自动。 ......我们最终得出结论   这不会起作用   一般情况。

     

...

     

总结:

     
      
  • 我们认为解决循环问题非常重要   而不是强迫程序员   理解,追踪和设计   围绕这些复杂的数据结构   问题。
  •   
  • 我们希望确保我们拥有高性能(速度和速度)   工作集)系统和我们的分析   显示使用引用计数   对于系统中的每个对象   不会允许我们实现这一目标   目标即可。
  •   
  • 由于各种原因,包括成分和铸造   问题,有没有简单透明   只有那些对象的解决方案   需要重新计算
  •   
  • 我们选择不选择提供确定性的解决方案   完成一个单一的   语言/语境,因为它抑制   互操作与其他语言和   导致类库的分叉   通过创建特定语言   版本
  •   

答案 1 :(得分:30)

垃圾收集器不要求您为您定义的每个类/类型编写Dispose方法。您只需在需要明确执行清理操作时定义一个;当您明确分配本机资源时。大多数情况下,即使您只对对象执行new()操作,GC也会回收内存。

GC确实引用了计数 - 但是它通过查找哪些对象“可达”(Ref Count > 0每次进行收集时以不同的方式进行计数 ...它只是不是以整数计数器方式做的。 。收集无法访问的对象(Ref Count = 0)。这样,每次分配或释放对象时,运行时不必执行内务处理/更新表...应该更快。

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

无数插件:我建议您阅读Jeffrey Richter在CLR via C#中关于GC的立场章节,以防您对GC的工作方式感兴趣。

答案 2 :(得分:21)

在C#中尝试了引用计数。我相信,那些发布了Rotor(CLR的参考实现,其中有源可用)的人确实参考了基于计数的GC,只是为了看看它与世代相比如何。结果令人惊讶 - “股票”GC的速度要快得多,甚至都不好笑。我不记得我听到的确切位置,我认为这是Hanselmuntes的一个播客。如果你想看到C ++在与C#的性能比较中得到了基本压力 - 谷歌Raymond Chen的中文词典应用程序。他做了一个C ++版本,然后Rico Mariani做了一个C#。我认为Raymond 6迭代最终击败了C#版本,但到那时他不得不放弃C ++的所有漂亮的面向对象,并进入win32 API级别。整个事情变成了性能黑客。同时,C#程序只进行了一次优化,最后仍然看起来像是一个体面的OO项目

答案 3 :(得分:14)

C ++样式智能指针引用计数和引用计数垃圾回收之间存在差异。我还谈到了my blog的差异,但这里有一个简短的总结:

C ++样式引用计数:

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

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

参考计数垃圾收集

  • 延迟RC:对于堆栈和寄存器引用,将忽略对象引用计数的更改。相反,当触发GC时,通过收集根集来保留这些对象。 可以推迟并批量处理对引用计数的更改。这会产生higher throughput

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

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

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

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

确定性资源处置

这几乎是一个单独的问题。 .net运行时使用IDisposable接口实现了这一点,例如下面的例子。我也喜欢Gishu's回答。


@Skrymsli,这是“using”关键字的目的。 E.g:

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

另见implementing IDisposable correctly

答案 4 :(得分:6)

  

我来自C ++背景,我已经和C#合作了大约一年。像许多其他人一样,我对于为什么确定性资源管理不是内置于语言中而感到沮丧。

using构造提供了确定性的"资源管理并内置于C#语言中。请注意,通过" deterministic"我的意思是在Dispose块开始执行之后,保证在代码之前调用using。还要注意,这不是单词" deterministic"意思是但每个人似乎都在这种情况下滥用它,这很糟糕。

  

在我的C ++偏见的大脑中,似乎使用具有确定性析构函数的引用计数智能指针是垃圾收集器的一个重要步骤,它需要您实现IDisposable并调用dispose来清理非内存资源。

垃圾收集器不需要您实现IDisposable。实际上,GC完全没有注意到它。

  

不可否认,我并不是很聪明......所以我纯粹是想要更好地理解为什么事情就像他们一样。

跟踪垃圾收集是一种快速可靠的模拟无限内存机器的方法,使程序员免于手动内存管理的负担。这消除了几类错误(悬空指针,过早免费,双重免费,忘记免费)。

  

如果C#被修改为:

     

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

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

另一个常见的误解是引用计数可以在程序中尽可能早地释放对象。它没有。递减总是推迟,通常到范围的末尾。这样可以使对象保持活动的时间超过必要的时间,留下所谓的“浮动垃圾”#34;躺在身边。请注意,特别是,某些跟踪垃圾收集器可以比基于范围的引用计数实现更早地回收对象。

  

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

无论如何,您不必为垃圾收集对象实施IDisposable,因此这是无益的。

  

如果要释放非内存资源,则只需实现资源清理功能。

     

为什么这是一个坏主意?

天真的引用计数非常慢并且会导致周期性泄漏。例如,Boost's shared_ptr in C++ is up to 10x slower than OCaml's tracing GC。在存在多线程程序(几乎所有现代程序)的情况下,即使是天真的基于范围的引用计数也是不确定的。

  

这会破坏垃圾收集器的目的吗?

完全没有,没有。事实上,这是一个坏主意,是在20世纪60年代发明的,并在接下来的54年中接受了强烈的学术研究,得出的结论是,在一般情况下,引用计数很糟糕。

  

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

绝对。早期原型.NET和JVM使用引用计数。他们还发现它被吸引并丢弃它,有利于追踪GC。

  

编辑:从目前为止的评论来看,这是个坏主意,因为

     

GC没有引用计数就更快

是。请注意,通过延迟计数器增量和减量可以使引用计数更快,但这会牺牲您非常渴望的确定性,并且仍然比使用当前堆大小跟踪GC更慢。然而,引用计数渐近更快,所以在未来的某些时候,当堆积变得非常大时,我们将开始在生产自动化内存管理解决方案中使用RC。

  

在对象图中处理循环的问题

试删除是一种专门用于检测和收集引用计数系统中的循环的算法。然而,它是缓慢且不确定的。

  

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

调用弱引用" easy"是希望胜过现实的胜利。他们是一场噩梦。它们不仅难以预测且难以构建,而且还污染了API。

  

速度优化也超过你的缺点:

     

可能无法及时释放非内存资源

没有及using及时释放非内存资源吗?

  

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

using构造是确定性的并且内置于语言中。

我认为你真正想问的问题是为什么不IDisposable使用引用计数。我的回答是轶事:我已经使用垃圾收集语言18年了,我从来不需要求助于引用计数。因此,我更喜欢简单的API,这些API没有像弱引用这样的偶然复杂性而被污染。

答案 5 :(得分:5)

我对垃圾收集有所了解。这是一个简短的总结,因为完整的解释超出了这个问题的范围。

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

引用计数不会收集周期。引用计数也具有较低的吞吐量(总体较慢),但具有更快的暂停(最大暂停较小)的优势,而不是跟踪收集器。

答案 6 :(得分:4)

这里有很多问题在起作用。首先,您需要区分释放托管内存和清理其他资源。前者可能非常快,而后者可能非常慢。在.NET中,两者是分开的,这样可以更快地清理托管内存。这也意味着,当你有超出托管内存的东西要清理时,你应该只实现Dispose / Finalizer。

.NET采用标记和扫描技术,遍历堆查找对象的根。 Rooted实例在垃圾收集中存活。只需回收内存即可清除其他所有内容。 GC必须时不时地压缩存储器,但除了回收存储器之外,即使在回收多个实例时也是一个简单的指针操作。将其与C ++中对析构函数的多次调用进行比较。

答案 7 :(得分:1)

当用户没有显式调用Dispose时,实现IDisposable的对象还必须实现GC调用的终结器 - 请参阅IDisposable.Dispose at MSDN

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

因此,您的提案在IDisposable方面不会改变任何内容。

编辑:

对不起。没有正确阅读您的提案。 : - (

维基百科对shortcomings of References counted GC

有一个简单的解释

答案 8 :(得分:1)

参考计数

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

.NET中的垃圾收集

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

除了上面的GC之外,还使用了几代的概念来实现更高效的垃圾收集。它基于以下概念 1.为托管堆的一部分压缩内存比整个托管堆更快 2.较新的物体寿命较短,较旧的物体寿命较长 3.较新的对象往往彼此相关,并且应用程序大约在同一时间访问

托管堆分为三代:0,1和2.新对象存储在gen 0中。未被GC循环回收的对象将被提升为下一代。因此,如果gen 0中的较新对象在GC循环1中存活,那么它们将被提升为gen 1.在GC循环2中存活的那些将被提升为gen 2.因为垃圾收集器仅支持三代,所以第2代中的对象继续存在于第2代,直到他们确定在未来的集合中无法访问。

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

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

答案 9 :(得分:1)

确定性非内存资源管理是该语言的一部分,但不是使用析构函数。

您的观点在来自C ++背景的人中很常见,尝试使用RAII设计模式。在C ++中,保证某些代码将在作用域结束时运行的唯一方法是,即使抛出一个异常,也就是在堆栈上分配一个对象并将清理代码放在析构函数中。

在其他语言(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.
}

I C#,您也可以使用using构造:

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

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