深入探讨 IDisposable(转载)

深入探讨 IDisposable
Shawn Farkas

 目录
可释放对象
Disposable 模式
托管资源与本机资源
托管资源清理
从 Disposable 类型中派生
释放和安全性
SafeHandle
结束语

公共语言运行库 (CLR) 为托管代码开发人员提供的一个主要生产优势是当不再需要为托管堆分配的任何内存时,垃圾收集器 (GC) 可确保将其清理。因此,开发人员无需排除因内存泄漏、试图使用已释放的内存以及重复释放内存所产生的问题,可以节省大量时间。然而,虽然垃圾处理器能够很好地确保内存不泄漏,但它对其他需要释放的资源毫无了解。例如,垃圾收集器不知道如何关闭一个文件句柄,以及如何使用 API(如 CoAllocTaskMem)来释放在托管堆之外分配的内存。
管理这些类型资源的对象必须确保不再需要这些资源时将其释放。您可以通过重写 System.Object 的 Finalize 方法来实现此结果,使垃圾收集器知道该对象希望参与其自身清理(在 C# 中使用 C++ 析构函数语法 ~MyObject,而不用直接重写该方法)。如果类有一个终结器,则在收集该类型的对象前,垃圾收集器将调用该对象的终结器,并且允许它清理任何正在占用的资源。
这个系统存在的一个问题就是:垃圾收集器不会确定地运行,其结果可能会使您的对象在上次引用之后很长时间不能被终结。如果您的对象占用了昂贵或稀少的资源(如一个数据库连接),这是不能被接受的。例如,如果只有 10 个可用的连接,而您的对象占用了一个 1 个打开的连接,它应该尽快释放该连接而不是等待垃圾收集器调用 Finalize 方法。

可释放对象
为了避免无休止地等待垃圾收集器运行,拥有资源的类型应该实现 IDisposable 接口,然后该类型资源的使用方会及时地释放那些资源。实现 IDisposable 对于您对象的使用方来说是一个提示,这些使用方在完成对象的使用后应尽快调用 Dispose,从而使对象在它们不需要资源时尽快释放。事实上,C# 和 Visual Basic® 都提供了一个“using”关键字来使这个过程尽可能轻松。在 C# 中,如果您有一个类 MyClass,它能够实现 IDisposable,则随后的代码会使编译器生成类似于图 1 中的代码。
 Figure 1 生成的代码
MyClass myClass = GetMyClass();
try
{
    myClass.DoSomething();
}
finally
{
    IDisposable disposable = myClass as IDisposable;
    if (disposable != null) disposable.Dispose();
}

using (MyClass myClass = GetMyClass())
{
    myClass.DoSomething();
}
这个 try/finally 块确保即使 using 块中的代码引发异常,MyClass 的 Dispose 方法仍有机会清理该对象。在调用 Dispose 方法之前,编译器生成的代码执行两件有意思的事情。第一,检查以确保可释放对象并非为空 — 这样,如果分配给变量的表达式计算结果恰好为空,using 块就不会在您的应用程序中引发 NullReferenceException。它还通过一个 IDisposable 引用来调用 Dispose,这允许显式实现 IDisposable 的类型仍然能被正确清理。
尽管 using 块能够使用具有显式 IDisposable 实现的类,但我还是建议这些类不要以这种方式实现接口。如果您显式地实现了 IDisposable,使用 Visual Studio® 中 IntelliSense® 探讨您的对象模型的开发人员不会注意到这个对象具有 Dispose 方法,因此可能不会利用早期清理,从而使得在垃圾收集器运行终结器前您的对象一直占用其资源。
从垃圾收集器的角度来看,IDisposable 只是另一个接口,这一点很重要。垃圾收集器不会区别对待可释放的对象和没有 IDisposable 实现的对象。特别是,垃圾收集器绝不会为您调用 Dispose 方法。垃圾收集器将会调用的唯一清理方法就是该对象的终结器;如果没有在编写的代码中明确调用 Dispose 方法,则绝不会调用该方法。

Disposable 模式
您已看到了,C# 和 Visual Basic 中的“using”结构可以轻松地清理可释放的对象。另一方面是需要创建未 disposable 类型的开发人员。因为 IDisposable 接口只定义了一种方法,所以 disposable 类型的开发人员似乎应该能够快速而轻松地实现该接口。然而,正如 Joe Duffy 在网站 IDisposable 指南中指出的,实现真正强大的 disposable 类型需要注意许多细节之处。
对于 Dispose 方法的一个主要发现是 Dispose 方法所做的清理工作与对象的终结器所做的清理工作极其相似。因为避免代码重复始终是有利的,所以最好这两个位置共享此清理代码而不是在两者间进行复制和粘贴。这还有益于使所有该类型的清理代码都保持在同一个位置。共享此代码的明显做法是将所有清理代码置于 Dispose 方法中,然后只需将该对象的终结器调用到 Dispose 中:
public class MyClass : IDisposable
{
    ~MyClass()
    {
        Dispose();
    }

    public void Dispose()
    {
        // Cleanup
    }
}
尽管这确实能够将所有清理代码保留在一个位置,但以此方式来实现终结器存在一个问题。尽管一些清理代码可以在 Dispose 和终结器之间共享,实际上在 Dispose 方法中可能会有一组您要释放的资源,但是您不想在终结过程中将其释放。

托管资源与本机资源
因为终结器不会以确定的顺序运行,所以您不能保证在终结器运行时,您的对象引用的对象尚未被终结。以下列各类为例:
public class Database : IDisposable
{
    private NetworkConnection connection;
    private IntPtr fileHandle;

    // ...
}

public class NetworkConnection : IDisposable
{
    private IntPtr connectionHandle;

    // ...
}
Database 类包括两种资源,一种是 NetworkConnection 类型,一种是 IntPtr 类型,后者代表一个文件句柄。虽然仍会有对 Database 类型的对象的活动引用,但垃圾收集器不会收集通过连接成员变量引用的 NetworkConnection 对象。然而,如果垃圾收集器在调用 Database 对象的终结器,则您可以知道不再有对该对象的实时引用了,因此可对该对象进行收集。
如果没有对 NetworkConnection 对象的其他实时引用,则可能在执行数据库终结器之前该对象已被终结。使用一个已被终结的对象是一个糟糕的想法,因为该对象很可能处于不可用状态;因为无法保证终结的顺序性,所以 Database 终结器不应该引用连接域。
然而,如果要通过调用 Dispose 方法进行清理工作,则因为一定存在对已调用的 Dispose 方法的对象的实时引用,该对象不能被终结。您的 Database 对象仍旧处于活动状态,充当连接域引用的 NetworkConnection 的 GC 根,从而防止 NetworkConnection 对象被终结。因此,在 Dispose 方法中,访问可终结的成员变量是安全的。
不过,这种情况对于 fileHandle 域会有所不同。这个句柄域只是一个 IntPtr,垃圾收集器不会将其终结。这就意味着无论在终结器还是在 Dispose 方法中访问 fileHandle 域都是安全的。
由此原则得出,在 Dispose 方法中清理对象所占用的所有资源是安全的,不管它们是托管对象还是本机资源。然而,在终结器中只有清理非终结的对象才是安全的,并且通常终结器只应用于释放本机资源。
有趣的是,我们在此示例中发现,尽管连接域明确包含本机连接句柄,但它不会被视为本机资源;而 NetworkConnection 类的内部实现不包括本机资源,从 Database 类的角度来看,NetworkConnection 是一个托管资源,因为 NetworkConnection 类型负责管理其自有资源的生存期。但在 NetworkConnection 类中,connectionHandle 是一种本机资源,因为除非在编码中明确指定为清理,否则它不会被清理。
假设您希望在释放过程中清理所有资源,在终结过程中清理这些资源的子集,您可以将清理代码包括在同时由终结器和 Dispose 方法调用的方法中。此时,这个新方法可确保您在释放过程中清理所有资源,如果对象正在被终结则只有本机资源被清理。这要求您创建一个符合图 2 中模式的可释放实现。
 Figure 2 基本 IDisposable 模式
public class DisposableClass : IDisposable
{
    ~DisposableClass()
    {
        Dispose(false);
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            // Clean up all managed resources
        }
            
        // Clean up all native resources
    }
}

您仍然实现了避免代码重复和使清理代码保留在一处的最初目标,因为现在所有的清理代码都将在 Dispose 方法中重写。我将在下文中作为清理方法来介绍此方法,以免将它与其他 Dispose 方法混淆。该清理方法将采用布尔参数,指示您采用释放代码方式还是采用终结代码方式。在这两种代码方式中,您都应确保清理类所占用的本机资源。此外,在释放代码方式中,还应该清理托管资源。
由于您不希望类的使用方直接调用该清理方法,所以该清理方法无需公开。相反,它们可以通过 IDisposable 接口来访问您的清理逻辑。虽然类的使用方无需直接调用您的清理方法,但是任何子类都要调用该清理方法,因此清理方法被标记为“受保护”而不是“专有”(以后会介绍更多有关子类化可释放类型的信息)。
由于 Dispose 将释放对象所占用的所有资源,那么垃圾收集器就没有理由终结该对象。要通知垃圾收集器无需终结该对象(即使该对象有终结器),请在 Dispose 方法中执行清理代码后调用 GC.SuppressFinalize。
事实上,如果一种类型没有任何本机资源,那么由终结器执行的代码便不能对其执行任何操作。在那些情况下,该类不应定义为终结器方法。
在该数据库示例中,新的清理方法可能看起来如图 3 中所示。如果 Database 对象正在被释放,那么它会同时清理连接托管资源和 fileHandle 本机资源。然而,如果该对象正在被终结,那么它只会清理 fileHandle 资源。请注意,可安全调用多次清理代码。第一次调用是通过 Dispose 方法进行的,该调用会释放 Database 对象正在占用的所有资源;接下来对 Dispose 的调用会发现资源已被释放,调用将退出而不再尝试释放资源。应对 Dispose 实现进行编码,以便即使被调用多次,即使出现不必要的额外调用也能成功地实现。如果在对象被释放后调用其他方法,则可能会引发 ObjectDisposedException。
 Figure 3 数据库清理方法示例
protected virtual void Dispose(bool disposing)
{
    if (disposing)
    {
        if (connection != null)
            connection.Dispose();
    }

    if (fileHandle != IntPtr.Zero)
    {
        CloseHandle(fileHandle);
        fileHandle = IntPtr.Zero;
    }
}

另一个有趣的发现是注意清理代码不会引发任何异常。通常,开发人员都希望在清理对象时不引发异常。这是因为在使用清理代码方法时实际上无法从异常中恢复。如果在清理时对象失败,那么该对象的使用方只能重试并希望第二次能成功,或者干脆放弃清理并允许一些资源不正确地释放。
为了确保清理代码不引发异常,您应该一定检查在清理方法中访问的所有对象引用以确保它们非空,并且仅调用不会失败的方法。这在对象终结过程中极其重要。尽管在 Dispose 调用过程中的某个异常现象会令开发人员处境困难,但默认情况下终结器线程上未处理的异常会使 CLR 版本 2.0 中的整个进程瘫痪。
即使您未在终结器线程中运行,在清理过程中引发异常也可能使您无法正常清理其他资源。例如,请参考图 4。在这里,DisposableClass 包括 Foo 和 Bar 两种类型的资源。如果 Foo.Dispose 在执行过程中引发异常,DisposableClass 就永没有机会释放 Bar 对象。
 Figure 4 Dispose 中出现异常的危险
public class Disposableclass : IDisposable
{
    Foo a;
    Bar b;

    // ...

    protected virtual void Dispose(bool disposing)
    {
        if (disposing)
        {
            if (a != null) a.Dispose();
            if (b != null) b.Dispose();
        }
    }
}


托管资源清理
因此,如果对象被终结时托管资源没有被清理,那么是什么阻止它们泄漏呢?哦,如果这些对象包括需要清理的资源,那么它们应该遵循 IDisposable 模式。因为对象终结器不能分辨托管资源是否已被终结,所以它无法直接对其清理。然而,如果这些类型包括需要释放的资源,则它们还应该具有垃圾收集器可运行的终结器;如果它们只包括托管资源,则无需使用终结器。
让我们看看这方面的示例。假设一个托管控件使用一个 Cookie 类在多个浏览器会话中维护其状态。该 Cookie 类可能通过使用 IsolatedStorage 来实现,而 IsolatedStorage 本身可能通过使用 FileStream 来实现。该 FileStream 可能包含一个 Win32® 文件句柄,这样最终类的代码看起来如图 5 中所示。
 Figure 5 在类层次结构中实现 IDisposable
public class FileStream : IDisposable
{
    private IntPtr fileHandle;

    // ...

    protected void Dispose(bool disposing)
    {
        if (fileHandle != IntPtr.Zero)
        {
            CloseHandle(fileHandle);
            fileHandle = IntPtr.Zero;
        }
    }
}

public class IsolatedStorageFileStream : IDisposable
{
    private FileStream file;

    // ...

    protected void Dispose(bool disposing)
    {
        if (disposing)
        {
            if (file != null)
                file.Dispose();
        }
    }
}

public class Cookie : IDisposable
{
    private IsolatedStorageFileStream isolatedStore;

    // ...

    protected void Dispose(bool disposing)
    {
        if (disposing)
        {
            if (isolatedStore != null)
                isolatedStore.Dispose();
        }
    }
}

如果该控件没有释放其 Cookie 对象,则 Cookie 不会让终结器来清理其 IsolatedStorageFileStream,因为这个流是托管资源。由于垃圾收集器将为您收集流对象,所以这一点很不错。同样,因为 IsolatedStorageFileStream 仅包括托管资源,所以它无需终结器。这一点仍然不错,因为 FileStream 对象确实拥有自己的本机资源,从而将包括一个终结器。当垃圾收集器终结 FileStream 时,它会关闭句柄。即使无人处理对象图表中的根对象,也不会有任何资源泄漏,因为调用的每个类都遵循 disposable 模式。

从 Disposable 类型中派生
如果从 disposable 类型中派生,并且派生出来的类型没有引入任何新资源,则无需做任何特殊处理。基本类型 IDisposable 实现将负责清理其资源,子类则可以借此而忽略细节。然而,子类包括需要清理的新资源也是很常见的。在此情况下,类需要释放其资源,同时确保基本类型的资源也得到释放。方法是重写清理方法、释放资源,然后调用基本类型来清理其资源,如图 6 所示。
 Figure 6 重写 Dispose
public class DisposableBase : IDisposable
{
    ~DisposableBase()
    {
        Dispose(false);
    }

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        // ...
    }
}

public class DisposableSubclass : DisposableBase
{
    protected override void Dispose(bool disposing)
    {
        try
        {
            if (disposing)
            {
                // Clean up managed resources
            }

            // Clean up native resources
        }
        finally
        {
            base.Dispose(disposing);
        }
    }
}

请注意,所派生的类型必须只重写清理方法,因为继承版本的公用 Dispose 方法和终结器均会准确地执行您所需的操作。此规则存在一个例外,即基本类型因为没有任何本机资源而无法拥有一个终结器,所派生的类型又恰好有本机资源。在此情况下,必须为所派生的类型添加一个终结器。
在派生的类型中,您要确保在调用基本类型的清理方法之前清理资源。这样就会使对象以与构建时相反的顺序被销毁。如果您首先调用基本类型的清理方法,您可能会处于这样一种状态:基本类型已销毁了对象的一部分,而您又依赖该对象来完成清理方法。

释放和安全性
编写清理代码的一个要点与其可运行的安全环境有关。不管执行线程扮演什么角色,所有的清理代码都必须能够运行。因为垃圾收集器在一个专用线程上运行终结器,所以在实例化可释放对象的线程上活动的任何模拟在终结器运行时都不能就位。此外,也无法确保在构建对象和调用 Dispose 之间,使用您的类型的代码尚未回复线程的模拟活动或模拟其他的身份。
不正确处理模拟活动的类的示例是 Microsoft® .NET Framework 2.0 中的 RSACryptoServiceProvider 类。如果 RSACryptoServiceProvider 对象创建了一个临时密钥并与之共同使用,该对象的终结器将试图删除该密钥;此密钥存储在创建该密钥的线程运行所使用的用户的配置文件中。如果当线程正在模拟用户时创建了一个 RSACryptoServiceProvider 对象,而对象未被释放,那么问题就会出现了。在这种情况下,终结器将在一个不同于对象构建者的用户环境中运行。如果终结器试图删除该密钥,则会引发异常,因为终结器线程运行时使用的用户可能没有访问储存密钥的用户配置文件的权限。
终结器在单独的线程上运行的事实还意味着不能依靠检查 Dispose 方法中调用堆栈的权限。如果对象的终结器正在调用清理方法,那么调用堆栈上将不存在任何代码,并且任何代码访问安全性检查都没有意义。(通常,无论如何在清理代码中应该避免安全要求,因为它们可能会引发异常。)

SafeHandle
.NET Framework 2.0 引入了 SafeHandle 类以帮助管理资源。SafeHandle 包装一个本机资源,这与使用 IntPtr 来占用资源相比有若干优势。本文并不探讨 SafeHandle 的所有细节,您可以在 Brian Grunkemeyer 的关于基类库 (BCL) 团队博客的精彩帖子里找到更多信息。
在此可以得出一种很有见解的看法,即随着 SafeHandle 的引入,并非从 SafeHandle 派生的类型不再有任何理由包含原始的本机资源。相反地,对象应该只包含托管资源,包括它们所用的任何本机资源的 SafeHandle 的子类。
遵循这种模式有以下几个好处:首先,除了 SafeHandles 外,其他类无需拥有终结器,因为只有 SafeHandles 拥有本机资源。其次,它创建了一种清理模式,其中每个 SafeHandle 都只拥有一种本机资源,并且托管对象可包含各种 SafeHandles 以满足其需求。

结束语
使用 CLR 垃圾收集器,您不必再担心如何管理对托管堆分配的内存,不过您仍需清理其他类型的资源。托管类通过 IDisposable 接口使其使用方可以在垃圾收集器终结对象前释放可能很重要的资源。通过遵循 disposable 模式并且留意需注意的问题,类可以确保其所有资源得以正确清理,并且在直接通过 Dispose 调用或通过终结器线程运行清理代码时不会发生任何问题。

posted @ 2012-11-05 15:01  JACKGhost  阅读(625)  评论(0编辑  收藏  举报