64

我来自 C++ 背景,我已经使用 C# 工作了大约一年。像许多其他人一样,我对为什么确定性资源管理没有内置在语言中感到困惑。我们有 dispose 模式,而不是确定性析构函数。人们开始怀疑通过他们的代码传播 IDisposable 癌症是否值得付出努力。

在我偏向 C++ 的大脑中,使用带有确定性析构函数的引用计数智能指针似乎是垃圾收集器的一大进步,垃圾收集器需要你实现 IDisposable 并调用 dispose 来清理你的非内存资源。诚然,我不是很聪明......所以我问这个纯粹是为了更好地理解为什么事情会这样。

如果 C# 被修改为:

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

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

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

  1. 没有引用计数的 GC 更快
  2. 处理对象图中的循环问题

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

速度优化是否超过了您的缺点:

  1. 可能无法及时释放非内存资源
  2. 可能过早释放非内存资源

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

4

10 回答 10

54

Brad Abrams 发布了 Brian Harry在 .Net 框架开发期间写的一封电子邮件。它详细说明了未使用引用计数的许多原因,即使早期的优先事项之一是保持与使用引用计数的 VB6 的语义等价。它研究了一些可能性,例如对某些类型进行引用而不是对其他类型进行计数(IRefCounted!),或者对特定实例进行引用计数,以及为什么这些解决方案都不被认为是可接受的。

因为[资源管理和确定性最终确定的问题]是一个如此敏感的话题,我将尽量在我的解释中做到准确和完整。我为邮件的长度道歉。这封邮件的前 90% 是试图让你相信这个问题确实很困难。在最后一部分中,我将讨论我们正在尝试做的事情,但您需要在第一部分了解我们为什么要考虑这些选项。

...

我们最初假设解决方案将采用自动引用计数的形式(因此程序员不会忘记)加上其他一些东西来自动检测和处理周期。...我们最终得出结论,这在一般情况下是行不通的。

...

总之:

  • 我们觉得在 不强迫程序员围绕这些复杂的数据结构问题去理解、追踪和设计的情况下,解决循环问题是非常重要的。
  • 我们希望确保我们拥有一个高性能(速度和工作集)系统,我们的分析表明,对系统中的每个对象使用引用计数将无法实现这一目标
  • 由于各种原因,包括组合和铸造问题,没有简单透明的解决方案来仅对那些需要它的对象进行引用计数
  • 我们选择不选择为单一语言/上下文提供确定性终结的解决方案,因为它抑制了与其他语言的互操作,并通过创建特定于语言的版本导致类库的分叉。
于 2009-05-26T04:52:36.340 回答
31

垃圾收集器不需要您为您定义的每个类/类型编写 Dispose 方法。当你需要明确地做一些事情来清理时,你只定义一个;当您明确分配本机资源时。大多数时候,GC 只是回收内存,即使您只对对象执行 new() 之类的操作。

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

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

无数个插件:如果您真的对 GC 的工作原理感兴趣,我建议您阅读 Jeffrey Richter 关于CLR 中通过 C#的 GC 的站立章节。

于 2009-05-15T05:48:14.593 回答
23

在 C# 中尝试了引用计数。我相信,发布 Rotor(提供了源代码的 CLR 的参考实现)的人确实引用了基于计数的 GC,只是为了看看它与分代的 GC 相比如何。结果令人惊讶——“普通”GC 速度如此之快,甚至都不好笑。我不记得我是在哪里听到的,我认为这是 Hanselmuntes 播客之一。如果你想看到 C++ 在与 C# 的性能比较中基本被压垮——谷歌 Raymond Chen 的中文词典应用程序。他做了一个 C++ 版本,然后 Rico Mariani 做了一个 C# 版本。我认为 Raymond 经过 6 次迭代才最终击败了 C# 版本,但到那时他不得不放弃 C++ 中所有好的面向对象的特性,并降到 win32 API 级别。整个事情变成了性能黑客。

于 2009-05-15T05:51:29.873 回答
15

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

C++ 样式引用计数:

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

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

引用计数垃圾回收

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

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

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

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

我认为跟踪收集器的使用部分是出于历史原因:最近对引用计数的许多改进都是在 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

于 2009-05-26T03:20:22.100 回答
8

我来自 C++ 背景,我已经使用 C# 工作了大约一年。像许多其他人一样,我对为什么确定性资源管理没有内置在语言中感到困惑。

using构造提供“确定性”资源管理并内置于 C# 语言中。请注意,“确定性”的意思是保证在块开始执行Dispose之后的代码之前被调用。using另请注意,这不是“确定性”一词的含义,但每个人似乎都在这种情况下以这种方式滥用它,这很糟糕。

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

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

诚然,我不是很聪明......所以我问这个纯粹是为了更好地理解为什么事情会这样。

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

如果 C# 被修改为:

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

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

另一个常见的神话是引用计数在程序中最早的可能点释放对象。它没有。递减总是被推迟,通常到作用域的末尾。这使对象的存活时间超过了必要的时间,留下了所谓的“浮动垃圾”。请注意,特别是,一些跟踪垃圾收集器可以并且确实比基于范围的引用计数实现更早地回收对象。

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

无论如何,您不必IDisposable为垃圾收集的对象实现,所以这是无益的。

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

为什么这是个坏主意?

天真的引用计数非常慢并且会泄漏周期。例如, C++ 中的 Boostshared_ptr比 OCaml 的跟踪 GC 慢 10 倍。在多线程程序(几乎所有现代程序)存在的情况下,即使是基于范围的简单引用计数也是不确定的。

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

一点也不,不。事实上,这是一个坏主意,它是在 1960 年代发明的,并在接下来的 54 年中进行了激烈的学术研究,得出的结论是引用计数在一般情况下很糟糕。

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

绝对地。早期的原型 .NET 和 JVM 使用引用计数。他们还发现它很糟糕并放弃了它以支持跟踪 GC。

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

没有引用计数的 GC 更快

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

处理对象图中的循环问题

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

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

将弱引用称为“简单”是希望战胜现实的胜利。他们是一场噩梦。它们不仅不可预测且难以架构,而且会污染 API。

速度优化是否超过了您的缺点:

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

不及时using释放非内存资源?

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

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

I think the question you really want to ask is why doesn't IDisposable use reference counting. My response is anecdotal: I've been using garbage collected languages for 18 years and I have never needed to resort to reference counting. Consequently, I much prefer simpler APIs that aren't polluted with incidental complexity like weak references.

于 2014-12-19T16:50:51.623 回答
5

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

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

引用计数不会收集循环。引用计数也具有较低的吞吐量(总体上较慢),但具有比跟踪收集器更快的暂停(最大暂停更小)的好处。

于 2009-05-15T05:53:22.130 回答
4

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

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

于 2009-05-15T05:55:43.343 回答
1

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

IDisposable 的全部意义在于 GC 在某个不确定的时间运行,而您实现 IDisposable 是因为您拥有宝贵的资源并希望在确定的时间释放它。

因此,就 IDisposable 而言,您的提议不会改变任何内容。

编辑:

对不起。没有正确阅读您的建议。:-(

维基百科对References counted GC的缺点有一个简单的解释

于 2009-05-15T05:55:31.237 回答
1

参考计数

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

.NET 中的垃圾收集

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

除了上述 GC 之外,还使用了生成的概念来进行更有效的垃圾收集。它基于以下概念 1. 压缩托管堆的一部分的内存比压缩整个托管堆更快 2. 较新的对象将具有较短的生命周期,而较旧的对象将具有较长的生命周期 3. 较新的对象倾向于彼此相关并由应用程序大约在同一时间访问

托管堆分为三代:0、1、2。新对象存储在gen 0中。经过一次GC循环没有回收的对象被提升到下一代。因此,如果第 0 代中较新的对象在 GC 周期 1 中存活,那么它们将被提升到第 1 代。其中那些在 GC 周期 2 中存活的对象被提升到第 2 代。因为垃圾收集器仅支持三代,所以第 2 代中的对象在第 2 代中保留一个集合,直到确定它们在未来的集合中无法访问。

垃圾收集器在第 0 代已满并且需要为新对象分配内存时执行收集。如果第 0 代的收集没有回收足够的内存,垃圾收集器可以执行第 1 代的收集,然后是第 0 代。如果这没有回收足够的内存,垃圾收集器可以执行第 2、1 和 0 代的收集.

因此 GC 比引用计数更有效。

于 2009-05-15T05:55:55.027 回答
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.
}

IC#,您还可以使用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 处理内存。

于 2013-09-03T01:31:39.917 回答