代码改变世界

.net垃圾回收学习【IDisposable: What Your Mother Never Told You About Resource Deallocation】[翻译&学习]

2011-09-18 10:12  一一九九  阅读(676)  评论(0编辑  收藏  举报

From: http://www.codeproject.com/KB/dotnet/idisposable.aspx

注: 非一字一句的翻译。纯属记录个人过程中的一些理解。

The Boring Stuff - IDisposable Gone Awry

这篇文章分为两个部分: 第一部分是由于IDisposable自身带来的一些问题,第二部分是写IDispoable Code的最佳实践。

Deterministic Resource Deallocation - Its Necessity

作者有20多年的编程经验,在日常工作中偶尔需要为某些任务设计一些编程语言,设计一门语言需要遵守一些规则,有些是永远都不能打破的规则,有些则是通用的指导方针。其中一条是:

           Never design a language with exceptions that do not also have determinsitic resource deallocation.

            不要设计一门没有确定的资源(时机)释放的语言。

.net runtime 没有遵循这一条指南,接下来我们分析一下.net的这种选择带来的后果,并且提出.net环境下的最好的实践方式。

这条指南(guideline)合理之处在于deterministic resource deallocation is necessary to produce usable programs withmaintainable code(明确的资源释放对于编写可维护的代码是非常有必要的). 明确的资源释放为程序员提供了一个了解资源何时会释放的时点。两种编写可靠的代码的方法: 传统的方式(尽快的释放资源) ,现在的方式(资源的释放是发生在一个不确定的时点)。现在的方式的优点对于程序员来说是不需要明确的释放资源,缺点是写可靠的软件变得困难起来:大量的判断错误的条件被加到分配逻辑中,很不幸的是,.net runtime 采用了现在的方式进行设计。

.net runtime通过finalize方式支持不确定的资源释放,这个方法对于runtime来说有特殊的意义。微软意识到存在明确的释放资源的需求,所以添加IDisposable接口(以及其他的一些辅助类)。然而,runtime将IDispoable接口视为另外一个接口,没有什么特殊的含义,这种歧视导致了一些问题。

C#中,可以通过使用try…finally来实现明确的资源释放,或者使用类似的using. 关于微软是否采用引用计数方式上存在很多的争论,个人认为微软做了一个错误的决定,结果,明确的资源释放要求使用糟糕的finally/using,或者容易引发错误的直接嗲用IDispoable.Dispose的方式。这些对于习惯了C++中的shared ptr<t>方式的编写可靠软件的程序员没有一点吸引力。

The IDisposable Solution


IDisposable是微软提供的确定的释放资源的方式。需要在以下的使用场景中来实现:

  • 任何拥有托管资源的的类型。any type that owns managed (IDisposable) resources. 注意容器类型必须拥有资源,而不是引用资源。这里的一个困难是: 不确定那个类实现了IDispoable,这经常需要程序员查看文档才清楚。FxCop可以协助分析代码,帮助列出来那些有IDisposable的类。
  • 任何拥有非托管资源的类型。
  • 任何同时拥有托管类型和非托管资源的类型。
  • 任何继承自IDisposable的类。不建议从那些拥有托管资源的类直接继承,而是将这些类作为一个字段而不是基类型。

IDisposable提供了明确的资源释放,但是,也打来了一堆问题。

IDisposable's Difficulties – Usability


IDisposable是C#中很难使用正确的类。任何使用IDisposable对象的人都需要将IDispoable对象包在Using结构中,而C#不灵活的一点就是using是不能用在没有显示IDisposable接口上的对象上,所以对于每一个在确定的程序中需要使用的对象,码工都需要判断using是否是有必要的, 而这需要仔细的翻看文档或者简单的在每个类上添加using ,然后等待编译报错。

C++在这方面就稍微好些。C++支持stack semantic for reference types,  这种方式会在需要的时候插入一段类似using的代码,如果C#能够允许在using中使用没有实现IDisposable的类型就比较好了。

IDisposable的问题是一个最终用户的问题: 这个可以通过代码分析工具或者代码规范来得到保证,尽管这里没有完美的解决方案。而问题是如果资源是在不确定的时间释放的(比如说码工忘记添加using语句了),代码可能在测试的时候运行正确,但是在上线后崩溃。

使用IDisposable而不是引用计数也带来一个所有者的问题,当C++ 引用计数的 shared_ptr<T>的最后一个引用超出了范围后,它的资源会被立即释放。然后,基于IDisposable的对象将哪段代码拥有对象的重担交给了最终用户,使得最终用户承担起资源释放的责任。有时候,所属关系是明确的,当一个对象被另外一个对象引用的时候,只要容器对象也实现了IDisposable接口,就会将对象自己释放。另外一个常见的场景是,对象的生命周期是被他在程序中的代码来定义的,最终用户通过使用using结构来定义了拥有对象的代码。然而,这里有些其他的场景,比如说对象的生命周期是被两段不同的代码引用,这就给最终用户编写正确的代码带来了困难(引用计数的方式此时就提供了一个比较简单方式)。

IDisposable's Difficulties - Backwards Compatibility


从一个接口或者类中添加IDisposable或者移除IDisposable接口是一个巨大的变化。优良的设计原则是客户代码应该采用接口的方式,所以IDisposable可以被添加到一个内部的类中,通过接口的方式来进行传递,然后,这仍然会对老的代码带来一些问题。

微软自己就陷入了这个问题。IEnumerator并没有从IDisposable继承而来,然而, IEnumerator<T>确实从IDisposable继承而来,现在当老的客户代码希望使用IEnumerable<T>的Collection来代替IEnumerable的Collections的时候,之前的Enumerators没有被正确的释放。

就这样完了?或许, 但是这确实带来了二等公民IDisposable如何影响设计的一些问题。

IDisposable's Difficulties – Design


IDisposable带来的在代码设计上的最大的缺陷是:任何接口设计都需要预测他们的继承类型是否需要IDisposable接口。完整的引用是“在一个其继承类型会持有资源即使基类型没有资源的基类型上实现Dispose设计模式from Implementing Finalize and Dispose to Clean Up Unmanaged Resources)” 从一个设计的角度来说, 接口的设计需要预测接口的实现是否会需要IDisposable。

假如一个接口没有从IDsposable继承下来,但是其中的一个实现需要(比如说来自第三方的一个供应商),那么终端用户的代码就面临着一个“限制”问题。终端用户的代码么有意识意识到他正在使用的类需要进行释放操作终端用户可能通过使用Using来测试对象有没有实现IDisposable接口,然而,这使得笨重的using将每一个抽象的本地对象变成了可怕的finally结构,将这种负担放到了最终用户身上,在我看来,是不能接受的。

“限制”的问题的典型是当微软更新IEnumerator的时候,他们认为每一个Enumerators经常的需要释放资源,所以给IEnumerator<T>添加了IDisposable接口,然后,将IDisposable接口添加到继承类型的时候会给那些使用IEnumerator接口的终端代码带来限制的问题。

对于某些接口来说,其继承的类型是否需要IDisposalbe是比较明显的,对于另外一些接口来说,就不是那么容易分清楚了,然而这些都需要在接口发布的时候明确下来,实际上,这是一个现实的的问题。

简单的来说,IDisposable给设计可复用的软件带来了困难,其背后带来的问题是违背了面向对象设计的中心原则: 接口和实现分离。一个接口实现的内部的资源的分配和释放需要明确出来,然而,微软只给了IDisposable一个二等公民的待遇,他们使用了细节的实现代替了接口的资源释放,这些都违背了接口和实现分离的原则。

这里有另外一个不那么引人注意的解决方案: 让所有的接口和类都从IDisposable继承下来,既然IDisposable可能随时被实现,也就意味着所有的继承自己或者类的子类可能有一个实现,或者将来的版本有一个实现。但是,个人没有勇气将这个作为一个设计原则。

IDisposable设计带来的另外的一个麻烦是如何和集合进行交互。既然IDisposable是一个接口,每一个集合当拥有子目的时候,必然表现出来不同的释放方式,或者最终用户记得必须明确的激活IDisposable.Dispose,那么这意味着这里有一堆的集合类是“own”他们的子目的。对于一个设计者来说,复制一个类的层级接口是一个比较恐怖的事情,表明这里有什么事情是错误的。加入.net支持引用计数的话,就不存在这个问题。(注:这里没有大明白,大体上是说如果一个集合是其条目的所有者的话,其条目是由该集合来释放的,那么类的层级关系就乱了。作者的意思应该是类的某个成员变量应该属于类本身的,而不应该属于集合自身的,而且由于.net是属于不确定释放的,所以何时释放集合,何时释放条目是不确定的,而集合如果拥有条目的话,意味着是存在释放的先后顺序的)。

IDisposable's Difficulties - Additional Error State


IDisposable 带来的另外的一个问题是必须明确的调用IDisposable,而不是和对象的生命周期绑定。这相当于给每个能够释放的对象添加了一个“disposed”的状态。由于有了这个状态,微软建议任何实现了IDisposable的任何类型都需要在每一个方法或者属性获取中检查对象是否已经被释放,如果已经释放的话就抛出一个异常。 这让我想起了一位同事,这位同事坚持在内存分配的时候进行内存检查算法,以免RAM失败了。在我的观点找哦你,检查disposed的状态是一种浪费,只有在调试的时候才有用。假如终端用户没有遵守最基本的软件规则,也不会编写出来任何工作的代码。

相对于检查Disposed的状态并且抛出来一个异常,我建议支持“undefined behaviour”. 访问一个释放的对系那个如同访问一个释放的内存。

IDisposable's Difficulties - No Guarantees


既然IDisposable仅仅是一个接口,实现这个接口的对象也就是仅仅支持明确的资源释放,不能要求他自动执行。既然认为终端用户不需要释放一个对象是可以接受的,任何实现IDisposable的对象必须支持额外的逻辑,以便能够同时处理确定的释放和不确定的释放。再一次,不确定的释放是.net提供的标准的模式,确定的释放仅仅是一个选项,而不是强制执行的。确定的释放只有在引用计数下才能完成。

IDisposable's Difficulties - Complexity of Implementation


微软提供了一个实现IDisposable的模式,并不是所有的编码工都能够理解这段代码(比如为什么调用gc.keepAlive是有必要的,而对disposed的同步确实没有必要的)。这里有很多的描述如何实现这个模式的文章。下面是为什么是一个比较晦涩的设计:

  • IDisposable.Dispose可能永远都不会被调用,所以disposable object必须包含一个能够释放资源的finalizer. 换句话说,确定的资源释放必须支持不确定的资源释放。
  • IDisposable.Dispose可能被调用多次,为了避免调用多次带来的影响,需要在每次运行之前都要检查一遍。
  • 因为finalizer是在所有的不可到达的对象上以任意次序运行的,finalizer可能获取不到托管对象,因此,资源释放的方法必须能够处理一个正常的模式(调用IDisposable.Dispos)和一个“unmanaged-only”模式(来自finalize的调用)。
  • 因为finalizer是运行在一个独立的线程上的,存在一种可能是:在IDisposable.Dispose返回之前就已经启动了finalizer线程。所以需要采用Gc.KeepAlive或者Gc.SuppressFinalize来组织条件竞赛。

另外,下列的一些事实需要深入了解:

  • 当构造函数抛出来一个异常的时候,finalizer会被调用。因此,释放的代码必须能够优雅的处理部分构造的对象。
  • 在一个CriticalfinalizerObject的继承类型上实现IDisposable是比较麻烦的一件事情。因为void Dispose(bool disposing)是virtual的,但是必须在一个constrained execution region上运行。这可能需要明确的调用RuntimeHelpers.PrepareMethod.

推荐的IDisposable模式的命名是十分让人混淆的。对象实现了接口IDisposable,但是还需要一个布尔类型的字段disposed,到了目前还算好的了,但是,作为模式的一部分,IDisposable.Dispose的实现嗲用了一个有布尔参数disposing的Dispose的重载。这段代码的命名规则十分的混乱,即使经验丰富的程序员也会困惑。任何偏离都会被Fxcop认为是这个代码模式的一个违背。

这是C++比C#稍微好的一点。C++的编译器在实现了IDisposable上做了很多的工作,C#是一个纯正的.net语言,所以在很多方面,比C++有更多的自然的语法,然而,在明确的资源释放上,C++ 有两个有用的优点: destructor syntax, and stack semantics for references types.

即使对于牛逼的程序员来说,推荐的IDispose模式也增大了编写不正确的代码的可能性。很容易在Dispose中添加一些shutdown逻辑,然而,既然我们不能在finalizer中接触到任何托管对象,IDisposable只支持明确的释放而不是强制的释放,这经常会导致一些错误。推荐的IDisposable模式只能用来释放资源,不能用来执行常见的shutdown逻辑。这个事实经常被忘记。

有时候,为了简单起见或者其他的原因,IDisposable进场被忘记。 Fxcop会检查几种无效的模式,但是会忽略掉一些其他的常见模式。微软的员工自己也陷入了这个陷阱: weakReference 并没有实现Idisposable接口,而它应该实现的。那些不认为明确的资源释放是有必要的的程序员一般简单的忽略到IDisposable接口。

IDisposable's Difficulties - Impossibility of Shutdown Logic (Managed Finalization)


ShutDown逻辑是现实中的应用程序的一个常见需要,这个在异步的编程模型中非常常见。比如说: 一个类有一个子线程,当一个ManualResetevent事件发生的话,这个类能够停止这个线程,这个逻辑很容易被在IDisposable.Dispose中实现,但是如果是从finalizer中调用Dispose的话,这将会是一个灾难性的错误。既然IDisposable不支持强制的明确资源释放,这里存在最终用户会忘记或者忽略掉用IDisposable.Dispose的可能,而这会导致一个资源泄漏。这引发了这样的一个问题: 是否什么方式能够在finalizer代码中获取托管对象?

In order to better understand the restrictions on finalizers, we must understand the garbage collector. [Note: The description of garbage collection and finalization given here is a simplification; the actual implementation is considerably more complicated. Generations, resurrection, weak references, and several other topics are ignored. For the purpose of this article, however, this logical description is correct and reasonably complete.]

The .NET garbage collector utilizes a mark/sweep algorithm. Specifically, it does the logical equivalent of the following:

  1. Suspends all threads (except the finalizer thread(s)).
  2. Creates a set of "root" objects. If the AppDomain is unloading or the CLR is shutting down, then there are no root objects. For normal garbage collections, the root objects are:
    • Static fields.
    • Method parameters and local variables for the whole call stack of each thread, unless the current CLI instruction has already moved past the point of their last access (e.g. if a local variable is only used for the first half of a function, then it is eligible for garbage collection for the second half of the function) - note that the this pointer is included here.
    • Normal and pinned GCHandle table entries (these are used for Interop code, so the GC doesn't remove objects that are referenced only by unmanaged code).
  3. Recursively marks each root object as "reachable": for each reference field in the reachable object, the object referred to by the field is recursively marked as reachable (if it isn't already).
  4. Identifies the remaining, unmarked objects as "unreachable".
  5. Recursively marks each unreachable object with a finalizer (that hasn't had GC.SuppressFinalize called on it) as reachable, and places them on the "finalization reachable queue" in a mostly-unpredictable order.

In parallel with the garbage collection above, finalization is also constantly running in the background:

  1. A finalizer thread takes an object from the finalization reachable queue, and executes its finalizer - note that multiple finalizer threads may be executing finalizers for different objects at any given time.
  2. The object is then ignored; if it is still reachable from another object on the finalization reachable queue, then it will be kept in memory; otherwise, it will be considered unreachable, and will be collected at the next garbage collection sweep.

finalizer不能够获取托管对象的原因是他们不知道其他的finalizer是否已经运行。任何通过字段引用其他对象的对象或许可能能够访问到这些字段,或许什么访问不到,因为他们自己的finalizer已经开始运行了。即使调用Dispose也会出现错误,因为Dispose也可能已经在其他的finalizer线程中被执行了。调用dispose是毫无意义的,因为这些对象从代码中是可以获取的或者已经进入了Finalization的可到达队列中。也需要注意在AppDomain卸载或者CLR关闭的时候,所有的对象都会被Gc回收的,包括CLR运行时的一些支持对象和其他的一些静态引用对象。这种情况下即使如同EventLog.WriteEntry方法也是不能够调用的。

当在finalizer中访问托管代码的时候,有一大把的免责条款:

  • finalization reachable队列是部分有序的: finalizers for CriticalFinalizerObject-derived types are called after finalizers for non-CriticalFinalizerObject-derived types. This means that, e.g. a class with a child thread may call ManualResetEvent.Set for a contained ManualResetEvent, as long as the class does not derive from CriticalFinalizerObject.
  • The Console object and some methods on the Thread object are given special consideration. This explains why example programs can create an object calling Console.WriteLine in its finalizer and then exit, but the same program won't work with EventLog.WriteEntry.

通常来说, finalizer可能获取不到托管对象,然而,对于一个合理负责的程序来说,支持shutdown逻辑是有必要的。The Windows.Forms namespace handles this with Application.Exit, which initiates an orderly shutdown. When designing library components, it is helpful to have a way of supporting shutdown logic integrated with the existing logically-similar IDisposable (this avoids having to define an IShutdownable interface without any built-in language support). This is usually done by supporting orderly shutdown when IDisposable.Dispose is invoked, and an abortive shutdown when it is not. It would be even better if the finalizer could be used to do an orderly shutdown whenever possible.

Microsoft came up against this problem, too. The StreamWriter class owns a Stream object; StreamWriter.Close will flush its buffers and then call Stream.Close. However, if a StreamWriter was not closed, its finalizer cannot flush its buffers. Microsoft "solved" this problem by not giving StreamWriter a finalizer, hoping that programmers will notice the missing data and deduce their error. This is a perfect example of the need for shutdown logic.