IDisposable-您的妈妈没有谈论释放资源。 第一部分

这是本文第一部分的翻译。 这篇文章写于2008年。 10年后,几乎失去了意义。


确定性释放资源-需求


在20多年的编码经验过程中,有时我会开发自己的语言来解决问题。 它们的范围从简单的命令式语言到用于树的专用正则表达式。 创建语言时,有很多建议,一些简单的规则也不应违反。 其中之一:


切勿创建没有确定性释放资源的异常语言。

猜猜.NET运行时没有遵循什么建议,因此,所有基于它的语言都没有?


该规则存在的原因是确定性释放资源对于创建支持的程序是必需的 。 确定的资源释放为程序员确定释放资源提供了一定的条件。 有两种编写可靠程序的方法:传统方法是尽早释放资源,现代方法是无限期释放资源。 现代方法的优点是程序员不需要显式释放资源。 缺点是编写可靠的应用程序要困难得多,存在许多细微的错误。 不幸的是,.NET运行时是使用现代方法创建的。


.NET支持使用Finalize方法进行非确定性的资源释放,该方法具有特殊含义。 为了确定性地释放资源,Microsoft还添加了IDisposable接口(以及其他类,我们将在后面讨论)。 但是,对于运行时, IDisposable是一个普通的接口,就像其他所有人一样。 这种“第二流”状态带来了一些困难。


在C#中,可以使用tryfinally tryusing (几乎是同一回事)实现“穷人的确定性释放”。 微软一直在讨论是否进行链接计数,在我看来,做出了错误的决定。 结果,要确定性地释放资源,您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构造,并且您将不得不编写难看的tryfinally


简而言之, 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垃圾收集器使用标记和清除算法。 通常,逻辑如下所示:


  1. 暂停所有线程。
  2. 获取所有根对象:堆栈上的变量,静态字段, GCHandle对象,完成队列。 在卸载应用程序域(程序终止)的情况下,认为堆栈和静态字段中的变量不是根。
  3. 递归地遍历对象的所有链接,并将它们标记为“可达”。
  4. 遍历所有其他具有析构函数(finalizer)的对象,将它们声明为可到达的,然后将它们放入终结队列中( GC.SuppressFinalize告诉GC不要这样做)。 对象以不可预测的顺序排队。

在后台,有一个(或几个)终结处理工作流:


  1. 从队列中获取一个对象并启动其终结器。 可以同时运行不同对象的多个终结器。
  2. 该对象将从队列中删除,并且如果没有其他人引用该对象,则将在下一个垃圾回收时将其清除。

现在应该清楚为什么无法从终结器访问托管资源了-您不知道终结器的调用顺序。 即使从终结器调用另一个对象的IDisposable.Dispose也可能导致错误,因为资源释放代码可能在另一个线程中工作。


当您可以从终结器访问托管资源时,有一些例外情况:


  1. CriticalFinalizerObject继承的对象的终结是在未继承自此类的对象的终结之后执行的。 这意味着您可以从终结器调用ManualResetEvent ,直到从CriticalFinalizerObject继承该类为止。
  2. 一些对象和方法很特殊,例如Console和一些Thread方法。 即使程序结束,也可以从终结器调用它们。

在一般情况下,最好不要从终结器访问托管资源。 但是,对于非平凡的软件,完成逻辑是必需的。 在Windows.FormsApplication.Exit方法中包含完成逻辑。 在开发组件库时,最好的办法是使用IDisposable完成完成逻辑。 在调用IDisposable.Dispose情况下可以正常终止,否则请紧急处置。


微软也遇到了这个问题。 StreamWriter类拥有一个Stream对象(取决于最新版本中的构造函数参数- 大约Per​​。 )。 StreamWriter.Close刷新缓冲区并调用Stream.Close (如果using - 大约Per​​。包装,也会发生)。 如果未关闭StreamWriter ,则不会刷新缓冲区,并且数据聊天也会丢失。 Microsoft根本没有重新定义终结器,从而“解决”了完成问题。 需要完成逻辑的一个很好的例子。


我建议阅读


本文中有关.NET内部的许多信息来自Jeffrey Richter的CLR通过C#。 如果您还没有,请购买 。 说真的 这是任何C#程序员必需的知识。


译者的结论


大多数.NET程序员永远都不会遇到本文中描述的问题。 .NET将不断发展,以提高抽象级别并减少对“管理”非托管资源的需求。 尽管如此,本文还是很有用,因为它描述了简单事物的深入细节及其对代码设计的影响。


下一部分将通过大量示例详细讨论如何在.NET中使用托管和非托管资源。

Source: https://habr.com/ru/post/zh-CN414873/


All Articles