1. Giới thiệu
Theo một cách nào đó, đăng ký sự kiện trong C# giống như đăng ký nhận meme từ một người bạn: thông báo sẽ tới cho tới khi bạn nói "đủ rồi" và hủy đăng ký. Trong lập trình điều này đặc biệt quan trọng, vì quên hủy đăng ký — không phải chỉ là "thêm một meme nữa", mà là rò rỉ bộ nhớ!
Hãy tưởng tượng bạn có một form trong ứng dụng (ví dụ một cửa sổ cài đặt). Nó đăng ký vào sự kiện của cửa sổ chính để phản ứng với thay đổi. Người dùng đóng form, nghĩ là nó đã bị hủy, nhưng handler vẫn còn đăng ký! Form vẫn sống trong bộ nhớ vì publisher giữ tham chiếu đến nó qua sự kiện.
Kết luận: Nếu đối tượng-subscriber đã đăng ký vào sự kiện của publisher và "quên" hủy đăng ký, thì garbage collector sẽ không xóa nó khỏi bộ nhớ chừng nào publisher còn sống.
Ôn lại toán tử += và giới thiệu -=
- += — đăng ký: thêm handler vào danh sách gọi của sự kiện.
- -= — hủy đăng ký: xoá handler khỏi danh sách gọi.
Nó trông đại loại như thế này:
worker.WorkCompleted += handler; // đăng ký
worker.WorkCompleted -= handler; // hủy đăng ký
Nếu handler được thêm hai lần, bạn cần xoá nó cùng số lần để nó thực sự biến mất khỏi danh sách gọi.
Một chút nội bộ
Phía sau hậu trường, sự kiện trong C# là một field-delegate (hoặc danh sách delegate), và toán tử += thực chất gọi Delegate.Combine, còn -= thì gọi Delegate.Remove. Đối tượng đã đăng ký trở thành một phần của đồ thị tham chiếu. Đó là lý do vì sao quên đăng ký = rò rỉ bộ nhớ.
2. Rò rỉ bộ nhớ qua sự kiện: hoạt động như thế nào
Tình huống kinh điển
class Window
{
public event EventHandler Updated;
public void SimulateUpdate()
{
// Giả lập: gửi thông báo cho tất cả subscribers
Updated?.Invoke(this, EventArgs.Empty);
}
}
class SettingsForm
{
public void OnWindowUpdated(object sender, EventArgs e)
{
Console.WriteLine("SettingsForm phản ứng với cập nhật cửa sổ");
}
}
Hãy đi từng bước:
var window = new Window();
var settingsForm = new SettingsForm();
window.Updated += settingsForm.OnWindowUpdated;
window.SimulateUpdate(); // SettingsForm phản ứng
// Người dùng đóng form. Chúng ta mất mọi tham chiếu đến nó:
settingsForm = null;
// Nhưng đối tượng SettingsForm SẼ KHÔNG bị GC thu dọn miễn là window còn sống,
// vì window.Updated vẫn giữ tham chiếu tới phương thức OnWindowUpdated,
// và do đó giữ tham chiếu tới chính đối tượng SettingsForm.
Phải làm gì?
Hủy đăng ký:
// Để làm việc này cần ta còn giữ tham chiếu tới handler hoặc đối tượng:
window.Updated -= settingsForm.OnWindowUpdated;
settingsForm = null; // Bây giờ đối tượng có thể bị thu dọn
Bảng: ai giữ tham chiếu tới ai
| Hành động | Ai giữ tham chiếu | Có thể giải phóng bộ nhớ không? |
|---|---|---|
| Đăng ký sự kiện (+=) | Publisher tới subscriber | Không, chừng nào publisher còn sống |
| Hủy đăng ký (-=) | Không | Có, sau khi loại hết các tham chiếu bên ngoài |
| Không đăng ký | Không | Có |
3. Cách tổ chức hủy đăng ký đúng
Xoá handler một cách rõ ràng
Điều này có thể thực hiện, ví dụ, khi cửa sổ hoặc form đóng lại:
class SettingsForm
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void Close()
{
_window.Updated -= OnWindowUpdated; // hủy đăng ký!
// ở đây code để đóng (ví dụ Dispose, GC.SuppressFinalize, v.v.)
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// Xử lý sự kiện
}
}
Nếu SettingsForm bị huỷ bằng nút "Close", quan trọng là đừng quên gọi method nơi thực hiện hủy đăng ký (ví dụ Close()).
Sử dụng interface IDisposable
Với các đối tượng phức tạp đăng ký sự kiện và quản lý vòng đời của chính nó, tiện lợi khi triển khai IDisposable. Trong method Dispose() thực hiện mọi hủy đăng ký cần thiết.
class SettingsForm : IDisposable
{
private readonly Window _window;
public SettingsForm(Window window)
{
_window = window;
_window.Updated += OnWindowUpdated;
}
public void OnWindowUpdated(object sender, EventArgs e)
{
// ...
}
public void Dispose()
{
_window.Updated -= OnWindowUpdated;
// Ở đây giải phóng các tài nguyên khác
}
}
Bây giờ SettingsForm có thể dùng trong khối using, gọi rõ ràng Dispose() hoặc tự động hoá giải phóng tài nguyên (ví dụ thông qua GC.SuppressFinalize trong các kiểu có finalizer).
4. Tương tác với lambda: rủi ro và mẹo
Nếu bạn đăng ký sự kiện bằng lambda mà không lưu lambda vào biến, bạn sẽ không thể hủy đăng ký!
// Đăng ký — lambda ẩn danh
window.Updated += (s, e) => Console.WriteLine("Lambda được gọi!");
// Bây giờ làm sao để hủy đăng ký? — Không thể!
window.Updated -= (s, e) => Console.WriteLine("Lambda được gọi!"); // Đây là delegate khác!
Phải làm thế nào?
Lưu lambda vào biến delegate:
EventHandler handler = (s, e) => Console.WriteLine("Lambda được gọi!");
window.Updated += handler;
// ... giờ có thể hủy đăng ký!
window.Updated -= handler;
5. Những điểm cần lưu ý hữu ích
Đặc điểm vòng đời đối tượng và sự kiện
Một vấn đề thường gặp khác là tham chiếu chéo qua sự kiện giữa hai đối tượng "sống lâu". Ví dụ, một cửa sổ đăng ký sự kiện của cửa sổ khác, cả hai được dùng liên tục, không bị hủy — và bộ nhớ bị tiêu tốn ngày càng nhiều.
Khuyến nghị: Luôn nhớ ai đăng ký ai và khi nào cần hủy đăng ký. Nếu vòng đời của subscriber bằng với publisher — OK. Nếu subscriber có thể sống ngắn hơn publisher, hãy triển khai hủy đăng ký rõ ràng.
Quy tắc chung: "Đăng ký thì phải hủy!"
- Với các publisher sống lâu (ví dụ global, singleton, cửa sổ chính) — luôn triển khai hủy đăng ký ở phía subscriber.
- Với các đối tượng tạm thời (ví dụ thông báo một lần hoặc khi subscriber sống lâu hơn publisher) — có thể bớt lo lắng, nhưng vẫn chú ý ngữ cảnh.
- Dùng các tiếp cận như WeakEvent (sự kiện yếu) hoặc các framework hỗ trợ nếu bạn không muốn quản lý hủy đăng ký thủ công.
6. Những lỗi thường gặp khi hủy đăng ký
Hủy sai: handler phải là cùng một instance
Rất quan trọng là khi hủy đăng ký bạn chỉ định chính xác handler đã dùng lúc đăng ký. Nếu không, hủy đăng ký sẽ không có tác dụng.
Lỗi:
window.Updated += settingsForm.OnWindowUpdated;
// ...
window.Updated -= new SettingsForm().OnWindowUpdated; // Không hoạt động! Đây là một instance khác và delegate khác!
Đúng:
window.Updated -= settingsForm.OnWindowUpdated;
Nếu khi đăng ký dùng lambda ẩn danh và không lưu delegate, bạn không thể hủy nó vì đó sẽ là một instance delegate khác:
// Đăng ký
window.Updated += (s, e) => Console.WriteLine("Lambda!");
// Cố gắng hủy — sẽ không hoạt động!
window.Updated -= (s, e) => Console.WriteLine("Lambda!");
"Quên" hủy đăng ký
Rất thường người ta quên hủy đăng ký, đặc biệt khi subscriber sống lâu hơn publisher hoặc khi developer không hiểu rõ cơ chế sự kiện. Hậu quả là các đối tượng-subscriber ở lại trong bộ nhớ lâu hơn cần thiết, dẫn tới rò rỉ bộ nhớ và vấn đề về performance.
GO TO FULL VERSION