C# 基础:托管堆与垃圾回收(part2:Dispose模式)

C# 基础:托管堆与垃圾回收(part2:Dispose模式)

C# 基础 是《C# CLR via》(第四版)和《框架设计指南》(第三版)的笔记。

·

4 min read

每个程序都需要调用资源,包括文件、内存缓冲区、屏幕空间、网络连接、数据库资源等,这些资源可能比较稀缺,或者使用时性能开销巨大,开发人员在使用这些资源时必须谨慎,因为它们在获取和使用后需要被释放。

CLR 提供了自动内存管理支持,托管内存(使用 C# 操作符 new 分配的内存)无需显式释放,它由垃圾回收器(GC)自动释放,这将开发人员从释放内存的繁琐和管理内存的困境汇中释放出来。

但托管内存只是许多类型的系统资源中的一种,除了托管内存,其他昂贵的资源类型仍然需要显式释放;因此,它们被称为非托管资源。GC 不是专门为了管理这种非托管资源而设计的,这意味着管理这些非托管资源的责任在开发者手中。

Dispose 模式

IDisposable 接口

.NET 提供了 System.IDisponsable 接口,可以实现该接口为开发者提供手动模式,一旦不再需要非托管资源,就立即释放它们。它还提供 GC.SuppressFinalize 方法。可以告诉 GC 一个对象已经被手动释放,不需要再被终结。在这种情况下,该对象的内存可以被提前回收。

实现 IDisposable 接口的类型被称为可处置类型。

Note:

Dispose 方法可以用来释放稀缺或非托管资源,如果不调用 Dispose 方法,该对象将会在最后终结时释放资源

当一个类型实现了 IDisposable,并且所有权很明显时,应该尽可能在处理完对象后调用Dispose。但如果所有权复杂(例如,该对象在多处引用或在不同线程之间共享时),忽略 Dispose 调用一般不会有坏处。

Note:

实际上在用完一个对象并没有处置它时,有可能带来问题,例如 FileStream 将保持文件句柄始终打开,如果该句柄是以写模式打开的,那么在终结器运行前,机器上没有其他人能够打开该文件,而且写入内容在那之前不会被写入磁盘。

Dispose 模式简介

Dispose 模式的目的是规范终结器和 IDisposable 接口的使用和实现。这种模式的主要目的是降低 Finalize 和 Dispose 方法实现的复杂性。

使用 Dispose 模式需要注意:

  • 为包含可处置类型实例的类型实现基本 Dispose 模式。如果一个类型负责管理其他可处置对象的生命周期,开发者需要一种方法来释放它们,使用 Dispose 方法是一个方便的方式。

  • 在本身不持有,但子类型可能持有非托管资源或可处置对象的类上实现基本 Dispose 模式。

基本 Dispose 模式示例

该模式的基本实现包括实现System.IDisposable 接口,并声明一个Dispose(bool) 方法,该方法实现了在Dispose方法和可选的终结器之间共享的所有资源清理逻辑。

一个最简单的 Dispose 模式的框架大致长这个样子:

using System.Runtime.InteropServices;
using Microsoft.Win32.SafeHandles;

public class DisposableResourceHolder : IDisposable
{

    // 使用 SafeHandle 表示非托管资源
    private SafeHandle? _unmanagedResource;

    // 假设在此处取得了非托管资源
    public DisposableResourceHolder()
    {
        _unmanagedResource = new SafeFileHandle(IntPtr.Zero, true);
    }

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

    protected virtual void Dispose(bool disposing)
    {

        if (disposing)
        {
            if (_unmanagedResource != null)
            {
                _unmanagedResource?.Dispose();
            }
        }
    }
}

class Program
{
    static void Main()
    {
        // 方法一
        using (var resource = new DisposableResourceHolder())
        {
            // 使用资源
            // ...
        }

        // 方法二
        var manualResource = new DisposableResourceHolder();
        // 使用资源
        // ...
        manualResource.Dispose();
    }
}

解释一下这段代码:

DisposableResoruceHolder 类在调用构造函数后取得了一个 SafeHandle 表示的非托管资源, Dispose(bool) 函数通过 bool 类型的参数指示是否要释放该资源。

Dispose 函数中的 bool 类型参数 disposing 表明该方法是由 IDisposable.Dispose 还是由终结器调用的。

Dispose(bool) 实现应该在访问其他引用对象(例如,前面例子中的资源字段_unmanagedResource )之前检查disposing 参数,只有当该方法从 IDisposabe.Dispose 的实现中调用时(即当 disposing 参数等于 true 时),这些对象才能被访问。如果该方法是从终结器中调用的(当 disposing 参数为 false 时),则不应该访问其他对象。

GC.SuppressFinalize 用于通知垃圾回收器在对象已经执行了显式的资源清理(通过调用 Dispose 方法)后,不再调用该对象的终结器(Finalizer)。

Dispose 模式的注意点

Dispose 模式的注意点为以下几项:

✔声明一个受保护的虚方法 void Dispose(bool disposing),来集中与释放托管资源和非托管资源相关的所有逻辑。所有的资源清理都应该发生在这个方法中,该方法被终结器和 IDisposable.Dispose 方法调用。

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

✔通过简单的调用 Dispose(true)GC.SuppressFinalize(this) 来实现 IDisposable 接口。GC.SuppressFinalize(this) 接口在 Dispose(true) 之后才会触发,确保GC.SuppressFinalize(this) 会在 Dispose 完成后才会调用。

当 Dispose(true) 执行时,可能会抛出异常,但当后面的 GC.SuppressFinalize(this) 执行时,会调用 Dispose(false),这可能与之前的代码路径不同,如果这个路径可以执行,也没有问题。

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

❌不要将无参数的 Dispose 方法实现为虚方法。Dispose(bool) 才是应该由子类复写的方法。本质上还是上一条,IDisposable 接口应该以简单的方式调用。

❌除了 Dispose 和 Dispose(bool),不要再声明 Dispose 方法的任何重载。

Dispose 应该被视为一个保留字,以帮助规范 Dispose 模式。防止发生混淆,有的语言可能在某些类型上自动实现这个模式。

✔要允许 Dispose(bool) 方法被多次调用,该方法可以选择在第一次调用之后什么都不做。

...
// 使用一个布尔值表示是否已经释放资源 
private bool _disposed = false;

protected virtual void Dispose(bool disposing) 
{ 
    // 当资源已经被释放后,不执行任何操作直接返回。 
    if (_disposed) { return; }

    // 资源未被释放过,执行清理 
    if (disposing) 
    {
        if (_unmanagedResource != null && !_unmanagedResource.IsInvalid) 
        {
            _unmanagedResource.Dispose(); 
            _unmanagedResource = null; 
        } 
    } 
    _disposed = true; 
} 
...

❌避免从 Dispose(bool) 中抛出异常,除非是在进程被破坏的关键情况下(例如内存泄漏,共享状态不一致(尤其是多线程环境下,资源释放发生异常引起对象只有某些部分被释放)等)。

如果执行 Dispose(bool disposing) 方法,当 disposing 为false 时,切记不要抛出任何异常,如果在终结器的上下文中执行,这将导致进程终止。

❌任何成员在对象被释放后不能被继续使用,则应该抛出 ObjectDisposedException 异常。

// DisposableResourceHolder 中使用资源的方法 
public void DoSomething() 
{
    if(_disposed) 
    {
        throw new ObjectDisposedException(...); 
    }
    // Do Something ... 
}

❌在调用 Dispose()方法后,要避免对象重新获得有意义的状态。

当一个对象被释放的时候,它通常应该保持在被释放状态,对象的再水化(rehydration)经常会产生难以诊断的性能问题。

但如果该类型有明确表明新资源正在被获取的方法,该对象取得资源后不再抛出ObjectDisposedException异常也可能是合理的。

Note:

对象的再水化是什么?

再水化(rehydration)是指将数据或对象从一种状态还原到另一种状态的过程。这可以涉及从序列化或持久化的形式重新创建对象,或者从一种表示形式还原到另一种表示形式。

✔如果一个领域有其他的术语表示回收资源,除了 Dispose()方法,还要提供与术语对应的方法。

例如,“close”是标准术语,那就提供一个 Close()方法。

public class Stream : IDisposable
{
    IDisposable.Dispose()
    {
        Close();
    }

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

可终结类型

终结器

System.Object 声明了一个虚方法,Finalize [也被称为终结器(finalizer)],在对象的内存被 GC 回收之前被 GC 调用,这个方法可以被覆写用于释放非托管资源。

覆写终结器有两个显著缺点:

  • 当 GC 检测到一个对象有资格被回收时,将调用终结器。这会发生在资源不再需要后的某个不确定时间段,在需要大量获取稀缺资源(容易耗尽)的程序中,或者在资源使用成本很高(例如占用大量内存的非托管内存缓冲区)的情况下,开发者所希望的释放资源的时间和资源实际被终结器释放的时间之间的延迟可能是不可接受的。

  • 当 CLR 需要调用一个终结器时,会将对象的内存回收推迟到下一轮的垃圾回收(终结器在两次回收之间运行。因此,该对象的内存(以及他所引用的所有对象)将在较长的时间内不会被释放。

以下是一个使用终结器的糟糕示例,即便已经显示要求回收资源,仍然不会准时调用析构函数。

using System;

class FinalizableClass
{
    // 构造函数
    public FinalizableClass()
    {
        Console.WriteLine("Object of FinalizableClass created.");
    }

    // 析构函数
    ~FinalizableClass()
    {
        Console.WriteLine("Object of FinalizableClass finalized.");
        // 执行清理操作,但避免在析构函数中释放托管资源
        // 事实上,这个函数在程序运行完成之后,仍然不会正确调用
    }
}

class Program
{
    static void Main()
    {
        // 创建可终结类型的实例
        FinalizableClass? finalizableObj = new();

        // 不再引用对象,等待垃圾收集器回收
        finalizableObj = null;

        // 强制进行垃圾收集
        GC.Collect();
        GC.WaitForPendingFinalizers();

        Console.WriteLine("Main method finished.");
    }
}

Note:

如果可能的话,不要真的去实现一个终结器,除了难以控制终结器的具体执行时间外,在类型上写终结器还会导致该类型的使用成本更高,即使这个终结器从未调用。

分配一个可终结对象是比较昂贵的,因为它还会被放在可终结对象列表中,这个代价是无法避免的。

可终结类型

可终结类型是通过覆写终结器,并在 Dispose(bool) 方法中提供最终处理的代码路径来扩展基本 Dispose 模式的类型。

终结器非常难以正确实现,因为我们无法在执行过程中对系统状态做出正确的假设,如果基类已经是可终结的,而且实现了基本 Dispose 模式,就不应该再覆写 Finalize 方法,而是应该直接覆写 Dispose(bool) 方法提供额外的清理逻辑。

一个可终结类型的示例程序如下:

using System;

public class MyDisposableClass : IDisposable
{
    // 一些资源或托管资源的状态
    private bool disposed = false;

    // 一些非托管资源或需要手动释放的资源
    private IntPtr unmanagedResource;

    // 构造函数
    public MyDisposableClass()
    {
        // 初始化资源
        unmanagedResource = IntPtr.Zero;
        // 其他初始化逻辑
    }

    // 实现 IDisposable 接口的 Dispose 方法
    public void Dispose()
    {
        Dispose(true);
        // 防止 Finalize 方法被调用
        GC.SuppressFinalize(this);
    }

    // 实现 IDisposable 接口的 Dispose 方法,接受一个布尔参数
    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (disposing)
            {
                // 释放托管资源
                // 这里可以处理托管资源的清理工作
            }

            // 释放非托管资源
            // 这里可以处理非托管资源的清理工作,比如关闭文件、释放句柄等

            disposed = true;
        }
    }

    // 如果类中包含非托管资源,通常也会实现析构函数作为备用清理机制
    ~MyDisposableClass()
    {
        Dispose(false);
    }
}

class Program
{
    static void Main()
    {
        // 使用 using 语句确保资源在使用后被正确释放
        using (MyDisposableClass myObject = new MyDisposableClass())
        {
            // 进行一些操作
        }

        // myObject 在此处已经被自动释放
    }
}

可终结类型的注意点

❌不要让公开类型成为可终结类型。持有可终结资源的公开类型应该使内部或私有嵌套的类型成为可终结类型的持有者。要优先使用现成的资源包装器,例如 SafeHandle,尽可能封装非托管资源。

✔在每个基本类型上实现基本 Dispose 模式。这给该类型的使用者提供了一种方式,让他们可以明确的对那些由终结器负责的相同资源进行确定性清理。

❌不要访问终结器代码路径中任何可终结对象,因为有很大风险,它们可能已经被终结了。

❌不要在终结器逻辑中抛出异常,除非是系统关键性故障,如果一个终结器发生异常,CLR 将关闭整个进程,阻塞其他终结器的执行和可控释放。

限定作用域

在 .NET 中大部分操作都是一次性操作,比如记录单一的值,设置单一属性,或循环处理一个列表。但是,有的操作天然具有作用域,例如:获得一个锁和释放一个锁;创建文件、执行操作再释放文件。

using 自动调用 Dispose 方法

IDisposable 接口和 C# 的 using 语句允许将逻辑作用域和方法作用域对齐,调用者可以不再提供与“开始“方法对应的”结束“方法。C# 的 using 或 .NET 中其他语言的等价语句,都是在一个 finally 块中调用 Dispose()方法。

以创建一个 example.txt 文件,在其中写入一行文字,然后读取文件内容为例,展示手动调用 Dispose()方法以及using 自动调用 Dispose 方法,以此关闭文件流并释放相关资源。

首先,我们手动使用 try-finally 块来确保在 try 块内的代码执行完毕后,无论是否发生异常,都会在 finally 块中调用 fileStream.Close()fileStream.Dispose()方法来释放资源。这样做需要更多的手动管理,而且如果有多个资源需要释放,代码会变得更加复杂。

以下是手动调用 Dispose()方法的实现代码:

using System;
using System.IO;
using System.Text;

class Program
{x 
    static void Main()
    {
        FileStream fileStream = null;
        try
        {
            // 手动创建文件流,指定 FileMode.Create 以便写入文件
            fileStream = new FileStream("example.txt", FileMode.Create);

            // 写入内容到文件
            string contentToWrite = "Hello, this is an example.";
            byte[] contentBytes = Encoding.UTF8.GetBytes(contentToWrite);
            fileStream.Write(contentBytes, 0, contentBytes.Length);

            // 移动文件指针到文件开头,以便读取文件内容
            fileStream.Seek(0, SeekOrigin.Begin);

            // 读取文件内容
            StreamReader reader = new StreamReader(fileStream);
            string contentRead = reader.ReadToEnd();
            Console.WriteLine("File Content: " + contentRead);
        }
        finally
        {
            // 在这里,手动释放资源
            if (fileStream != null)
            {
                fileStream.Close();
                fileStream.Dispose();
            }
        }

        // 在这里,fileStream 不再可用,已经被释放
    }
}

接下来,我们使用 using 语句创建了一个 FileStream 对象(实现了 IDisposable 接口),并在 using 语句块内使用该文件流进行文件读取和编辑操作。一旦 using 语句块结束,FileStream 对象就会超出范围,自动调用其 Dispose 方法,这会关闭文件流并释放相关资源。这样可以确保资源在不再需要时得到及时释放,而不需要手动调用 CloseDispose 方法。

以下是使用 using 自动调用 Dispose 方法的示例代码:

using System;
using System.IO;
using System.Text;

class Program
{
    static void Main()
    {
        // 使用 using 语句创建文件流,确保在离开作用域时自动释放资源
        using (FileStream fileStream = new("example.txt", FileMode.Create))
        {
            // 写入内容到文件
            string contentToWrite = "Hello, this is an example.";
            byte[] contentBytes = Encoding.UTF8.GetBytes(contentToWrite);
            fileStream.Write(contentBytes, 0, contentBytes.Length);

            // 移动文件指针到文件开头,以便读取文件内容
            fileStream.Seek(0, SeekOrigin.Begin);

            // 读取文件内容
            using (StreamReader reader = new StreamReader(fileStream))
            {
                string contentRead = reader.ReadToEnd();
                Console.WriteLine("File Content: " + contentRead);
            }
        } 
        // 在这里,fileStream 超出范围,自动调用 Dispose 方法,关闭文件流
        // 在这里,fileStream 不再可用,已经被释放
    }
}

不要手动管理”开始“和”结束“方法

为了程序的正确性,当”结束“方法必须位于 finally 块中时,考虑返回一个可处置的值(一个实现了 IDisposable 接口的句柄),而不是让调用者手动管理”开始“和”结束“方法。

对于某些操作,比如加锁,如果不调用结束方法会导致死锁或其他难以解决的程序状态。

在如下示例中,开发者没有考虑如下可能性:如果获取锁和释放锁之间的代码抛出异常,那么锁就永远不会释放:

public void DoStuff()
{
    _lock.EnterReadlock();
    DoStuffCore();
    // 如果 DoStuffCore 发生异常,函数将永远无法到达此处
    _lock.ExitReadlock();
}

如果我们使用 using 语句,那么使用可处置的操作返回值会时调用者可以更可靠的在 finally 块中释放锁,示例代码如下:

public void DoStuff()
{
    using(_lock.GetReadLock())
    {
        DoStuffCore();
    }
}