Khởi đầu với C# - Bí quyết quản lý tài nguyên: Giải mã Dispose và Pattern giải phóng

Phần 1: Hiểu về Dispose trong C# Hãy xem một ví dụ đơn sử dụng .NET 8. Chúng ta định nghĩa một đối tượng ResourceHandler có phương thức Dispose. Sau đó thực thi đoạn mã sau trong console:

// Định nghĩa kiểu ResourceHandler
readonly struct ResourceHandler(Action action) 
{ 
    public void Dispose() => action?.Invoke(); 
}

// Phương thức Main
static void Main(string[] args)
{
    using var handler = new ResourceHandler(() => Console.WriteLine("Đã thực thi")); 
    Console.WriteLine("Xin chào, thế giới!");
}

// Kết quả xuất ra console:
// Xin chào, thế giới!
// Đã thực thi

Như bạn thấy, thứ tự xuất bị đảo ngược. Cấu trúc ResourceHandler này mô phỏng chức năng của từ khóa defer trong Golang. Câu lệnh using thực chất là một cú pháp đường giúp kiểm soát thời điểm gọi phương thức Dispose().

Đối với ref struct, đoạn mã trên tương đương với:

{
    ResourceHandler handler = new ResourceHandler(() => Console.WriteLine("Đã thực thi"));
    try
    {
        Console.WriteLine("Xin chào, thế giới!");
    }
    finally
    {
        handler.Dispose();
    }
}

Ở đây, khối try chứa các mã trong vòng đời của đối tượng handler.

Đối với DisposeAsync() không đồng bộ, using tương đương với:

{
    ResourceType resource = «expression»;
    try
    {
        «statement»;
    }
    finally
    {
        IAsyncDisposable d = (IAsyncDisposable)resource;
        if (d != null)
        {
            await d.DisposeAsync();
        }
    }
}

Phần 2: Tại sao cần Dispose? C# sử dụng cơ chế thu gom rác tự động để quản lý bộ nhớ, giúp lập trình viên không cần quản lý cấp phát và giải phóng bộ nhớ thủ công, giảm thiểu các vấn đề rò rỉ bộ nhớ và con trỏ trỏ đến địa chỉ không hợp lệ. Tuy nhiên, bộ thu gom rác chỉ chịu trách nhiệm thu hồi bộ nhớ được quản lý, không thể tự động quản lý tài nguyên không được quản lý. Hơn nữa, thời gian chạy của bộ thu gom rác không xác định, nó có thể chạy rất lâu sau khi tài nguyên không còn cần thiết. Vì vậy, cần một cơ chế để chủ động giải phóng tài nguyên không được quản lý, đó là một trong những lý do xuất hiện của Dispose.

Trong phát triển C#, chúng ta thường sử dụng nhiều tài nguyên như tệp, kết nối cơ sở dữ liệu, v.v. Sau khi sử dụng, các tài nguyên này cần được giải phóng kịp thời, nếu không sẽ chiếm dụng tài nguyên hệ thống và ảnh hưởng đến hiệu suất chương trình. Phương thức Dispose được sử dụng để giải phóng các tài nguyên này. Khi không còn cần một đối tượng nào đó, chúng ta cần chủ động hoặc bị động gọi phương thức Dispose để trả lại tài nguyên cho hệ thống, tránh rò rỉ tài nguyên.

Nói đơn giản, Dispose là một phương thức "dọn dẹp sau khi sử dụng" được quy ước. Có thể dễ dàng sử dụng kết hợp với từ khóa using. Hãy xem một vài ví dụ.

Ví dụ 1: Sử dụng using để kích hoạt Dispose sau khi mã hoàn thành:

// Phương thức Main
using (ResourceHandler handler1 = new(() => Console.WriteLine("Đã thực thi")))
    Console.WriteLine("Xin chào, thế giới!1"); 
    
Console.WriteLine("Xin chào, thế giới!2");

// Kết quả xuất ra console:
// Xin chào, thế giới!1
// Đã thực thi
// Xin chào, thế giới!2

Ví dụ 2: Sử dụng using nhiều lần, thực thi theo thứ tự ngược với thứ định nghĩa (thứ tự xếp chồng):

// Phương thức Main
using ResourceHandler handler1 = new(() => Console.WriteLine("Thực thi1")),
            handler2 = new(() => Console.WriteLine("Thực thi2")), 
            handler3 = new(() => Console.WriteLine("Thực thi3"));
Console.WriteLine("Xin chào, thế giới!");

// Kết quả xuất ra console:
// Xin chào, thế giới!
// Thực thi3
// Thực thi2
// Thực thi1

Ví dụ 3: Sử dụng IAsyncDisposable với await using:

public class AsyncResourceHandler:IAsyncDisposable 
{
    public async ValueTask DisposeAsync() => await Task.CompletedTask;
}

static async void Main(string[] args)
{
   await using AsyncResourceHandler handler = new();
}

Phần 3: Tại sao cần sử dụng Pattern giải phóng? Khi triển khai interface trong C#, Visual Studio thường gợi ý "Triển khai interface qua pattern giải phóng". Vậy pattern giải phóng là gì? Pattern giải phóng là sự kết hợp giữa phương thức Dispose và hủy hàm (finalizer), nhằm đảm bảo tài nguyên được giải phóng chính xác, dù được gọi rõ ràng qua phương thức Dispose hay khi đối tượng được bộ thu gom rác (GC) thu hồi. Pattern này được gọi là "Pattern giải phóng", là phương pháp tốt nhất để quản lý tài nguyên, xử lý cả tài nguyên được quản lý và không được quản lý.

Ví dụ, chúng ta có một đối tượng chứa một số tài nguyên không được quản lý và một số tài nguyên được quản lý:

class ResourceContainer:IDisposable
{
    private ManagedResource _managedResource;  // Tài nguyên được quản lý
    private UnmanagedResource _unmanagedResource; // Tài nguyên không được quản lý

    public void Dispose()   // Giải phóng tài nguyên
    {
      _managedResource.Dispose(); // Giải phóng tài nguyên được quản lý
      _unmanagedResource.Dispose(); // Giải phóng tài nguyên không được quản lý
    }
}

3.1 Ngăn chặn việc gọi Dispose() nhiều lần

Trong điều kiện bình thường, mã của chúng ta không có vấn đề gì. Nhưng giả sử ManagedResourceUnmanagedResource không phải do chúng ta viết, vì vậy cần xem xét việc gọi Dispose nhiều lần có thể gây ra vấn đề. Vì vậy, chúng ta cần thêm một cờ bên trong ResourceContainer để tránh giải phóng trùng lặp:

class ResourceContainer:IDisposable
{
  private ManagedResource _managedResource;
  private UnmanagedResource _unmanagedResource;
  private bool disposedValue = false; // Thêm: biến cờ

  public void Dispose()
  {
    if (!disposedValue) // Thêm: kiểm tra giá trị cờ, tránh gọi trùng lặp
    {
      _managedResource.Dispose();
      _unmanagedResource.Dispose();
      disposedValue = true;
    }
  }
}

3.2 Tránh bỏ lỡ việc gọi Dispose()

Đối với các đối tượng chứa tài nguyên không được quản lý, nếu quên gọi Dispose(), nhẹ thì gây rò rỉ bộ nhớ, nặng thì có thể gây thảm họa. Để đảm bảo đối tượng của chúng ta có thể gọi Dispose(), chúng ta xem xét thêm hủy hàm. Kỳ vọng khi chương trình được GC thu hồi sẽ tự động giải phóng tài nguyên:

class ResourceContainer:IDisposable
{
  private ManagedResource _managedResource;
  private UnmanagedResource _unmanagedResource;
  private bool disposedValue = false;

  public void Dispose()
  {
    ReleaseResources(); // Thực hiện giải phóng tài nguyên
    
    // Thêm: nếu gọi Dispose() thủ công, báo cho hủy hàm không thực thi nữa
    // tức là không gọi lại phương thức ReleaseResources()
    GC.SuppressFinalize(this); 
  }

  public void ReleaseResources()  // Đổi tên, tách ra từ phương thức Dispose
  {
    if (!disposedValue)
    {      
      _managedResource.Dispose();
      _unmanagedResource.Dispose();
      disposedValue = true;
    }
  }

  // Thêm: hủy hàm, khi quên gọi Dispose() sẽ được hủy hàm thực thi ReleaseResources()
  ~ResourceContainer() 
  {
      ReleaseResources();
  }
}

3.3 Thu hồi sớm tài nguyên được quản lý

Nếu trong 3.2, đối tượng quên gọi Dispose(), lúc này hủy hàm được kích hoạt, vẫn có thể thực hiện Dispose(). Dù trông có vẻ hoàn hảo, nhưng vẫn có tiềm ẩn nguy cơ gọi Dispose() nhiều lần. Vì thứ tự thực thi hủy hàm không cố định, khi đối tượng ResourceContainer bị hủy hàm kích hoạt, các đối tượng khác (như _managedResource) cũng có thể đã bị hủy. Điều này dẫn đến khi ResourceContainer thực hiện Dispose(), phương thức _managedResource.Dispose() có thể được thực hiện 2 lần (một lần từ bên ngoài, một lần từ chính nó), gây ra hậu quả không mong muốn.

Ví dụ:

3.3.1 Định nghĩa một lớp tài nguyên được quản lý có khuyết điểm

Lớp này không chặn việc giải phóng trùng lặp:

// Định nghĩa một lớp tài nguyên được quản lý có khuyết điểm
class ManagedResource:IDisposable
{
    // Mô phỏng tài nguyên được quản lý, mảng lớn để GC giữ lâu hơn
    private MemoryStream data = new MemoryStream(new byte[100_000000]);  
    private bool _finalized = false;
    int id;
    public ManagedResource(int id)  // Ghi nhận id của đối tượng
    {
        this.id = id;
    }

    ~ManagedResource()
    {
        _finalized = true;  // Được giải phóng bởi hủy hàm
        Console.WriteLine($"{id}:ManagedResource đã bị hủy.");
    }

    public void Dispose()
    {
        if (_finalized)
            throw new ObjectDisposedException($"{id}:Không thể truy cập ManagedResource đã bị hủy.");

        data.Dispose();
        Console.WriteLine($"{id}:ManagedResource được giải phóng bình thường.");
        _finalized = true;  // Được giải phởi bởi Dispose
    }
}
3.3.2 Định nghĩa một lớp triển khai interface IDisposable

Định nghĩa một lớp ResourceContainer triển khai interface IDisposable để sử dụng. Ở đây chúng ta viết theo pattern giải phóng chuẩn, nhưng cố tình đặt tài nguyên được quản lý ngoài câu lệnh disposing để kiểm tra:

class ResourceContainer:IDisposable
{
  private ManagedResource _managedResource;
  int id; 
  public ResourceContainer(int id) // Ghi nhận id của đối tượng
  {
      this.id = id; 
      _managedResource = new ManagedResource(id);
  }

  private bool disposedValue;

  // Cách viết chuẩn pattern giải phóng
  protected virtual void Dispose(bool disposing)
  {
      if (!disposedValue) // Nếu đã thực hiện dispose, bỏ qua mã dưới
      {
          // Xác định nguồn gốc
          // Nếu được gọi bởi Dispose() thủ công, disposing là true, giải phóng tài nguyên được quản lý
          // Nếu bị động bởi hủy hàm trong hàm hủy, disposing là false, không nên giải phóng tài nguyên được quản lý
          if (disposing)  
          {
              // Nên viết tài nguyên được quản lý ở đây
          }           

          try
          {
              _managedResource.Dispose();  // Để kiểm tra, đặt giải phóng tài nguyên được quản lý bên ngoài
          }
          catch (Exception ex)
          {
              Console.WriteLine($"{id}:Lỗi: {ex.GetType().Name} - {ex.Message}");
          }
          disposedValue = true;
      }
  }

  ~ResourceContainer()
  {
      Dispose(disposing: false);
  }

  public void Dispose()
  {
      Dispose(disposing: true);
      GC.SuppressFinalize(this);
  }
}
3.3.3 Tạo một số đối tượng để kiểm tra

Thử tạo đối tượng trong một vòng lặp, sau đó gọi GC, chờ GC giải phóng:

for (int i = 0; i <5; i++)
{
    new ResourceContainer(i); // Ngay lập tức trở thành rác
}

Console.WriteLine("Tạo xong, bắt đầu GC...");
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine($"Hoàn thành GC");
Console.ReadLine(); // Cần dừng để xem kết quả cuối cùng

// Kết quả xuất ra console
// Tạo xong, bắt đầu GC...
// 0:ManagedResource đã bị hủy.
// 1:ManagedResource đã bị hủy.
// 1:Lỗi: ObjectDisposedException - Không thể truy cập đối tượng đã bị hủy.
// Tên đối tượng: '1:Không thể truy cập ManagedResource đã bị hủy.'.
// 2:ManagedResource được giải phóng bình thường.
// 2:ManagedResource đã bị hủy.
// 3:ManagedResource được giải phóng bình thường.
// 3:ManagedResource đã bị hủy.
// 0:Lỗi: ObjectDisposedException - Không thể truy cập đối tượng đã bị hủy.
// Tên đối tượng: '0:Không thể truy cập ManagedResource đã bị hủy.'.
// Hoàn thành GC

Kết quả trên không cố định, đôi khi tất cả đều thành công, đôi khi một phần thất bại. Vì thứ tự kích hoạt hủy hàm không cố định, đôi khi đối tượng tham chiếu con đã được thu hồi nhưng hàm hủy của đối tượng cha vẫn đang truy cập nó. Tóm lại, pattern giải phóng Dispose đã xem xét kỹ lưỡng các vấn đề tiềm ẩn với Dispose. Nếu mỗi phương thức đều được viết theo yêu cầu, thì sẽ rất an toàn.

Kết luận Tóm lại, Dispose() trong C# là một tính năng rất hữu ích, không chỉ là trợ thủ lớn trong các tình huống giải phóng tài nguyên, mà còn là một mẫu hình tốt trong nhiều trường hợp cần thực thi hoãn hoặc cần kết thúc thống nhất (ví dụ Stream vừa có phương thức Close() vừa hỗ trợ Dispose(), hai phương thức này tương đương). Nhiều đối tượng Transaction cũng thường sử dụng using để kiểm soát thời điểm thực hiện Commit(). Ngoài ra, các tài nguyên đồ họa, mạng, kết nối cơ sở dữ liệu, handle hệ thống, quản lý bộ nhớ không được quản lý, v.v., thông qua việc triển khai Dispose() có thể nâng cao đáng kể khả năng đọc và bảo trì mã.

Thẻ: C# Dispose Pattern resource management garbage collection IDisposable

Đăng vào ngày 21 tháng 6 lúc 21:33