Làm chủ IDisposable trong C# là yếu tố quan trọng để quản lý tài nguyên một cách tin cậy. Hãy học cách dọn dẹp tài nguyên không được quản lý (unmanaged resources) như kết nối cơ sở dữ liệu, file handles và network sockets một cách xác định, đồng thời tránh được rò rỉ bộ nhớ.
Runtime .NET đi kèm với khả năng quản lý tài nguyên hiệu quả, giúp bạn xây dựng các ứng dụng mạnh mẽ. Nó có thể cấp phát, quản lý và thu hồi bộ nhớ hiệu quả cho các đối tượng, giúp ngăn ngừa rò rỉ bộ nhớ và cạn kiệt bộ nhớ. Hầu hết việc dọn dẹp được thực hiện tự động. Tuy nhiên, có những tình huống mà bạn, với vai trò là nhà phát triển, cần phải dọn dẹp thủ công các tài nguyên không được quản lý mà runtime không thể nhìn thấy.
Hướng dẫn này sẽ giải thích ngắn gọn cách quản lý tài nguyên của .NET hoạt động và những hạn chế của nó khi xử lý các tài nguyên không được quản lý. Sau đó bạn sẽ thấy cách giao diện IDisposable có thể được triển khai trong các tình huống khác nhau để đảm bảo tài nguyên được dọn dẹp đúng cách.
Mục lục
Quản lý tài nguyên trong .NET
Runtime .NET quản lý một heap bộ nhớ cho các kiểu tham chiếu (reference types) của ứng dụng của bạn. Trong khi runtime xử lý việc cấp phát, Garbage Collector (GC) chịu trách nhiệm tự động thu hồi bộ nhớ từ các đối tượng không còn được sử dụng. Nó cũng chịu trách nhiệm tối ưu hóa bộ nhớ bằng cách nén heap. Toàn bộ quá trình này giải phóng bạn khỏi việc giải phóng bộ nhớ thủ công.
Kiểu tham chiếu là gì?
Kiểu tham chiếu là bất kỳ kiểu .NET nào có giá trị được lưu trữ trên heap thay vì stack. Khi bạn gán một kiểu như vậy cho một biến, biến đó lưu trữ một tham chiếu đến đối tượng trên heap. Điều này khác với kiểu giá trị, lưu trữ giá trị thực tế trong biến.
Vì runtime quản lý bộ nhớ cho các kiểu tham chiếu, nó được gọi là bộ nhớ được quản lý. Tuy nhiên, các ứng dụng thường có thể sử dụng tài nguyên không được quản lý. Bộ nhớ cho các tài nguyên này nằm ngoài tầm kiểm soát và tầm nhìn của runtime. Các tài nguyên như vậy có thể bao gồm file handles, kết nối cơ sở dữ liệu và network sockets. Vì nó không có tầm nhìn về các tài nguyên này, GC không có cách nào để tự động nén hoặc thu hồi bộ nhớ này. Thay vào đó, chính lớp đó cần phải giải phóng các tài nguyên không được quản lý này để ngăn ngừa rò rỉ bộ nhớ.
Để giải quyết vấn đề này, .NET cung cấp giao diện IDisposable để dọn dẹp tài nguyên một cách xác định. Phần tiếp theo sẽ trình bày một cách triển khai IDisposable đơn giản được sử dụng phổ biến nhất.
Dispose Pattern cơ bản: Một triển khai đơn giản
Trong hầu hết các trường hợp, các lớp của bạn sẽ làm việc với các tài nguyên không được quản lý đã được bao bọc trong các lớp IDisposable của chúng.
Ví dụ, lớp của bạn có thể sử dụng lớp NpgsqlConnection để thiết lập kết nối đến cơ sở dữ liệu PostgreSQL. Mặc dù kết nối cơ sở dữ liệu là một tài nguyên không được quản lý, lớp NpgsqlConnection đã triển khai phương thức Dispose() để quản lý các tài nguyên đó. Phương thức Dispose() của lớp bạn chỉ cần gọi phương thức Dispose() của cơ sở dữ liệu.
Điều này được minh họa trong đoạn mã dưới đây:
public class CustomerRepository : IDisposable
{
private bool _disposed = false;
private readonly NpgsqlConnection _connection;
public CustomerRepository(string connectionString)
{
_connection = new NpgsqlConnection(connectionString);
_connection.Open();
}
// Methods that use the database connection...
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Call the database Dispose() method to handle cleanup
_connection.Dispose();
}
}
Chú ý cách phương thức CustomerRepository.Dispose() gọi đến kết nối cơ sở dữ liệu cơ bản để dispose nó. Bây giờ tài nguyên không được quản lý đã được xử lý. Không có gì quá phức tạp!
Trường _disposed cũng theo dõi xem phương thức dispose đã được gọi chưa. Điều này quan trọng vì phương thức Dispose() của bạn nên luôn luôn idempotent, nếu không các ngoại lệ có thể được ném ra.
Các lệnh gọi Dispose() xếp tầng
Bất cứ khi nào lớp của bạn sở hữu một lớp triển khai IDisposable, nó phải xếp tầng các lệnh gọi dispose xuống các đối tượng được sở hữu đó để đảm bảo dọn dẹp đúng cách.
Tuy nhiên, điều này không cần thiết nếu lớp của bạn không sở hữu tài nguyên (ví dụ: nó được truyền vào như một dependency trong constructor).
Sử dụng lớp IDisposable
Khi sử dụng lớp trên, bạn nên sử dụng khối using vì nó tự động gọi Dispose() cho bạn khi bạn hoàn thành với đối tượng:
using (var repository = new CustomerRepository(_connectionString))
{
// Use repository here...
} // Calls repository.Dispose() automatically
Chú ý cách phương thức Dispose() được gọi ngay khi bạn đạt đến cuối khối using.
Bạn cũng có thể loại bỏ khối, trong trường hợp đó phương thức Dispose() sẽ được gọi khi khối mã xung quanh kết thúc:
private Customer? FindCustomer(int id)
{
// Create repository with `using var`
using var repository = new CustomerRepository(_connectionString);
return repository.Get(id);
} // Calls repository.Dispose() automatically
Trong đoạn mã trên, phương thức Dispose() sẽ được gọi tự động sau khi hàm trả về.
Dispose Pattern đầy đủ: Xử lý tài nguyên không được quản lý
Việc triển khai ở trên sẽ đủ cho hầu hết các triển khai IDisposable của bạn. Tuy nhiên, có thể có lúc bạn xử lý trực tiếp các tài nguyên không được quản lý (tức là chúng không được bao bọc trong IDisposable của riêng chúng). Trong những trường hợp đó, có một vài điều khác cần xem xét trong triển khai của bạn.
Hãy xem triển khai dưới đây:
using Microsoft.Win32.SafeHandles;
using System.ComponentModel;
using System.Runtime.InteropServices;
public class UnmanagedFileHandler : IDisposable
{
// Raw, unmanaged resource pointer
private IntPtr _handle;
// Managed resource
private readonly MemoryStream _buffer;
private bool _disposed = false;
// Using a constant for the invalid handle value for clarity.
private static readonly IntPtr INVALID_HANDLE_VALUE = new(-1);
// Import the CreateFile function from the Windows API.
[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
private static extern IntPtr CreateFile(
string lpFileName,
uint dwDesiredAccess,
uint dwShareMode,
IntPtr lpSecurityAttributes,
uint dwCreationDisposition,
uint dwFlagsAndAttributes,
IntPtr hTemplateFile);
// Import the CloseHandle function.
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr hObject);
// Constants for file access from the Windows API.
private const uint GENERIC_WRITE = 0x40000000;
private const uint CREATE_ALWAYS = 2;
private const uint NO_SHARING = 0;
private const uint DEFAULT_ATTRIBUTES = 0;
public UnmanagedFileHandler(string filePath)
{
// Call the unmanaged Windows API to get a file handle.
_handle = CreateFile(
filePath,
GENERIC_WRITE,
NO_SHARING,
IntPtr.Zero,
CREATE_ALWAYS,
DEFAULT_ATTRIBUTES,
IntPtr.Zero);
_buffer = new MemoryStream();
// Check if the handle is valid. If not, throw an exception.
if (_handle == INVALID_HANDLE_VALUE)
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create the file handle.");
}
// File methods here...
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
~UnmanagedFileHandler()
{
Dispose(false);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;
if (disposing)
{
// Dispose managed resources if called from Dispose()
_buffer.Dispose();
}
// Always dispose unmanaged resources
if (_handle != IntPtr.Zero && _handle != INVALID_HANDLE_VALUE)
{
CloseHandle(_handle);
_handle = IntPtr.Zero;
}
_disposed = true;
}
}
Đó là một chút mã nhiều hơn!
Đầu tiên, chú ý cách lớp có một IntPtr trỏ đến một file, được tạo bằng phương thức cấp thấp CreateFile của Windows. Con trỏ này là một tài nguyên không được quản lý phải được dọn dẹp thủ công.
Một MemoryStream cũng được tạo để hoạt động như một bộ đệm. Đây là một tài nguyên không được quản lý khác. Tuy nhiên, vì lớp MemoryStream triển khai IDisposable, bạn chỉ cần gọi phương thức Dispose() trên trường đó.
Cũng có một phương thức Dispose(bool disposing) mới. Nó dọn dẹp các tài nguyên được quản lý và không được quản lý. Phương thức này có thể được gọi từ hai nơi trong lớp: phương thức IDisposable.Dispose(), hoặc finalizer của lớp.
Finalizers là gì?
Finalizers là một tên khác cho destructors trong C#. Các phương thức này có chữ ký đơn giản: public ~ClassName() {}
GC gọi finalizer trước khi thu hồi bộ nhớ của đối tượng.
Khi gọi phương thức Dispose(bool disposing) mới, tham số disposing được xác định bởi nguồn gốc của lệnh gọi phương thức:
- Khi được gọi từ
IDisposable.Dispose(), thìdisposinglàtrue, nghĩa là cả tài nguyên được quản lý và không được quản lý nên được dọn dẹp. - Khi được gọi từ finalizer (tức là
~UnmanagedFileHandler()), thìdisposinglàfalse, vì vậy chỉ các tài nguyên không được quản lý được dọn dẹp. Điều này là do GC sẽ finalize các tài nguyên được quản lý được sở hữu, vì vậy không cần phảiDispose()chúng chính chúng ta.
GC.SuppressFinalize() cũng được gọi trên đối tượng hiện tại trong phương thức Dispose(). Điều này nói với GC rằng nó không cần gọi phương thức finalizer trên lớp này.
Điều này là cần thiết vì lý do hiệu suất, vì finalizers không chính xác là hiệu quả. Khi GC gặp một lớp với một finalizer cần được thu hồi, trước tiên nó đặt finalizer đó vào một hàng đợi để thực thi sau. Điều này là để ngăn chặn lần chạy GC hiện tại bị trì hoãn tiềm năng bằng cách gọi finalizer ngay lập tức. Một khi lần chạy GC hiện tại kết thúc, finalizer được thực thi. Chỉ sau khi finalizer được thực thi thì lớp mới trở nên đủ điều kiện để được thu hồi.
Vì vậy, bằng cách suppress finalizer trên lớp, bộ nhớ cho lớp đó có thể được thu hồi ngay lập tức mà không cần chờ finalizer thực thi trước.
Cuối cùng, bạn sẽ thấy phương thức Dispose(bool disposing) là virtual. Trong phần tiếp theo, bạn sẽ tìm hiểu tại sao điều này là cần thiết khi nói đến kế thừa lớp với IDisposable.
Dispose các lớp được kế thừa
Điều gì xảy ra nếu một lớp kế thừa từ lớp IDisposable của bạn và sử dụng các tài nguyên không được quản lý của riêng nó? Tài nguyên của lớp con cũng cần được dọn dẹp, cùng với tài nguyên của lớp cha.
May mắn thay, vì phương thức Dispose(bool disposing) là virtual, một lớp con có thể thực thi logic dọn dẹp của riêng nó khi lớp được dispose.
Đoạn mã dưới đây dành cho lớp LogFileHandler, kế thừa từ lớp UnmanagedFileHandler được tham chiếu ở trên.
public class LogFileHandler : UnmanagedFileHandler
{
private bool _disposed = false;
private readonly MemoryStream _logBuffer;
public LogFileHandler(string filePath) : base(filePath)
{
_logBuffer = new MemoryStream();
}
// Log methods here...
// Override the parent class Dispose method to clean up the additional resource.
protected override void Dispose(bool disposing)
{
if (_disposed)
return;
_disposed = true;
if (disposing)
{
// Dispose the additional managed resource
_logBuffer.Dispose();
}
// No unmanaged resources to clean up in this child class
// Call the base class Dispose method
base.Dispose(disposing);
}
}
Lớp LogFileHandler có trường MemoryStream riêng của nó, được sử dụng để đệm các thông báo log. Tài nguyên này triển khai giao diện IDisposable, vì vậy LogFileHandler phải ghi đè phương thức Dispose(bool disposing) từ lớp cha để dispose bộ đệm. Khi ghi đè phương thức, phương thức Dispose(bool disposing) của lớp cơ sở vẫn phải được gọi.
Vì phương thức Dispose(bool disposing) đã được gọi từ IDisposable.Dispose() và finalizer trong lớp cơ sở, không cần phải triển khai chúng trong lớp LogFileHandler.
Nếu tôi không bao giờ có kế hoạch kế thừa?
Nếu lớp IDisposable của bạn sẽ không bao giờ được kế thừa, đánh dấu lớp là sealed và loại bỏ cờ virtual khỏi phương thức Dispose(bool disposing).
Best Practices
Khi triển khai bất kỳ mẫu nào ở trên, hãy ghi nhớ những điều này để đảm bảo logic disposal của bạn mạnh mẽ.
Đảm bảo Idempotency
Các lệnh gọi đến Dispose() nên luôn luôn idempotent để tránh các ngoại lệ bị ném ra. Điều này có thể xảy ra nếu một phần của việc disposing một thuộc tính đặt nó thành một giá trị không hợp lệ (như null):
public void Dispose()
{
_connection.Dispose();
_connection = null;
}
Nếu, vì bất kỳ lý do nào, phương thức Dispose() ở trên được gọi lại, một NullReferenceException sẽ được ném ra vì _connection đã được đặt thành null trước đó.
Vì vậy tốt nhất là luôn sử dụng một trường riêng _disposed để theo dõi xem dispose đã được chạy chưa:
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
_connection.Dispose();
_connection = null;
}
Bây giờ phương thức sẽ trả về sớm nếu nó từng được gọi nhiều lần.
Không ném ngoại lệ trong Finalizers
Khi triển khai finalizers mà dispose tài nguyên, điều quan trọng là tránh ném bất kỳ ngoại lệ nào vì chúng có thể có tác dụng phụ không mong muốn, như gây ra sự cố toàn bộ ứng dụng.
Luôn viết finalizers của bạn một cách phòng thủ nhất có thể để ngăn chặn các ngoại lệ không được xử lý xuất hiện.
Cascade Dispose Calls đến các tài nguyên được sở hữu
Bất kỳ lớp nào sở hữu tài nguyên triển khai IDisposable phải triển khai giao diện IDisposable và gọi phương thức Dispose() trên các tài nguyên đó trong phương thức Dispose() của chính nó. Nếu không thực hiện, các tài nguyên được sở hữu sẽ không được giải phóng, điều này có thể gây rò rỉ bộ nhớ.
Luôn gọi Base Class Dispose
Nếu một lớp kế thừa từ một lớp khác triển khai IDisposable, hãy đảm bảo bạn ghi đè phương thức Dispose(bool disposing) để giải phóng bất kỳ tài nguyên không được quản lý nào trong lớp được kế thừa.
Đồng thời, luôn gọi phương thức Dispose(bool disposing) của lớp cơ sở từ phương thức được ghi đè.
Sử dụng SafeHandle để quản lý các tài nguyên không được quản lý
Dispose Pattern đầy đủ là cần thiết nếu lớp của bạn xử lý trực tiếp các tài nguyên không được quản lý (tức là các tài nguyên không có wrapper IDisposable hiện có, chẳng hạn như IntPtr). Tuy nhiên, runtime .NET đi kèm với các lớp SafeHandle có thể bao bọc bất kỳ IntPtr không được quản lý thô nào trong một IDisposable. Các lớp wrapper này quản lý con trỏ cho bạn, nghĩa là lớp của bạn chỉ cần triển khai Dispose Pattern cơ bản.
Tài liệu chính thức đi vào chi tiết về SafeHandles và cách chúng đơn giản hóa việc disposing một lớp.
Dispose bất đồng bộ với IAsyncDisposable
Khi lớp của bạn giữ các tài nguyên liên quan đến các hoạt động bất đồng bộ trong quá trình dọn dẹp (như đóng kết nối cơ sở dữ liệu hoặc giải phóng khóa), bạn nên triển khai IAsyncDisposable thay vì (hoặc ngoài) IDisposable. Điều này cho phép dọn dẹp không chặn, giữ cho ứng dụng của bạn phản hồi.
Giao diện IAsyncDisposable mong đợi một phương thức ValueTask DisposeAsync() được triển khai.
Dưới đây là một ví dụ về triển khai giao diện IAsyncDisposable:
public class CustomerRepository : IAsyncDisposable
{
// ...
public async ValueTask DisposeAsync()
{
// Implement asychronous cleanup logic
await _connection.DisposeAsync();
// Also implement any synchronous cleanup here as well
_buffer.Dispose();
}
}
Sử dụng một lớp IAsyncDisposable tương tự như lớp IDisposable. Bạn chỉ cần thêm await vào câu lệnh using của bạn:
await using (var repository = new CustomerRepository(_connectionString))
{
// Use repository here
} // Calls repository.DisposeAsync() automatically
Kết luận
Trong khi GC .NET làm tốt công việc quản lý bộ nhớ, nó không thể dọn dẹp các tài nguyên không được quản lý, như file handles cấp thấp hoặc kết nối cơ sở dữ liệu. Giao diện IDisposable giúp đảm bảo tất cả tài nguyên được quản lý và không được quản lý được dọn dẹp một cách xác định.
Hy vọng rằng, hướng dẫn này đã làm sáng tỏ Dispose Pattern trong C#. Trong hầu hết các trường hợp, bạn sẽ gắn bó với triển khai đầu tiên, đơn giản. Tuy nhiên, nếu bạn từng phải dọn dẹp thủ công các tài nguyên không được quản lý, cần một chút mã nhiều hơn để làm cho nó hoạt động.
Bằng cách triển khai đúng cách giao diện IDisposable, bạn sẽ giảm số lượng rò rỉ bộ nhớ tiềm năng, tạo ra một sản phẩm cuối mạnh mẽ và ổn định.



