5.5 — 4. Delegates và Events
Delegate là con trỏ hàm có kiểu: một biến giữ được một phương thức và gọi nó sau. Func, Action, Predicate đều chỉ là delegate dựng sẵn, và mọi biểu thức lambda bạn truyền cho LINQ đều trở thành một delegate. event không phải khái niệm khác — nó là một delegate bị giới hạn quyền: bên ngoài chỉ được += và -=, không được gán đè hay tự kích hoạt. Bài này dành phần quan trọng nhất cho rò rỉ bộ nhớ do qu ên -=: một đối tượng sống lâu giữ tham chiếu tới đối tượng ngắn hạn qua event handler, và GC không bao giờ thu hồi được nó.
Mục tiêu bài học
Sau bài này bạn có thể:
- Khai báo và dùng delegate,
Func,Action,Predicateđúng chỗ. - Giải thích
eventkhác delegate công khai ở điểm nào. - Chỉ ra cơ chế rò rỉ bộ nhớ do quên gỡ đăng ký, và cách tránh.
- Gọi event an toàn khi có nhiều luồng.
- Biết vì sao event không hợp với xử lý bất đồng bộ, và dùng gì thay thế.
Nội dung bài học
5.5.1 — Delegate là con trỏ hàm có kiểu
// Khai báo một kiểu delegate
public delegate decimal DiscountRule(Order order);
// Gán một phương thức vào biến
DiscountRule rule = order => order.Total >= 500_000m ? 0.15m : 0m;
decimal rate = rule(order); // gọi như gọi hàm
Trong .NET hiện đại bạn hiếm khi tự khai báo delegate, vì đã có sẵn ba họ:
Func<Order, decimal> calc = o => o.Total * 0.1m; // có trả về
Action<Order> log = o => Console.WriteLine(o.Code); // không trả về
Predicate<Order> isPaid = o => o.IsPaid; // trả về bool
Func nhận tới 16 tham số, tham số cuối cùng là kiểu trả về: Func<int, string, bool> nghĩa là nhận int và string, trả bool.
Mọi lambda truyền cho LINQ đều là delegate. orders.Where(o => o.IsPaid) chính là truyền một Func<Order, bool>.
5.5.2 — Multicast: một delegate giữ nhiều phương thức
Action<Order> handlers = o => Console.WriteLine("Ghi log");
handlers += o => Console.WriteLine("Gửi email");
handlers += o => Console.WriteLine("Cập nhật kho");
handlers(order); // gọi cả ba, theo đúng thứ tự đăng ký
Hai điều cần biết, và cả hai đều gây bất ngờ:
- Một handler ném ngoại lệ thì các handler sau đó không chạy. Không có cơ chế thử tiếp.
- Với
Func, chỉ giá trị trả về của handler CUỐI CÙNG được giữ lại. Kết quả của những cái trước bị vứt đi.
Func<int> f = () => 1;
f += () => 2;
Console.WriteLine(f()); // 2 — giá trị 1 biến mất
Vì hai điều này, multicast hợp với thông báo (Action) và không hợp với tính toán (Func).
5.5.3 — event: delegate bị giới hạn quyền
public class OrderService
{
// Delegate CÔNG KHAI — bên ngoài làm gì cũng được
public Action<Order>? OrderCreated;
// EVENT — bên ngoài chỉ được += và -=
public event Action<Order>? OrderConfirmed;
}
Với delegate công khai, người dùng lớp bạn làm được hai việc nguy hiểm:
service.OrderCreated = null; // xoá sạch handler của người khác
service.OrderCreated!(someOrder); // tự kích hoạt sự kiện giả
event chặn cả hai. Bên ngoài chỉ đăng ký và gỡ đăng ký; chỉ chính class đó mới kích hoạt được.
Quy ước .NET cho event:
public class OrderService
{
public event EventHandler<OrderEventArgs>? OrderConfirmed;
private void OnOrderConfirmed(Order order)
=> OrderConfirmed?.Invoke(this, new OrderEventArgs(order));
}
public class OrderEventArgs(Order order) : EventArgs
{
public Order Order { get; } = order;
}
Dùng EventHandler<T> thay vì Action<T> để theo quy ước: tham số đầu là nguồn phát, tham số sau là dữ liệu. Nhờ vậy handler biết ai đã phát sự kiện.
5.5.4 — Gọi event an toàn
// KHÔNG an toàn — handler có thể bị gỡ giữa hai dòng
if (OrderConfirmed != null)
OrderConfirmed(this, args); // NullReferenceException nếu gỡ ở giữa
// AN TOÀN
OrderConfirmed?.Invoke(this, args);
Toán tử ?. đọc giá trị delegate một lần vào biến tạm rồi mới kiểm tra và gọi, nên không có khe hở giữa kiểm tra và gọi. Đây là cách viết chuẩn kể từ C# 6.
5.5.5 — Rò rỉ bộ nhớ do quên gỡ đăng ký
Đây là phần quan trọng nhất của bài.
public class OrderService // sống suốt vòng đời ứng dụng (Singleton)
{
public event EventHandler<OrderEventArgs>? OrderConfirmed;
}
public class OrderPage // ngắn hạn, tạo theo từng request
{
public OrderPage(OrderService service)
{
service.OrderConfirmed += OnConfirmed; // ĐĂNG KÝ
}
private void OnConfirmed(object? s, OrderEventArgs e) { }
// KHÔNG có chỗ nào gỡ đăng ký
}
Chuyện xảy ra:
Khi bạn viết service.OrderConfirmed += OnConfirmed, delegate được tạo ra giữ tham chiếu tới this — tức tới OrderPage. Và OrderService giữ delegate đó.
Nên OrderService (sống mãi) giữ tham chiếu tới mọi OrderPage từng được tạo. GC không thu hồi được cái nào. Bộ nhớ tăng đều, và triệu chứng chỉ xuất hiện sau vài giờ chạy.
Ba cách xử lý:
// 1. Cài IDisposable và gỡ đăng ký
public class OrderPage : IDisposable
{
private readonly OrderService _service;
public OrderPage(OrderService service)
{
_service = service;
_service.OrderConfirmed += OnConfirmed;
}
public void Dispose() => _service.OrderConfirmed -= OnConfirmed;
}
// 2. Dùng lambda th ì PHẢI giữ lại tham chiếu mới gỡ được
_handler = (s, e) => { /* ... */ };
service.OrderConfirmed += _handler;
// ...
service.OrderConfirmed -= _handler; // gỡ bằng CHÍNH biến đó
Lưu ý quan trọng ở cách 2: service.OrderConfirmed -= (s, e) => { }; không gỡ được gì cả, vì đó là một lambda mới, khác với cái đã đăng ký. Đây là lỗi rất hay gặp.
// 3. Trong ASP.NET Core, thường tốt hơn là đừng dùng event
// Dùng MediatR notification, hoặc một IEventBus có vòng đời rõ ràng
5.5.6 — Event và bất đồng bộ không hợp nhau
// TRÔNG như đúng, nhưng không đợi được
public event EventHandler<OrderEventArgs>? OrderConfirmed;
OrderConfirmed += async (s, e) => await SendEmailAsync(e.Order); // async void!
Handler async gắn vào event trở thành async void, và async void có ba vấn đề: không await được, ngoại lệ bên trong không bắt được ở chỗ gọi mà làm sập tiến trình, và bạn không biết nó đã xong hay chưa.
Trong backend hiện đại, thay event bằng một cơ chế có hỗ trợ bất đồng bộ:
public interface IDomainEventHandler<in TEvent>
{
Task HandleAsync(TEvent e, CancellationToken ct);
}
// Bên phát gọi tuần tự và await được từng handler
foreach (var handler in _handlers)
await handler.HandleAsync(evt, ct);
Đây là hướng mà MediatR và các thư viện tương tự đi, và là lý do event kiểu .NET cổ điển hiếm gặp trong code ASP.NET Core mới.
5.5.7 — Rà lại code của bạn
Danh sách rà soát delegate và event
- •Không có delegate công khai nào đáng lẽ phải là event.
- •Mọi lời gọi event đều dùng ?.Invoke thay vì kiểm tra null rồi gọi.
- •Mỗi += đều có một -= tương ứng ở đâu đó.
- •Lambda dùng làm handler được giữ trong một biến để còn gỡ được.
- •Không có handler async nào gắn vào event (tránh async void).
- •Đối tượng ngắn hạn đăng ký vào đối tượng sống lâu đều cài IDisposable.
- •Trong ASP.NET Core, cân nhắc domain event bất đồng bộ thay cho event .NET cổ điển.
Bài tập áp dụng
Bài 1 — Tái hiện rò rỉ bộ nhớ do event
Tạo một service Singleton có event, và một class ngắn hạn đăng ký vào nó trong vòng lặp 100.000 lần mà không gỡ. In GC.GetTotalMemory(true) trước và sau. Thêm bước gỡ đăng ký rồi đo lại.
Tiêu chí hoàn thành: bạn đo được lượng bộ nhớ rò rỉ và nêu đúng hướng của tham chiếu gây ra nó.
Gợi ý và lời giải — Bài 1
Gợi ý. Khi subscriber.OnDoi được gắn vào publisher.Doi, ai giữ tham chiếu tới ai? Vẽ mũi tên ra giấy trước khi đọc tiếp.
Lời giải.
public class Publisher
{
public event EventHandler? Notified;
public int HandlerCount => Notified?.GetInvocationList().Length ?? 0;
}
public class Subscriber
{
private readonly byte[] _payload = new byte[10_000]; // cho dễ thấy trên biểu đồ
public Subscriber(Publisher p) => p.Notified += OnNotified;
private void OnNotified(object? s, EventArgs e) { }
}
var pub = new Publisher();
var before = GC.GetTotalMemory(true);
for (int i = 0; i < 100_000; i++)
{
var s = new Subscriber(pub); // tạo rồi bỏ, không giữ biến nào
}
var after = GC.GetTotalMemory(true); // true = ép thu gom rác trước khi đo
Console.WriteLine($"tăng {(after - before) / 1024 / 1024} MB, handler còn lại = {pub.HandlerCount}");
Kết quả đo thật trên .NET 9:
sau 100k subscriber không gỡ: tăng 965 MB, handler còn lại = 100000
965 MB rò rỉ, và cả 100.000 handler vẫn còn gắn — dù không dòng code nào còn giữ biến s.
Hướng của tham chiếu — đây là điểm mấu chốt. Trực giác nói rằng subscriber tham chiếu tới publisher, nên bỏ subscriber đi là xong. Thực tế ngược lại:
publisher.Notified ──giữ──> delegate ──giữ──> subscriber
Event là một delegate, và delegate giữ tham chiếu tới đối tượng chứa phương thức được gắn vào. Vì vậy publisher giữ subscriber sống, chứ không phải chiều ngược lại.
Khi publisher là Singleton — sống suốt vòng đời ứng dụng — thì mọi subscriber từng đăng ký đều bị giữ sống vĩnh viễn. Bộ thu gom rác không thể dọn chúng, vì theo nó thì chúng vẫn đang được tham chiếu.
Sửa bằng IDisposable:
public sealed class Subscriber : IDisposable
{
private readonly Publisher _pub;
private readonly byte[] _payload = new byte[10_000];
public Subscriber(Publisher p)
{
_pub = p;
_pub.Notified += OnDoi;
}
private void OnNotified(object? s, EventArgs e) { }
public void Dispose() => _pub.Notified -= OnDoi; // GỠ đăng ký
}
for (int i = 0; i < 100_000; i++)
{
using var s = new Subscriber(pub); // tự gỡ khi ra khỏi phạm vi
}
Đo lại: bộ nhớ trở về gần mức ban đầu, HandlerCount bằng 0.
Vì sao đây là lỗi nghiêm trọng trong ASP.NET Core. Tình huống điển hình: một service Singleton phát sự kiện, và một service Scoped — sống một request — đăng ký vào nó. Mỗi request để lại một đối tượng không bao giờ được dọn. Sau vài triệu request, ứng dụng hết bộ nhớ.
Triệu chứng đặc trưng: bộ nhớ tăng đều theo số request và không bao giờ giảm, kể cả lúc hệ thống rảnh. Đây là dấu hiệu phân biệt rò rỉ thật với việc GC chỉ chưa kịp dọn.
Ba cách phòng, theo thứ tự nên chọn:
- Đừng dùng event xuyên vòng đời. Dùng một bus trong tiến trình, ví dụ
IMediatorhoặcChannel, nơi người nhận được đăng ký một lần lúc khởi động chứ không phải mỗi request. - Cài
IDisposablevà gỡ đăng ký. DI container tự gọiDisposecho serviceScopedkhi request kết thúc. - Dùng mẫu event yếu khi không kiểm soát được vòng đời — phức tạp hơn, chỉ dùng khi hai cách trên không khả thi.
Cách phát hiện trong hệ thống đang chạy. Chụp ảnh heap và tìm những đối tượng lẽ ra phải ngắn hạn nhưng còn sống:
dotnet-gcdump collect -p <pid>
Mở file bằng Visual Studio hoặc PerfView và nhìn cột "đường tới gốc" — nếu nó đi qua một EventHandler, bạn đã tìm ra nguyên nhân.
Bài 2 — Chứng minh lambda không gỡ được
Đăng ký một lambda vào event, rồi thử gỡ bằng một lambda viết lại y hệt. Kiểm tra số handler còn lại.
Tiêu chí hoàn thành: bạn giải thích được vì sao hai lambda giống hệt nhau về mặt văn bản lại không bằng nhau.
Gợi ý và lời giải — Bài 2
Gợi ý. Trình biên dịch làm gì với một biểu thức lambda? Nhớ lại bài 2.10: lambda không tồn tại trong IL, nó được biến đổi thành thứ khác.
Lời giải.
var pub = new Publisher();
pub.Notified += (s, e) => { };
Console.WriteLine($"trước khi gỡ: {pub.HandlerCount} handler");
pub.Notified -= (s, e) => { }; // lambda viết lại y hệt
Console.WriteLine($"sau khi gỡ bằng lambda viết lại y hệt: {pub.HandlerCount} handler");
Kết quả đo thật trên .NET 9:
trước khi gỡ: 1 handler
sau khi gỡ bằng lambda viết lại y hệt: 1 handler
Handler vẫn còn. Lệnh -= không làm gì cả, và không có cảnh báo nào.
Vì sao. Mỗi biểu thức lambda được trình biên dịch biến thành một phương thức riêng biệt trong một lớp ẩn danh. Hai lambda giống hệt nhau về văn bản trở thành hai phương thức khác nhau, với tên sinh tự động khác nhau. Toán tử -= so sánh delegate theo cặp đối tượng đích và phương thức, nên nó không tìm thấy gì để gỡ.
Ba cách gỡ được:
// Cách 1 — giữ tham chiếu tới delegate
EventHandler handler = (s, e) => { };
pub.Notified += handler;
pub.Notified -= handler; // hoạt động
// Cách 2 — dùng phương thức có tên
pub.Notified += OnDoi;
pub.Notified -= OnDoi; // hoạt động — cùng đối tượng, cùng phương thức
// Cách 3 — bọc trong một đối tượng IDisposable
public sealed class DangKy : IDisposable
{
private readonly Action _go;
public DangKy(Action go) => _go = go;
public void Dispose() => _go();
}
var dk = new DangKy(() => pub.Notified -= handler);
Vì sao lỗi này khó phát hiện. Nó có đủ ba đặc điểm tệ nhất:
- Không lỗi biên dịch.
-=với một lambda mới là cú pháp hoàn toàn hợp lệ. - Không ngoại lệ lúc chạy. Gỡ một handler không tồn tại là thao tác hợp lệ, không ném gì.
- Không triệu chứng ngay. Handler thừa vẫn chạy đúng logic của nó; vấn đề chỉ tích tụ dần thành rò rỉ bộ nhớ như bài 1.
Đây là loại lỗi mà bạn chỉ phát hiện khi đã đi tìm nó.
Kiểm tra trong lúc phát triển:
Debug.Assert(pub.HandlerCount == 0, $"Còn {pub.HandlerCount} handler chưa gỡ");
Đặt câu này ở cuối vòng đời của thành phần phát sự kiện. Nó không tốn gì ở bản Release và bắt được lỗi ngay khi nó xuất hiện.
Lưu ý về lambda bắt biến. Lambda không bắt biến nào từ bên ngoài được trình biên dịch lưu đệm thành một thể hiện dùng chung. Lambda có bắt biến thì tạo đối tượng mới mỗi lần — và đối tượng đó giữ luôn các biến bị bắt, làm rò rỉ lan rộng hơn. Đây là lý do một lambda tưởng nhỏ lại có thể giữ sống cả một cây đối tượng lớn.
Bài 3 — Multicast nuốt giá trị trả về
Tạo Func<int> multicast với ba handler trả về 1, 2, 3. Gọi và ghi lại kết quả. Giải thích vì sao hai giá trị biến mất.
Tiêu chí hoàn thành: bạn nêu được cách lấy đủ kết quả của mọi handler.
Gợi ý và lời giải — Bài 3
Gợi ý. Một delegate multicast gọi lần lượt các handler. Nhưng chữ ký của nó chỉ cho phép trả về một giá trị. Vậy giá trị nào được trả?
Lời giải.
Func<int> f = () => 1;
f += () => 2;
f += () => 3;
Console.WriteLine($"trả về: {f()} (có {f.GetInvocationList().Length} handler)");
Kết quả đo thật trên .NET 9:
Func multicast trả về: 3 (có 3 handler)
Cả ba handler đều chạy, nhưng chỉ giá trị của handler cuối cùng được trả v ề. Hai giá trị đầu bị vứt bỏ, không cảnh báo.
Lấy đủ kết quả bằng GetInvocationList:
var ketQua = f.GetInvocationList()
.Cast<Func<int>>()
.Select(h => h())
.ToList();
Console.WriteLine(string.Join(", ", ketQua)); // 1, 2, 3
Cùng vấn đề với tham số out và ref — chỉ giá trị của handler cuối còn lại. Và cùng vấn đề với async:
// SAI — chỉ Task của handler cuối được chờ
event Func<Task>? DaTaoDon;
await DaTaoDon?.Invoke(); // hai handler đầu chạy nhưng KHÔNG được chờ
// ĐÚNG
if (DaTaoDon is not null)
{
var tasks = DaTaoDon.GetInvocationList()
.Cast<Func<Task>>()
.Select(h => h());
await Task.WhenAll(tasks);
}
Trường hợp async nguy hiểm nhất, vì hai handler đầu trở thành fire and forget: ngoại lệ trong chúng bị nuốt, và ứng dụng có thể kết thúc trước khi chúng chạy xong.
Kết luận thực dụng. Delegate multicast được thiết kế cho thông báo, không cho thu thập kết quả. Dấu hiệu bạn đang dùng sai công cụ:
| Nếu bạn cần | Thì đừng dùng event, hãy dùng |
|---|---|
| Kết quả trả về từ người nhận | Một interface với danh sách cài đặt tường minh |
| Bảo đảm thứ tự thực hiện | Gọi trực tiếp theo thứ tự |
| Chờ các thao tác bất đồng bộ | Danh sách Func<Task> và Task.WhenAll |
| Xử lý ngoại lệ của từng người nhận | Vòng lặp có try/catch riêng cho mỗi handler |
Một điểm nữa về ngoại lệ. Nếu handler thứ nhất ném ngoại lệ, các handler còn lại không bao giờ chạy, và người gọi nhận đúng ngoại lệ đó. Để mọi handler đều được chạy bất kể lỗi:
foreach (var h in f.GetInvocationList().Cast<Func<int>>())
{
try { ketQua.Add(h()); }
catch (Exception ex) { _logger.LogError(ex, "Handler lỗi"); }
}
Với nhu cầu phức tạp hơn thế, hãy chuyển sang một bus sự kiện thật. Module 17 mở rộng ý này ra phạm vi nhiều tiến trình, nơi các vấn đề về thứ tự, lỗi và thử lại đều được xử lý tường minh.
Tự kiểm tra
Frequently asked questions
Delegate là gì và liên quan gì tới lambda?
Delegate là con trỏ hàm có kiểu: một biến giữ được một phương thức và gọi nó sau. Func, Action, Predicate đều là delegate dựng sẵn. Mọi biểu thức lambda bạn truyền cho LINQ đều được biên dịch thành một delegate, ví dụ orders.Where với điều kiện chính là truyền một Func nhận Order trả bool.
event khác gì với một delegate công khai?
event giới hạn quyền của bên ngoài: chỉ được cộng bằng và trừ bằng để đăng ký hoặc gỡ, không gán đè được và không tự kích hoạt được. Với delegate công khai, người dùng lớp bạn có thể gán null xoá sạch handler của người khác, hoặc tự phát một sự kiện giả.
Vì sao quên gỡ đăng ký event lại gây rò rỉ bộ nhớ?
Vì khi đăng ký một phương thức instance làm handler, delegate tạo ra giữ tham chiếu tới this. Nếu đối tượng phát sự kiện sống lâu — ví dụ một singleton — thì nó giữ tham chiếu tới mọi đối tượng ngắn hạn đã đăng ký, và GC không thu hồi được cái nào. Bộ nhớ tăng đều và chỉ lộ ra sau vài giờ chạy.
Vì sao gỡ đăng ký bằng một lambda viết lại y hệt không có tác dụng?
Vì mỗi biểu thức lambda tạo ra một đối tượng delegate mới, khác với cái đã đăng ký dù nội dung giống hệt. Muốn gỡ được thì phải giữ lambda đó trong một biến lúc đăng ký và dùng chính biến đó khi gỡ.
Multicast delegate có gì cần lưu ý?
Hai điều. Một handler ném ngoại lệ thì các handler sau không chạy và không có cơ chế thử tiếp. Và với Func, chỉ giá trị trả về của handler cuối cùng được giữ lại, kết quả của những cái trước bị vứt đi. Vì vậy multicast hợp với thông báo chứ không hợp với tính toán.
Vì sao event không hợp với bất đồng bộ?
Vì handler async gắn vào event trở thành async void: không await được nên bên phát không biết nó xong chưa, và ngoại lệ bên trong không bắt được ở chỗ gọi mà làm sập tiến trình. Trong backend hiện đại nên thay bằng cơ chế domain event trả Task, như cách MediatR làm.
Kết luận
Ba điều đáng nhớ nhất:
eventlà delegate bị giới hạn quyền. Dùng nó thay cho delegate công khai, luôn luôn.- Mỗi
+=cần một-=. Đây là nguồn rò rỉ bộ nhớ phổ biến nhất trong .NET không liên quan tới unmanaged resource. - Đừng gắn handler
asyncvào event.async voidlà thứ bạn không muốn trong backend.
Tham khảo
- Delegates — C# programming guide
- Events overview — quy ước
EventHandler<T> - LINQ overview — nơi delegate xuất hiện nhiều nhất
- Fundamentals of garbage collection — vì sao tham chiếu ngăn thu hồi
Điều hướng
- Bài trước: 5.3 — 3. LINQ
- Bài tiếp theo: 5.5 — 5. Extension Methods
- Về module: Trang mục lục