这是本文第一部分的翻译。 这篇文章写于2008年。 10年后,几乎失去了意义。
确定性释放资源-需求
在20多年的编码经验过程中,有时我会开发自己的语言来解决问题。 它们的范围从简单的命令式语言到用于树的专用正则表达式。 创建语言时,有很多建议,一些简单的规则也不应违反。 其中之一:
切勿创建没有确定性释放资源的异常语言。
猜猜.NET运行时没有遵循什么建议,因此,所有基于它的语言都没有?
该规则存在的原因是确定性释放资源对于创建支持的程序是必需的 。 确定的资源释放为程序员确定释放资源提供了一定的条件。 有两种编写可靠程序的方法:传统方法是尽早释放资源,现代方法是无限期释放资源。 现代方法的优点是程序员不需要显式释放资源。 缺点是编写可靠的应用程序要困难得多,存在许多细微的错误。 不幸的是,.NET运行时是使用现代方法创建的。
.NET支持使用Finalize
方法进行非确定性的资源释放,该方法具有特殊含义。 为了确定性地释放资源,Microsoft还添加了IDisposable
接口(以及其他类,我们将在后面讨论)。 但是,对于运行时, IDisposable
是一个普通的接口,就像其他所有人一样。 这种“第二流”状态带来了一些困难。
在C#中,可以使用try
和finally
try
或using
(几乎是同一回事)实现“穷人的确定性释放”。 微软一直在讨论是否进行链接计数,在我看来,做出了错误的决定。 结果,要确定性地释放资源,您finally
需要使用笨拙的\构造或直接调用IDisposable.Dispose
,这充满了错误。 对于习惯使用shared_ptr<T>
的C ++程序员shared_ptr<T>
这两个选项都不有吸引力。 (最后一句话清楚地表明了作者之间的这种关系- 大约
一次性
IDisposable
是Misoftro提供的确定性释放资源的解决方案。 一种是针对以下情况:
- 拥有托管(
IDisposable
)资源的任何类型。 类型必须必须拥有 ,即管理生命周期,资源,而不仅仅是引用它们。 - 拥有非托管资源的任何类型。
- 拥有托管和非托管资源的任何类型。
- 从实现
IDisposable
的类继承的任何类型。 我不建议从拥有非托管资源的类继承。 最好使用附件。
IDisposable
可帮助确定性地释放资源,但有其自身的问题。
难点IDisposposable-可用性
IDisposable
对象使用起来很麻烦,是IDisposable
。 使用对象必须包装在using
构造中。 坏消息是C#不允许与未实现IDisposable
的类型一起使用。 因此,程序员必须每次都参考文档,以了解是否有必要使用编写,或者只是在各处using
编写,然后清除编译器发誓的地方。
在这方面,托管C ++更好。 它支持引用类型的堆栈语义 ,该引用语义仅在必要时用于类型。 C#可以受益于using
任何类型进行编写的能力。
这个问题可以解决。 代码分析工具。 更糟糕的是,如果您忘记使用它,该程序可以通过测试,但在“现场”工作时会崩溃。
除了计数链接之外, IDisposable
还有另一个问题-确定所有者。 在C ++中,当shared_ptr<T>
的最后一个副本超出范围时,资源将立即释放,而无需考虑谁应该释放。 相反, IDisposable
强制程序员确定谁“拥有”该对象并负责释放该对象。 有时所有权是显而易见的:当一个对象封装另一个对象并本身实现IDisposable
,它负责释放子对象。 有时,对象的生存期由代码块决定,程序员仅在此块周围使用using
。 但是,在很多情况下,可以在多个地方使用对象,并且很难确定其寿命(尽管在这种情况下,引用计数就可以了)。
难点IDisposposable-向后兼容性
将IDisposable
添加到类中并从已实现的接口列表中删除IDisposable
是一项重大更改。 如果将IDisposable
添加到通过引用接口或基类传递的一个类中,则不期望IDisposable
客户端代码将不会释放资源。
微软本身就遇到了这个问题。 IEnumerator
不是从IDisposable
继承的,而IEnumerator<T>
继承的。 如果将IEnumerator<T>
传递IEnumerator<T>
接收IEnumerator
IEnumerator<T>
代码,则不会调用Dispose
。
这不是世界末日,但它给出了IDisposable
一些辅助本质。
IDisposable的难点-设计类层次结构
由IDisposable
引起的最大缺点是在层次结构设计领域中,每个类和接口都必须预测其子孙是否需要IDisposable
。
如果接口不继承IDisposable
,但实现该接口的类也实现IDisposable
,则最终代码将忽略确定性版本,或者必须检查对象是否实现IDisposable
接口。 但是为此,将无法使用using构造,并且您将不得不编写难看的try
和finally
。
简而言之, IDisposable
使可重用软件的开发复杂化。 关键原因是违反了面向对象设计的原理之一-接口与实现的分离。 释放资源应该是实现细节。 微软决定将确定性的资源发布作为第二类接口。
一种不太美观的解决方案是使所有类都实现IDisposable
,但是在绝大多数类中, IDisposable.Dispose
不会做任何事情。 但这不是太漂亮。
IDisposable
另一个困难是集合。 有些集合在其中“拥有”对象,有些则没有。 但是,集合本身不实现IDisposable
。 程序员必须记住要调用IDisposable.Dispose
在集合中的对象上,或者创建自己的实现IDisposable
意味着所有权的集合类的后代。
困难IDisposposable-附加的“错误”状态
无论对象的生存时间如何,都可以在任何时候显式调用IDisposable
。 也就是说,将“释放”状态添加到每个对象,建议在其中抛出ObjectDisposedException
。 检查状态和引发异常是额外的费用。
与其检查每个喷嚏,不如将对处于“已释放”状态的对象的访问视为“未定义行为”,以作为对已释放内存的调用。
困难IDisposposable-没有保证
IDisposable
只是一个接口。 实现IDisposable
的类支持确定性发布,但不能保证一定要发布。 对于客户代码,最好不要调用Dispose
。 因此,实现IDisposable
的类必须支持确定性和非确定性发布。
IDisposable的复杂性-复杂的实现
Microsoft 提供了一种实现IDisposable
的模式 。 (以前有一个通常可怕的模式,但是相对较新的.NET 4出现之后,文档经过了更正,包括在本文的影响下。在.NET旧版本中,您可以找到旧版本。-大约。 )
IDisposable.Dispose
可能根本不被调用,因此该类必须包含终结器以释放资源。IDisposable.Dispose
可以被调用多次,并且应该可以正常工作而没有明显的副作用。 因此,有必要添加一个检查方法是否已经被调用的检查。- 终结器在单独的线程中调用,可以在
IDisposable.Dispose
之前调用。 必须使用GC.SuppressFinalize
来避免此类“竞赛”。
另外:
- 调用终结器,包括在构造函数中引发异常的对象。 因此,发布代码必须与部分初始化的对象一起使用。
- 在从
CriticalFinalizerObject
继承的类中实现IDisposable
要求非平凡的构造。 void Dispose(bool disposing)
是一种病毒方法,必须在Constrained Execution Region中执行 ,这需要调用RuntimeHelpers.PrepareMethod
。
难点IDisposable-不适合完成逻辑
关闭对象-通常在并行或异步线程的程序中发生。 例如,一个类使用一个单独的线程,并希望使用ManualResetEvent
完成该线程。 这可以在IDisposable.Dispose
中完成,但是如果在终结器中调用代码,则可能导致错误。
要了解终结器的局限性,您需要了解垃圾收集器的工作方式。 下面是简化图,其中省略了与世代,弱链接,对象的复兴,背景垃圾回收等有关的许多细节。
.NET垃圾收集器使用标记和清除算法。 通常,逻辑如下所示:
- 暂停所有线程。
- 获取所有根对象:堆栈上的变量,静态字段,
GCHandle
对象,完成队列。 在卸载应用程序域(程序终止)的情况下,认为堆栈和静态字段中的变量不是根。 - 递归地遍历对象的所有链接,并将它们标记为“可达”。
- 遍历所有其他具有析构函数(finalizer)的对象,将它们声明为可到达的,然后将它们放入终结队列中(
GC.SuppressFinalize
告诉GC不要这样做)。 对象以不可预测的顺序排队。
在后台,有一个(或几个)终结处理工作流:
- 从队列中获取一个对象并启动其终结器。 可以同时运行不同对象的多个终结器。
- 该对象将从队列中删除,并且如果没有其他人引用该对象,则将在下一个垃圾回收时将其清除。
现在应该清楚为什么无法从终结器访问托管资源了-您不知道终结器的调用顺序。 即使从终结器调用另一个对象的IDisposable.Dispose
也可能导致错误,因为资源释放代码可能在另一个线程中工作。
当您可以从终结器访问托管资源时,有一些例外情况:
- 从
CriticalFinalizerObject
继承的对象的终结是在未继承自此类的对象的终结之后执行的。 这意味着您可以从终结器调用ManualResetEvent
,直到从CriticalFinalizerObject
继承该类为止。 - 一些对象和方法很特殊,例如Console和一些Thread方法。 即使程序结束,也可以从终结器调用它们。
在一般情况下,最好不要从终结器访问托管资源。 但是,对于非平凡的软件,完成逻辑是必需的。 在Windows.Forms
在Application.Exit
方法中包含完成逻辑。 在开发组件库时,最好的办法是使用IDisposable
完成完成逻辑。 在调用IDisposable.Dispose
情况下可以正常终止,否则请紧急处置。
微软也遇到了这个问题。 StreamWriter
类拥有一个Stream
对象(取决于最新版本中的构造函数参数- 大约Per。 )。 StreamWriter.Close
刷新缓冲区并调用Stream.Close
(如果using
- 大约Per。包装,也会发生)。 如果未关闭StreamWriter
,则不会刷新缓冲区,并且数据聊天也会丢失。 Microsoft根本没有重新定义终结器,从而“解决”了完成问题。 需要完成逻辑的一个很好的例子。
我建议阅读
本文中有关.NET内部的许多信息来自Jeffrey Richter的CLR通过C#。 如果您还没有,请购买 。 说真的 这是任何C#程序员必需的知识。
译者的结论
大多数.NET程序员永远都不会遇到本文中描述的问题。 .NET将不断发展,以提高抽象级别并减少对“管理”非托管资源的需求。 尽管如此,本文还是很有用,因为它描述了简单事物的深入细节及其对代码设计的影响。
下一部分将通过大量示例详细讨论如何在.NET中使用托管和非托管资源。