【.NET C#基础】IDisposable接口 - 需要介入的资源管理

【.NET C#基础】IDisposable接口 - 需要介入的资源管理

0.目录

1.什么是IDisposable接口?

IDisposable接口是一个用于约定可进行释放资源操作的接口,一个类实现该接口则意味着可以使用接口约定的方法Dispose来释放资源。其定义如下:

public interface IDisposable
{
    void Dispose();
}

上述描述中可能存在两个问题:

  1. 什么是“资源”?
  2. C#是一个运行在含有垃圾回收(GC)平台上的语言,为什么还需要手动释放资源?

1.1.资源

资源包括托管资源和非托管资源,具体来说,它可以是一个普通的类实例,是一个数据库连接,是一个文件指针,或者是一个窗口的句柄等等。不太准确地说,你可以理解为就是程序运行时用到的各种东西。

1.2.为什么要手动释放资源

对于托管资源,通常来说由于CLR的GC的帮助,可以自动释放回收而无需程序员手动管理。然而,由于C#允许使用非托管资源,这些非托管资源不受GC的控制,无法自动释放回收,因此对于这类资源,就要程序员进行手动管理。另一方面,有些资源虽然是托管资源,但是实际包装了一个非托管资源,并实现了IDisposable接口,同样的,对于这类资源,最好也手动管理。

CLR,Common Language Runtime,即C#编译后的IL代码的运行平台
GC,Garbage Collection,垃圾回收,即一种用于自动回收资源的机制

如果你写过C++,这就相当于应该在实例销毁时释放掉成“new”出来的分配到堆上的资源,否则资源将一直保留在内存中无法释放,导致内存泄漏等一系列问题。

在C++中,通常将资源释放的操作放置在类的析构函数中,但C#并没有析构函数这一概念,因此,C#使用IDisposable接口来对资源释放做出约定——当程序员看到一个类实现IDisposable接口时,就应该想到在使用完该类的实例后就应该调用其Dispose方法来及时释放资源。

对于实现了IDisposable接口的类,在C#中你通常可以采用如下方式来释放资源:

(1) try...finally

UnmanagedResource resource = /* ... */;

try
{
    // 各种操作
}
finally
{
    resource.Dispose();
}

在finally中释放是为了确保即便运行时出错也可以顺利释放资源

(2) using

using (UnmanagedResource resource = /* ... */)
{
    // 离开using的作用域后会自动调用resource的Dispose方法
}
 
// 或者如果不需要额外控制作用域的简写
using UnmanagedResource resource = /* ... */;

实际上,哪怕不实现IDisposable接口,只要类实现了public void Dispose()方法都可以使用using进行管理。
using本质上是try...finally的语法糖,所以即便using块中抛出异常也可以正常释放资源。

2.如何实现IDisposable

2.1.不太完美的基本实现

你可能还会认为IDisposable很容易实现,毕竟它只有一个方法需要实现,并且看上去只要在方法里释放掉需要释放的资源即可:

class UnmanagedResource : IDisposable
{
    public void Dispose()
    {
        // 释放需要释放的资源
    }    
}

通常来说这样做也不会有什么大问题,然而,有几个问题需要考虑。接下来将逐步阐述问题并给出解决方案。

2.2.如果使用者忘记了调用Dispose方法释放资源

尽管程序员都应该足够细心来保证他们对那些实现了IDisposable接口的类的实例调用Dispose方法,但是,出于各种原因,或许是他是一名新手,或许他受到老板的催促,或许他昨天没睡好等等,这些都可能导致他没有仔细检查自己的代码。

永远不要假设你的代码会被一直正确地使用,总得留下些兜底的东西,提高健壮性——把你的用户当做一个做着布朗运动的白痴,哪怕他可能是个经验丰富的程序员,甚至你自己。

对于这样的问题,最自然的想法自然是交给GC来完成——如果程序员忘记了调用Dispose方法释放资源,就留着让GC来调用释放。还好,C#允许你让GC来帮助你调用一些方法——通过终结器。

关于终结器的主题会是一个比较复杂的主题,因此在这里不展开讨论,将更多的细节留给其他主题。就本文而言,暂时只需要知道终结器的声明方法以及GC会在“某一时刻”自动调用终结器即可。(你或许想问这个“某一时刻”是什么时候,这实际上是需要交给复杂主题来讨论的话题)

声明一个终结器类似于声明一个构造方法,但是需要在方法的类名前添加一个~。如下:

class UnmanagedResource : IDisposable
{
    // UnmanagedResource的终结器
    ~UnmanagedResource()
    {
        // 一些操作
    }
}

一个类中只能定义一个终结器,且终结器不能有任何访问修饰符(即不能添加public/private/protected/internal)
永远不要手动调用终结器(实际上你也无法这么做)

由于GC会在某一个时刻自动调用终结器,因此如果在终结器中调用Dispose方法,即使有粗心的程序员忘记了手动释放资源,GC也会在某一时刻来帮他们兜底。如下:

class UnmanagedResource : IDisposable
{
    public void Dispose()
    {
        // 释放需要释放的资源
    }  
 
    ~UnmanagedResource()
    {
        // 终结器调用Dispose释放资源
        Dispose();
    }
}

你或许会觉得终结器很像C++的析构函数,无论是声明方式还是作用(释放资源)上,但是终结器和析构函数有本质上差别,但这里不展开讨论。

2.3.手动调用了Dispose后,终结器再次调用Dispose

当你手动调用了Dispose方法后,并不表示你就告诉了GC不要再调用它的终结器,实际上,在你调用Dispose方法后,GC还是会在某一时刻调用终结器,而由于我们在终结器里调用了Dispose方法,这会导致Dispose方法再次被调用——Double Free!

当然,要解决这一问题非常简单,只需要用一个字段来表明资源是否被释放,并在Dispose方法里检查这个字段的值,一旦发现已经释放则过就立刻返回。如下:

class UnmanagedResource : IDisposable
{
    public void Dispose()
    {
        // 如果已经释放过就立刻返回
        if (_disposed)
        {
            return;
        }
   
        // 释放需要释放的资源
        
        // 标记已释放
        _disposed = true;
    }  
 
    ~UnmanagedResource()
    {
        Dispose();
    }
 
    // 用于标记是否已经释放的字段
    private bool _disposed;
}

这样可以解决资源被重复释放的问题,但是这还是无法阻止GC调用终结器。当然你或许会认为让GC调用终结器没什么问题,毕竟我们保证了Dispose重复调用是安全的。不过,要知道终结器是会影响性能的,因此为了性能考虑,我们还是希望在Dispose方法调用后阻止终结器的执行(毕竟这时候已经不需要GC兜底了)。

而要实现这一目标十分简单,只需要在Dispose方法中使用GC.SuppressFinalize(this)告诉GC不要调用终结器即可。如下:

class UnmanagedResource : IDisposable
{
    public void Dispose()
    {
        if (_disposed)
        {
            return;
        }
   
        // 释放需要释放的资源
 
        _disposed = true;
       
        // 告诉GC不要调用当前实例(this)的终结器
        GC.SuppressFinalize(this);
    }  
 
    ~UnmanagedResource()
    {
        Dispose();
    }
 
    private bool _disposed;
}

这样,如果调用了Dispose方法,就会“抑制”GC对终结器的调用;而让终结器调用Dispose也不会产生什么问题。

2.4.不是任何时候都需要释放所有资源

考虑一个比较复杂的类:

class UnmanagedResource : IDisposable
{
   // 其他代码
   
    private FileStream _fileStream;
}

上述例子中,FileStream是一个实现了IDisposable的类,也就是说,FileStream也需要进行释放。UnmanagedResource不仅要释放自己的非托管资源,还要释放FileStream。你或许认为只需要在UnmanagedResource的Dispose方法中调用一下FileStream的Dispose方法就行。如下:

class UnmanagedResource : IDisposable
{
    // 其它代码    
    
    public void Dispose()
    {
        // 其他代码
 
        _fileStream.Dispose();
 
        // 其它代码
    }
 
    private FileStream _fileStream;
}

咋一看没什么问题,但是考虑一下,如果UnmanagedResource的Dispose方法是由终结器调用的会发生什么?

提示:终结器的调用是无序的。

是的,很可能FileStream的终结器先被调用了,执行过了其Dispose方法释放资源,随后UnmanagedResource的终结器调用Dispose方法时会再次调用FileStream的Dispose方法——Double Free, Again。

因此,如果Dispose方法是由终结器调用的,就不应该手动释放那些本身就实现了终结器的托管资源——这些资源的终结器很可能先被执行。仅当手动调用Dispose方法时才手动释放那些实现了终结器的托管资源。

我们可以使用一个带参数的Dispose方法,用一个参数来指示Dispose是否释放托管资源。稍作调整,实现如下:

class UnmanagedResource : IDisposable
{
    // 其它代码
    private void Dispose(bool disposing)
    {
        // 其他代码
       
        if (disposing)
        {
            // 释放托管资源
            _fileStream.Dispose();
        }
       
        // 释放非托管资源
 
        // 其它代码
    }
}

上述代码声明了一个接受disposing参数的Dispose(bool disposing)方法,当disposing为true时,同时释放托管资源和非托管资源;当disposing为false时,仅释放托管资源。另外,为了不公开不必要的接口,将其声明为private。

接下来,只需要在Dispose方法和终结器中按需调用Dispose(bool disposing)方法即可。

class UnmanagedResource : IDisposable
{
    // 其它代码
 
    public void Dispose()
    {
        // disposing=true,手动释放托管资源
        Dispose(true);
        GC.SuppressFinalize(this);
    }    
    
    ~UnmanagedResource()
    {
        // disposing=false,不释放托管资源,交给终结器释放
        Dispose(false);
    }
    
    private void Dispose(bool disposing)
    {
        if (_disposed)
        {
            return;
        }
   
        if (disposing)
        {
            // 释放托管资源
        }
 
        // 释放非托管资源
 
        _disposed = true;
    }
}

2.5.考虑一下子类的资源释放

考虑一下如果有UnmanagedResource的子类:

class HandleResource : UnmanagedResource
{
    private HandlePtr _handlePtr;
}

HandleResource有自己的资源HandlePtr,显然如果只是简单继承UnmanagedResource的话,UnmanagedResource的Dispose方法并不能释放HandleResource的HandlePtr。

那么怎么办呢?使用多态,将UnmanagedResource的Dispose方法声明为virtual并在HandleResource里覆写;或者在HandleResource里使用new重新实现Dispose似乎都可以:

// 使用多态
class UnmanagedResource : IDisposable
{
    public virtual void Dispose() { /* ... */}
}
class HandleResource : UnmanagedResource
{
    public override void Dispose() { /* ... */}
}

// 重新实现
class UnmanagedResource : IDisposable
{
    public void Dispose() { /* ... */}
}
class HandleResource : UnmanagedResource
{
    public new void Dispose() { /* ... */}
}

这两种方法似乎都可行,但是一个很大的问题是,你还得对HandleResource重复做那些在它的父类UnmanagedResource做过的事——解决重复释放、定义终结器以及区分对待托管和非托管资源。

这太不“继承了”——显然,有更好的实现方法。

答案是:将UnmanagedResource的的Dispose(bool disposing)方法访问权限更改为protected,并修饰为virtual,以让子类访问/覆盖:

class UnmanagedResource : IDisposable
{
    protected virtual void Dispose(bool disposing) { /* ... */ }
}

这样,子类可以通过覆写Dispose(bool disposing)来实现自己想要的释放功能:

class HandleResource : UnmanagedResource
{
    protected override void Dispose(bool disposing)
    {
        // 其他代码
        
        base.Dispose(disposing);
    }
}

建议先释放子类资源,再释放父类资源

由于Dispose(bool disposing)是虚方法,因此父类UnmanagedResource的终结器和Dispose方法中对Dispose(bool disposing)的调用会受多态的影响,调用到正确的释放方法,故子类可以不必再做那些重复工作。

3.异步释放 - IAsyncDisposable

IAsyncDisposable接口是IDisposable接口的异步版,其设计于处理那些可能造成明显阻塞的清理操作,通过异步释放来避免阻塞。其定义如下:

public interface IAsyncDisposable
{
    ValueTask DisposeAsync();
}

看起来和IDisposable接口定义基本一致,唯一的区别就是IAsyncDisposable具有异步特征(返回Task/ValueTask,方法名带Async后缀)。

另外需要注意IAsyncDisposable不是IDisposable的子集(即不包含IDisposable)。不过,尽管IAsyncDisposable不是IDisposable的子集,但是通常的建议是,实现IAsyncDisposable接口的时候应当同时实现IDisposable接口——除非确实无法实现同步释放。

3.1 使用

IAsyncDisposable和IDisposable在使用上基本相似,唯一的区别就是需要加入await关键字。

(1) try...catch

UnmanagedResource res = /* ... */;
 
try
{
    // 一些操作
}
finally
{
    // 此处添加await等待DisposeAsync操作
    await res.DisposeAsync();
}

(2) using

await using (UnmanagedResource res = /* ... */)
{
    // 一些操作
}
 
// 或者如果不需要考虑作用域
await using UnmanagedResource res = /* ... */;

3.2.与Disposable重要的区别

3.2.1.终结器不需要异步方法

由于终结器不支持异步(你无法await),因此在终结器中调用其DisposeAsync方法是不太明智的。当然,直接调用而不等待在某些情况下或许可行,但无论如何都不建议这样做。

不过,也因为不建议在终结器中调用DisposeAsync,因此实现IAsyncDisposable的时候反而不用像实现IDisposable那样考虑终结器以及因为终结器带来的各种问题。当然,也正因为如此,实现IAsyncDisposable接口的时候应当同时实现IDisposable接口,否则如果程序员忘记了调用DisposeAsync方法,就很可能造成资源泄露,因此还是需要一些兜底。

3.2.2.释放什么资源

你可能认为IAsyncDisposable就是IDisposable的异步版,只是一个异步释放托管/非托管资源,另一个同步释放。然而,真的需要IAsyncDisposable清理非托管资源吗?考虑下面几个问题:

  1. 是否存在释放需要大量CPU/IO时间的非托管资源?
  2. 这样的非托管资源是否提供了能够用于.NET异步的释放方法?

对于第一个问题,实际上绝大部分非托管资源无非就是一个指针,绝大部分情况下释放一个指针指向的资源很难成为什么影响性能的操作——考虑释放指针的压力,这在哪怕是在C/C++等这类对性能敏感的语言中也十分罕见。

对于第二个问题,如果真的就是要释放一个比较复杂的非托管资源,你会发现它也几乎不太可能提供一个可以让你“await”的异步释放方法。Marshal类的各种Free方法也没有异步版——毕竟那压根没必要。

所以结论是:一般情况下,IAsyncDisposable基本不用考虑释放非托管资源(实际就是如果你真的遇到了如此罕见的情况,那么你应该知道该怎么做)。

那么IAsyncDisposable究竟释放什么资源?

答案是,实现了IAsyncDisposable的托管资源。

class UnmanagedResource : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        await _socket.DisposeAsync();
    }
 
    // 假设Socket类实现了IAsyncDisposable
    private Socket _socket;
}

上述代码中UnmanagedResource包含了一个Socket类型的字段,假设Socket类实现了IAsyncDisposable,那么你可以在UnmanagedResource的DisposeAsync中调用Socket的DisposeAsync来释放它。

当然一个小问题是,也不可能就不清理非托管资源了。答案是:除非你只实现了IAsyncDisposable接口,否则可以交给IDispose实现,毕竟释放这些资源不需要异步操作。

3.3.IAsyncDisposable的实现

3.3.1.简单实现

上一节中已经给了一个简单的案例,这一节再更详细讨论IAsyncDisposable的实现。类似于IDisposable接口的实现,它的基本结构如下:

class UnamangedResource : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        // 清理(托管)资源
        if (_socket != null)
        {
            await _socket.DisposeAsync();
            _socket = null;  
        }
 
        // 清理非托管资源(如果有的话)
    }
 
    private Socket _socket;
}

这里为了减少代码信息量,不使用空值传播运算符、可空引用类型和is not判空

实际上一般来说,上述例子其实就比较完善足以应付一般情况了,唯一需要考虑的只是子类问题(当然,如果类是密封(sealed)类不需要考虑)。

为了方便子类的调用,类似IDisposable,我们将释放主体实现提取到一个protected virtual方法中,该方法命名为DisposeAsyncCore:

class UnamangedResource : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        await DisposeAsyncCore();
    }
   
    protected virtual async ValueTask DisposeAsyncCore()
    {
        // 清理(托管)资源
        if (_socket != null)
        {
            await _socket.DisposeAsync();
            _socket = null;  
        }
 
        // 清理非托管资源(如果有的话)
    }
 
    private Socket _socket;
}

这样子类就可以通过调用父类的DisposeAsyncCore方法来释放父类的资源,同时只需要覆写DisposeAsyncCore方法就可以实现自己的释放操作:

class HandleResource : UnamangedResource
{
    protected override async ValueTask DisposeAsyncCore()
    {
        // 释放子类资源    
   
        // 调用父类的DisposeAsyncCore释放父类资源
        await base.DisposeAsyncCore();
    }
}

3.3.2.同时实现IDisposable

由于实现IAsyncDisposable也常常要实现IDisposable,因此实际上文的简单实现是有点缺陷的。暂时先不考虑方法的实现细节,我们仅从方法的抽象上去考虑问题。

class UnamangedResource : IDisposable, IAsyncDisposable
{
    public async ValueTask DisposeAsync();
    
    public void Dispose();
   
    ~UnamangedResource();
 
    protected virtual async ValueTask DisposeAsyncCore();
 
    protected void Dispose(bool disposing);
}

在同时实现IDisposable接口后,有两个问题需要考虑:

  1. DisposeAsync方法被调用后需要抑制终结器
  2. DisposeAsyncCore不再需要清理非托管资源,这一工作交给Dispose(bool disposing)即可。(前文说过绝大部分情况下非托管资源的清理考虑不上异步)

所以,更具体的实现如下(只写出IAsyncDisposable的部分,IDisposable的实现细节不变):

class UnamangedResource : IDisposable, IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        // 清理托管资源
        await DisposeAsyncCore();
       
        // disposing=false,只需要让其清理非托管资源
        Dispose(false);
       
        // 同样,抑制终结器
        GC.SuppressFinalize(this);
    }
 
    public void Dispose();
   
    ~UnamangedResource();
 
    protected virtual async ValueTask DisposeAsyncCore()
    {
        // 清理托管资源(异步和同步)
    }
 
    protected void Dispose(bool disposing);
}

4.代码总览

4.1.IDisposable实现

class UnmanagedResource : IDisposable
{
    // 对IDisposable接口的实现
    public void Dispose()
    {
        // 调用Dispose(true),同时释放托管资源与非托管资源
        Dispose(true);
        // 让GC不要调用终结器
        GC.SuppressFinalize(this);
    }    
    
    // UnmanagedResource的终结器
    ~UnmanagedResource()
    {
        // 调用Dispose(false),仅释放非托管资源,托管资源交给GC处理
        Dispose(false);
    }
    
    // 释放非托管资源,并可以选择性释放托管资源,且可以让子类覆写的Dispose(bool disposing)方法
    protected virtual void Dispose(bool disposing)
    {
        // 防止重复释放
        if (_disposed)
        {
            return;
        }
       
        // disposing指示是否是否托管资源
        if (disposing)
        {
            // 释放托管资源
        }
 
        // 释放非托管资源
        
        // 标记已释放
        _disposed = true;
    }
}

4.2.IAsyncDisposable实现

class UnamangedResource : IAsyncDisposable
{
    public async ValueTask DisposeAsync()
    {
        await DisposeAsyncCore();
    }
   
    protected virtual async ValueTask DisposeAsyncCore()
    {
        // 清理(托管)资源
        if (_socket != null)
        {
            await _socket.DisposeAsync();
            _socket = null;  
        }
 
        // 清理非托管资源(如果有的话)
    }
 
    private Socket _socket;
}

4.3.IAsyncDisposable + IDisposable实现

class UnmanagedResource : IDisposable, IAsyncDisposable
{
    // 对IDisposable接口的实现
    public void Dispose()
    {
        // 调用Dispose(true),同时释放托管资源与非托管资源
        Dispose(true);
        
        // 让GC不要调用终结器
        GC.SuppressFinalize(this);
    }
    
    // 对IAsyncDisposable接口的实现
    public async ValueTask DisposeAsync()
    {
        // 调用DisposeAsyncCore清理托管资源
        await DisposeAsyncCore();
        
        // 调用Dispose(false),仅释放非托管资源
        Dispose(false);
        
        // 让GC不要调用终结器
        GC.SuppressFinalize(this);
    }
 
    // UnmanagedResource的终结器
    ~UnmanagedResource()
    {
        // 调用Dispose(false),仅释放非托管资源,托管资源交给GC处理
        Dispose(false);
    }
    
    // 释放非托管资源,并可以选择性释放托管资源,且可以让子类覆写的Dispose(bool disposing)方法
    protected virtual void Dispose(bool disposing)
    {
        // 防止重复释放
        if (_disposed)
        {
            return;
        }
       
        // disposing指示是否是否托管资源
        if (disposing)
        {
            // 释放托管资源
        }
 
        // 释放非托管资源
        
        // 标记已释放
        _disposed = true;
    }
    
    // 异步释放托管资源
    protected virtual async ValueTask DisposeAsyncCore()
    {
        // 清理托管资源(同步和异步)
    }
}

5.参考资料/更多资料

[1] IDisposable 接口
[2] 实现 Dispose 方法
[3] IAsyncDisposable 接口
[4] 实现 DisposeAsync 方法

posted @ 2023-09-11 15:47  HiroMuraki  阅读(1720)  评论(7编辑  收藏  举报