Việc thành thạo IDisposable trong C# là rất quan trọng để quản lý tài nguyên một cách đáng tin cậy. Hãy tìm hiểu cách dọn dẹp một cách xác định các tài nguyên không được quản lý như kết nối cơ sở dữ liệu, handle tập tin, socket mạng và tránh 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 tư cách là nhà phát triển, cần phải tự dọn dẹp 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 của ứng dụng của bạn. Trong khi runtime xử lý việc cấp phát, Bộ thu gom rác (GC) chịu trách nhiệm thu hồi bộ nhớ tự động 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 phải tự hủy cấp phát bộ nhớ.
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 handle tập tin, kết nối cơ sở dữ liệu và socket mạng. Vì không có tầm nhìn về các tài nguyên này, GC không có cách nào để nén hoặc thu hồi bộ nhớ này tự động. 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 triển khai IDisposable đơn giản được sử dụng phổ biến nhất.
Mẫu Dispose 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 bọc trong các lớp IDisposable riêng 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 một phương thức Dispose() để quản lý các tài nguyên đó. Phương thức Dispose() của lớp của 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 code 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();
}
// Các phương thức sử dụng kết nối cơ sở dữ liệu...
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
// Gọi phương thức Dispose() của cơ sở dữ liệu để xử lý dọn dẹp
_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 rất 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.
Cuộc gọi Dispose() Cascading
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 cascade các cuộc 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 phụ thuộc 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))
{
// Sử dụng repository ở đây...
} // Gọi repository.Dispose() tự động
Chú ý cách phương thức Dispose() được gọi ngay khi bạn đến cuối khối using.
Bạn cũng có thể bỏ khối, trong trường hợp đó phương thức Dispose() sẽ được gọi khi khối code xung quanh kết thúc:
private Customer? FindCustomer(int id)
{
// Tạo repository với `using var`
using var repository = new CustomerRepository(_connectionString);
return repository.Get(id);
} // Gọi repository.Dispose() tự động
Trong đoạn code trên, phương thức Dispose() sẽ được gọi tự động sau khi hàm trả về.
Mẫu Dispose Đầy Đủ: Xử Lý Tài Nguyên Không Được Quản Lý
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 bọc trong IDisposable riêng của chúng). Trong những trường hợp đó, có một vài điều cần xem xét thêm 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
{
// Con trỏ tài nguyên không được quản lý thô
private IntPtr _handle;
// Tài nguyên được quản lý
private readonly MemoryStream _buffer;
private bool _disposed = false;
// Sử dụng hằng số cho giá trị handle không hợp lệ để rõ ràng.
private static readonly IntPtr INVALID_HANDLE_VALUE = new(-1);
// Import hàm CreateFile từ 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 hàm CloseHandle.
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool CloseHandle(IntPtr hObject);
// Các hằng số cho truy cập tập tin từ 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)
{
// Gọi Windows API không được quản lý để lấy handle tập tin.
_handle = CreateFile(
filePath,
GENERIC_WRITE,
NO_SHARING,
IntPtr.Zero,
CREATE_ALWAYS,
DEFAULT_ATTRIBUTES,
IntPtr.Zero);
_buffer = new MemoryStream();
// Kiểm tra xem handle có hợp lệ không. Nếu không, ném một ngoại lệ.
if (_handle == INVALID_HANDLE_VALUE)
throw new Win32Exception(Marshal.GetLastWin32Error(), "Failed to create the file handle.");
}
// Các phương thức tập tin ở đây...
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
~UnmanagedFileHandler()
{
Dispose(false);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;
if (disposing)
{
// Dispose tài nguyên được quản lý nếu được gọi từ Dispose()
_buffer.Dispose();
}
// Luôn dispose tài nguyên không được quản lý
if (_handle != IntPtr.Zero && _handle != INVALID_HANDLE_VALUE)
{
CloseHandle(_handle);
_handle = IntPtr.Zero;
}
_disposed = true;
}
}
Đó là một chút nhiều code hơn!
Đầu tiên, chú ý cách lớp có một IntPtr trỏ đến một tập tin, đượ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ả 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.
Finalizer là gì?
Finalizer là một tên khác cho destructor 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ời gọi phương thức:
- Khi được gọi từ
IDisposable.Dispose(), thìdisposinglàtrue, có nghĩa là cả tài nguyên được quản lý và không được quản lý đều 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ó tài nguyên không được quản lý được dọn dẹp. Điều này là vì 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ì finalizer 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 để ngă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 ra lý do 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 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 tài nguyên không được quản lý riêng của 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 riêng của nó khi lớp được dispose.
Đoạn code dưới đây cho một 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();
}
// Các phương thức log ở đây...
// Ghi đè phương thức Dispose của lớp cha để dọn dẹp tài nguyên bổ sung.
protected override void Dispose(bool disposing)
{
if (_disposed)
return;
_disposed = true;
if (disposing)
{
// Dispose tài nguyên được quản lý bổ sung
_logBuffer.Dispose();
}
// Không có tài nguyên không được quản lý để dọn dẹp trong lớp con này
// Gọi phương thức Dispose của lớp cơ sở
base.Dispose(disposing);
}
}
Lớp LogFileHandler có trường MemoryStream riêng của nó, được sử dụng để đệm các tin nhắn 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 triển khai chúng trong lớp LogFileHandler.
Nếu Tôi Không Bao Giờ Dự Định 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à xóa cờ virtual khỏi phương thức Dispose(bool disposing).
Các Thực Hành Tốt Nhất
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 dispose của bạn mạnh mẽ.
Đảm Bảo Idempotency
Các lời gọi đến Dispose() nên luôn luôn idempotent để tránh các ngoại lệ được 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 gì, 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 tư _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ó được gọi nhiều lần.
Không Ném Ngoại Lệ Trong Finalizer
Khi triển khai finalizer 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ụ ngoài ý muốn, như gây ra sự cố toàn bộ ứng dụng.
Luôn viết finalizer của bạn một cách phòng thủ nhất có thể để ngăn chặn các ngoại lệ chưa được xử lý xuất hiện.
Cascade Các Lời Gọi Dispose Đến 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() riêng của nó. Nếu không được 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 Dispose Của Lớp Cơ Sở
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 kế thừa.
Cũng 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ý
Mẫu Dispose đầ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ể 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, có nghĩa là lớp của bạn chỉ cần triển khai mẫu Dispose 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 nắm giữ tài nguyên liên quan đến các hoạt động không đồ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 bổ sung cho) 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()
{
// Triển khai logic dọn dẹp bất đồng bộ
await _connection.DisposeAsync();
// Cũng triển khai bất kỳ dọn dẹp đồng bộ nào ở đây
_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))
{
// Sử dụng repository ở đây
} // Gọi repository.DisposeAsync() tự động
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ư handle tập tin 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ỏ một chút về mẫu Dispose 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 tài nguyên không được quản lý thủ công, cần thêm một chút code để làm cho nó hoạt động.
Bằng cách triển khai giao diện IDisposable đúng cách, bạn sẽ giảm số lượng rò rỉ bộ nhớ tiềm năng, dẫn đến một sản phẩm cuối cùng mạnh mẽ và ổn định.



