Effective-CSharp-17实现标准的dispose模式

如前文所说的,若对象包含非托管资源,那么一定要正确的清理。对于非托管型资源来说,.NET Framework 会采用一套标准的模式来完成清理工作,如果你所变写的类里也用到了非托管资源,那么该类的使用者就会认为这个类同样遵循这套模式。标准的 dispose (释放/处置) 模式会实现 IDisposable 接口,又会提供 finalizer (终结器/终止化器),以便在客户端忘记调用 IDisposable.Dispose() 的情况下也可以释放资源。这样做虽然有可能令程序的性能因执行 finalizer 而下降,但毕竟可以保证垃圾回收器能够把资源回收掉。这是处理非托管资源的正确方式,开发者应该透彻地理解该方式。实际上,.NET 中的非托管资源还可以通过System.Runtime.Interop.SafeHandle 的派生类来访问,那个类也正确地实现了这套标准的 dispose 模式。

在类的继承体系中,位于根部的那个基类应该做到如下几点

  • 实现 IDisposable 接口,以便释放资源。

  • 若本身含有非托管资源,添加 finalizer,以防客户端忘记调用 Dispose() 方法。若是没有非托管资源,则不添加 finalizer。

  • Dispose 方法与 finalizer (如果有) 都把释放资源的工作委派给虚方法,使得子类能够重写该方法,以释放它们自己的资源。

继承体系中的子类应该做到以下几点:

  • 若子类有自己的资源需要释放,那就重写由基类所定义的那个虚方法,若没有,则不重写该方法。
  • 若子类自身的某个成员字段表示非托管资源,实现 finalizer,若没有这样的字段,则不用实现 finalizer。
  • 记得调用基类的同名函数。

若类包含非托管资源,必须提供 finalizer,因为开发者不能保证使用者总是会调用 Dispose()。如果他们忘了,则会造成资源泄漏,尽管这是使用者的错误,但是受责备的是你 (因为你没有提前防范这种情况)。(Ryuu:当然,作为使用者,应使用 Dispose(),而不是全部依赖于 finalizer (特别是在使用外部资源时)。finalizer 仅是保险手段,其确切的执行时间是不可知的 (作者下文有述)。Java 中也是如此。)

垃圾收集器每次运行时,都会把不带 finalizer 的垃圾对象立刻从内存中移除。而带有 finalizer 的对象则会继续留在内存中,并添加到队列中。GC 会安排线程在这些对象上运行其 finalizer,运行完毕后,通常可以像不带 finalizer 的垃圾对象一样被移除。但与那些对象相比,他们属于老一代的对象,因为只有当其 finalizer 执行过一次后, GC 才会将其视为可以直接释放的对象,他们需要在内存中停留更长的时间。这也是没有办法,因为必须通过 finalizer 来保证非托管资源得到释放。尽管程序的性能可能因此有所下降,但只要客户端记得调用 Dispose(),就不会有此问题。

如果所编写的类使用了某些必须及时释放的资源,那么应按照广利实现 IDisposable 接口,以提醒此类使用者与运行系统注意。该接口只包含一个方法:

1
2
3
public interface IDisposable {
void Dispose();
}

实现 IDisposable.Dispose() 时需要注意:

  1. 释放所有的非托管资源
  2. 释放所有的托管资源 (其中包括取消事件订阅)
  3. 设定相关状态标志,表示该对象已被清理。若清理后还有对其成员的访问,可通过状态标志得知该情况,令这些操作抛出 ObjectDisposedException。
  4. 阻止垃圾回收器对该对象的重复清除 (可以通过 GC.SuppressFinalize(this) 来完成)。

正确实现 IDisposable 接口是一举两得的,因为它既提供了适当的机制使得托管资源能够及时释放,又令客户端可以通过标准的 Dispose() 来释放非托管类型的资源。如果你编写的类实现了 IDisposable 接口,并且客户端又能够记得调用其 Dispose(),那么程序将不必执行 finalizer,其性能也得到了保证,这将使得此类能顺利融入 .NET 环境中。

但此机制依然有漏洞,因为子类在清理自身的资源时必须保证基类的资源也能得到清理。若子类要重写 finalizer 或是想根据自己的需要给 IDisposable.Dispose() 添加新的逻辑,那么必须调用基类的版本。否则,基类的资源无法正确释放。此外,由于 finalizer 和 Dispose() 都有类似的任务。因此,这两个方法几乎总是包含重复的代码。直接重写接口中的函数可能无法达到预期效果,因为这些函数默认的情况下是非虚的。为此,需要再做一点工作来解决问题:把 finalizer 和 Dispose() 中重复的代码提取到 protected 级别的虚函数中,使得子类能够重写该函数,以释放他们分配的资源,而基类则应在接口方法中把核心的逻辑实现好。该辅助函数可以声明为此以供子类重写,使得其能在 Dispose() 方法或 finalizer 得以执行时把相关的资源清理干净:

1
protected virtual void Dispose(bool isDisposing)

IDisposable.Dispose() 和 finalizer 都可以调用此方法以清理相关资源。这个方法与 IDisposable.Dispose() 相互重载。由于其是虚方法,子类可以重写该方法,以便用适当的代码来清理自身的资源并调用基类版本。

  • isDisposing:true

    清理托管资源与非托管资源 (这表明该方法是在 IDisposable.Dispose() 中调用的)

  • isDisposing:false

    仅清理非托管资源 (这表明该方法是在 finalizer 中调用的)

无论是哪种情况,都要调用基类的 Dispose(bool),使得基类有机会清除其资源。

如下示例演示了该模式所用的代码框架,其中,MyResourceHog 实现了 IDisposable 接口,并创建了 Dispose(bool) 的虚方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
public class MyResourceHog : IDisposable
{
// Flag for already disposed
private bool alreadyDisposed = false;

// Implementation of IDisposable
// Call the virtual Dispose method
// Suppress Finalization
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}

protected virtual void Dispose(bool isDisposing)
{
// Don't dispose more than once.
if (alreadyDisposed) return;

if (isDisposing)
{
// elided: free managed resources here.
}

// elided: free managed resources here.
// Set disposed flag:
alreadyDisposed = true;
}

public void ExampleMethod()
{
if (alreadyDisposed)
throw new ObjectDisposedException("MyResourceHog", "Called Example Method on Disposed object");
// remainder elided.
}
}

DerivedResourceHog 继承了 MyResourceHog,并重写了基类中的 protected Dispose(bool):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class DerivedResourceHog : MyResourceHog
{
// Have its own disposed flag.
private bool disposed = false;

protected override void Dispose(bool isDisposing)
{
// Don't dispose more than once.
if (disposed) return;
if (isDisposing)
{
// TODO: free managed resources here.
}
// TODO: free unmanaged resources here.
// Let the base class free its resource.
// Base class is responsible for calling
// GC.SuppressFinalize()
base.Dispose(isDisposing);

// Set derived class disposed flag:
disposed = true;
}
}

请注意,基类和子类对象采用各自的 disposed 标志来表示其资源是否得到释放。若公用一个标志,那么子类可能率先将其设置为 true,而等到基类运行 Dispose(bool) 时,则会误认为其资源已释放。

Dispose(bool) 与 finalizer 需要具备 幂等性 (idempotent),多次调用 Dispose(bool) 的效果应与调用一次相同。由于各对象的 dispose 操作之间可能没有明确的顺序,因此在执行自身的 Dispose(bool) 时,或许其中某些成员已经被释放 (dispose) 了。这并不表示程序出了问题,因为 Dispose() 本身就可能多次被调用。对于该方法以外的其他 public 方法而言,如果在此对象已被释放后还有人要调用,那么应抛出 ObjectDisposedException ( Dispose() 是个例外)。在对象释放后调用该方法应该没有任何效果。当系统执行某个对象的 finalizer 时,该对象所引用的某些资源可能已经释放过,或是没有得到初始化。对于前者来说,不用检查其是否为 null,因为他所引用的资源还可以继续引用,只是有可能被释放,甚至其 finalizer 有可能已经执行过了。

上文示例的两个类都没有 finalizer,示例代码根本不会以 false 为参数调用 Dispose(bool)。只有当该类型直接包含非托管资源时,才应实现 finalizer。否则不调用也会给该类带来负担,因为这有着较大的开销。若有,则必须添加 finalizer 才能正确的实现 dispose 模式,此时的 finalizer 应与 Dispose(bool) 相同,都可以适当地将非托管资源释放掉。

在编写 Dispose 或 finalizer 等资源清理方法时,最重要的一点是:仅释放资源,不进行其他处理。否则就会产生一些涉及对象生存期的严重问题。一般的,对象在构造时诞生,在变成垃圾并回收时死亡。若程序不在访问某个对象,可以认为该对象已 昏迷 (comatose),对象中的方法也不会得到调用,实际上等于已经消亡了,然而如果他包含 finalizer,那么系统在正式宣告其死亡前,会给他机会,使其能够将非托管资源清理。此时,如果 finalizer 令该对象可以重新为程序引用,那么他将复活,但是这种从昏迷中醒来的对象有其问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// DON'T DO THIS!
public class BadClass
{
// Store a reference to a global object:
private static readonly List<BadClass> FinalizedList = new List<BadClass>();
private string msg;

public BadClass(string msg)
{
this.msg = msg;
}

~BadClass()
{
// Add this object to the list.
// This object is reachable, no longer garbage.
// It's back!
FinalizedList.Add(this);
}
}

BadClass 对象执行其 finalizer 时,会将指向自身的引用添加到全局变量表中,使得程序能够再度访问该对象,使得此对象复活。这会造成很大问题。由于 finalizer 已经执行过了,因此垃圾回收器不会再执行其 finalizer ,于是这个复活的对象将不会被系统做终结 (finalize)。其次,该对象引用的资源可能无效了。对于那些只通过 finalizer 队列中对象访问的资源来说,GC 将不会把他们从内存中移除,但这些资源的 finalizer 可能已经执行过了,这些资源基本上不能再使用了。请不要采用此写法。

应该不会有人在终结对象时故意将其复活。但此例说明,若想在 Dispose 和 finalizer 中调用其他的函数以执行一些工作,请仔细考虑,这些操作可能会导致 bug,最好是将其删除,使得 Dispose 和 finalizer 只用来释放资源。

对于运行在托管环境的程序来说,开发者不需要给自己的每一个类都编写 finalizer。只有当其中包含了非托管资源或是带有实现了 IDisposable 接口的成员,才需要添加 finalizer。注意,在只需 IDisposable 接口但不需要 finalizer 的场合下,还是应该把整套模式写出,使得子类可轻松的实现标准的 dispose 方案。

(Ryuu:作者所述许多要点,文档中都有具体实现,推荐查看 (本书作者也是 dotnet docs 的作者,Bill wagner’s github overview)。终结器 - C# 编程指南非托管类型 - C# 参考IDisposable 接口 (System)实现 Dispose 方法)