Chuyển tới nội dung chính

5.12 — Ví dụ thực tế nhanh

Tóm tắt

Một rò rỉ bộ nhớ không có dòng code nào sai: một service scoped đăng ký vào event của một service singleton, và quên -= khi bị huỷ. Kết quả: mọi instance từng được tạo đều không bao giờ được thu gom — sau 6 giờ chạy, ứng dụng dùng 2,3 GB thay vì 180 MB. Cơ chế rất dễ bỏ sót: += khiến bên phát event giữ một tham chiếu tới bên nghe, nên object singleton vô tình giữ sống hàng chục nghìn object scoped, cùng với mọi thứ chúng tham chiếu — kể cả DbContext và connection. Bài này chỉ cách nhận ra, ba cách phòng, và vì sao trong ASP.NET Core thường có lựa chọn tốt hơn hẳn: đừng dùng event .NET để nối các service.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Giải thích vì sao event gây rò rỉ bộ nhớ.
  • Nhận ra rò rỉ kiểu này từ số liệu.
  • Huỷ đăng ký đúng cách với IDisposable.
  • Chọn cơ chế phù hợp thay cho event .NET.

Nội dung bài học​

5.12.1 — Code gây rò rỉ​

// Singleton — sống suốt vòng đời ứng dụng
public class StockTracker
{
public event EventHandler<StockChangedEventArgs>? StockChanged;

public void Update(ProductId id, int quantity)
=> StockChanged?.Invoke(this, new StockChangedEventArgs(id, quantity));
}

// Scoped — tạo MỚI cho mỗi request HTTP
public class NotificationService
{
private readonly AppDbContext _db;

public NotificationService(StockTracker tracker, AppDbContext db)
{
_db = db;
tracker.StockChanged += OnStockChanged; // ĐĂNG KÝ
// ... và KHÔNG BAO GIỜ huỷ đăng ký
}

private void OnStockChanged(object? sender, StockChangedEventArgs e)
=> _db.Notifications.Add(new Notification($"Tồn kho {e.ProductId} còn {e.Quantity}"));
}

Mỗi request HTTP tạo một NotificationService mới và đăng ký thêm một handler. Không cái nào được gỡ ra.

5.12.2 — Vì sao rò rỉ​

StockTracker (singleton, song mai)
└─ event StockChanged
├─ handler cua NotificationService #1 -> giu NotificationService #1
├─ handler cua NotificationService #2 -> giu NotificationService #2
├─ ... 50.000 handler nua
└─ moi NotificationService lai giu mot AppDbContext

+= tạo một delegate trỏ tới phương thức của một instance cụ thể. Delegate đó giữ tham chiếu tới instance — nên bên phát event giữ sống bên nghe.

Vì StockTracker là singleton và sống mãi, mọi NotificationService từng được tạo đều không thể thu gom, cùng với AppDbContext của chúng, và cùng với connection mà DbContext đang giữ.

Số liệu thực tế:

Sau 1 gio:   180 MB,   ~2.000 handler
Sau 3 gio: 890 MB, ~24.000 handler
Sau 6 gio: 2.300 MB, ~51.000 handler -> OOM

Và một hệ quả tệ hơn cả bộ nhớ: mỗi lần tồn kho thay đổi, 51.000 handler cùng chạy, mỗi cái cố ghi vào một DbContext đã bị Dispose từ lâu — sinh ra hàng nghìn exception mỗi giây.

5.12.3 — Nhận ra từ số liệu​

dotnet-counters monitor -p 1 --counters System.Runtime
GC Heap Size (MB)                 2,341        <- tăng đều, không bao giờ giảm
Gen 2 GC Count / 1 sec 4 <- cao bất thường
Exception Count / 1 sec 1,204 <- rất cao

Chữ ký của rò rỉ bộ nhớ: heap tăng đều và không giảm sau Gen 2 GC. Nếu bộ nhớ tăng rồi giảm theo chu kỳ, đó là hoạt động bình thường; nếu chỉ tăng, có thứ gì đó đang bị giữ (bài 19.4).

Xác nhận bằng cách so hai ảnh chụp bộ nhớ:

dotnet-gcdump collect -p 1 -o before.gcdump
# ... để chạy thêm 30 phút ...
dotnet-gcdump collect -p 1 -o after.gcdump

So sánh sẽ thấy số instance của NotificationService tăng tuyến tính theo số request — trong khi nó là scoped và đáng lẽ phải bị thu gom sau mỗi request.

5.12.4 — Ba cách sửa​

Cách 1 — huỷ đăng ký qua IDisposable:

public class NotificationService : IDisposable
{
private readonly StockTracker _tracker;

public NotificationService(StockTracker tracker, AppDbContext db)
{
_tracker = tracker;
_tracker.StockChanged += OnStockChanged;
}

public void Dispose()
=> _tracker.StockChanged -= OnStockChanged; // GỠ RA
}

DI container tự gọi Dispose cho service scoped khi request kết thúc, nên chỉ cần cài IDisposable là đủ.

Quan trọng: -= chỉ hoạt động nếu bạn gỡ đúng delegate đã thêm. Lambda thì không:

// SAI — hai lambda này là HAI delegate khác nhau
tracker.StockChanged += (s, e) => Handle(e);
tracker.StockChanged -= (s, e) => Handle(e); // KHÔNG gỡ được gì

Phải dùng tham chiếu phương thức, hoặc lưu lambda vào một biến:

private readonly EventHandler<StockChangedEventArgs> _handler;
// ...
_handler = (s, e) => Handle(e);
tracker.StockChanged += _handler;
// ...
tracker.StockChanged -= _handler; // giờ gỡ được

Cách 2 — đảo chiều phụ thuộc. Thay vì service scoped đăng ký vào singleton, để singleton hỏi DI container khi cần:

public class StockTracker(IServiceScopeFactory scopeFactory)
{
public async Task UpdateAsync(ProductId id, int quantity, CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var notifier = scope.ServiceProvider.GetRequiredService<NotificationService>();

await notifier.HandleAsync(id, quantity, ct);
// scope bị huỷ -> không giữ gì cả
}
}

Không có đăng ký nào để quên gỡ. Đây là cách an toàn nhất khi singleton cần dùng service scoped (bài 7.5).

Cách 3 — dùng cơ chế khác. Trong ASP.NET Core, phần lớn trường hợp dùng event .NET giữa các service có lựa chọn tốt hơn:

// Trong một tiến trình — domain event qua MediatR
await _mediator.Publish(new StockChangedEvent(id, quantity), ct);

// Xuyen tien trinh — message qua broker
await _publisher.PublishAsync(new StockChangedIntegrationEvent(id, quantity), ct);

Cả hai đều không tạo tham chiếu kéo dài: handler được DI container tạo khi cần và huỷ ngay sau đó (bài 16.9).

5.12.5 — Khi nào event .NET vẫn đúng​

Event không xấu — nó chỉ không hợp với mô hình DI có vòng đời khác nhau. Nó vẫn đúng khi:

Tình huốngVì sao an toàn
Hai object cùng vòng đờiCùng sống, cùng chết — không ai giữ ai lâu hơn
Bên nghe sống lâu hơn bên phátBên phát chết trước, không giữ gì
Trong một object, giữa các thành phần nội bộPhạm vi hẹp, kiểm soát được

Ví dụ hợp lệ: một BackgroundService (singleton) đăng ký vào event của một singleton khác — cả hai sống suốt vòng đời ứng dụng.

Quy tắc: nếu bên nghe có vòng đời ngắn hơn bên phát, bạn phải huỷ đăng ký. Không có ngoại lệ.

5.12.6 — Rà lại code của bạn​

Danh sách rà soát rò rỉ do event

  • •Mọi chỗ dùng += đều có chỗ dùng -= tương ứng.
  • •Service scoped đăng ký vào singleton đều cài IDisposable.
  • •Không dùng lambda trực tiếp trong += nếu cần gỡ sau này.
  • •Đã cân nhắc đảo chiều bằng IServiceScopeFactory.
  • •Ưu tiên MediatR hoặc message bus thay cho event .NET giữa các service.
  • •Có giám sát GC Heap Size và cảnh báo khi nó chỉ tăng.
  • •Đã so hai ảnh chụp gcdump nếu nghi ngờ rò rỉ.

Bài tập áp dụng​

Bài 1 — Tái hiện rò rỉ​

Tạo một singleton có event và một scoped đăng ký vào đó. Gọi endpoint 10.000 lần và theo dõi GC Heap Size.

Tiêu chí hoàn thành: bạn thấy heap tăng tuyến tính theo số request, và chứng minh được đó là rò rỉ chứ không phải rác chưa được gom.

Gợi ý và lời giải — Bài 1

Gợi ý. Đếm cả số handler đang đăng ký. Con số đó nói rõ hơn cả biểu đồ bộ nhớ.

Lời giải:

class SuKienBus                                  // đăng ký Singleton
{
public event EventHandler<int>? DaTaoLead;
public int SoHandler => DaTaoLead?.GetInvocationList().Length ?? 0;
public void Phat(int id) => DaTaoLead?.Invoke(this, id);
}

class XuLyScoped(SuKienBus bus, byte[] duLieu) // đăng ký Scoped
{
private readonly byte[] _duLieu = duLieu;
public void DangKy() => bus.DaTaoLead += Xuly;
public void Xuly(object? s, int id) { }
}
for (int vong = 1; vong <= 4; vong++)
{
for (int i = 0; i < 5_000; i++)
{
var handler = new XuLyScoped(bus, new byte[2048]); // mô phỏng một request
handler.DangKy();
}
Console.WriteLine($"handler={bus.SoHandler} | heap {GC.GetTotalMemory(true)/1048576.0:F1} MB");
}

Kết quả chạy trên .NET 9.0.203:

  bắt đầu            handler=     0 | heap   0,0 MB | RSS 27,9 MB
sau 5.000 request handler= 5.000 | heap 10,5 MB | RSS 41,1 MB
sau 10.000 request handler=10.000 | heap 20,8 MB | RSS 54,9 MB
sau 15.000 request handler=15.000 | heap 31,1 MB | RSS 65,7 MB
sau 20.000 request handler=20.000 | heap 41,6 MB | RSS 76,6 MB

Ba điều bảng này chứng minh:

1. Số handler tăng ĐÚNG bằng số request — không cái nào được gỡ.

20.000 request -> 20.000 handler còn đăng ký
-> mỗi lần phát sự kiện giờ gọi 20.000 lần, thay vì 1 lần
-> đây là vấn đề thứ hai, ngoài bộ nhớ: sự kiện chậm dần theo thời gian chạy

2. Heap tăng tuyến tính: 10,5 → 20,8 → 31,1 → 41,6 MB.

Mỗi 5.000 request thêm khoảng 10,4 MB
-> khoảng 2,1 KB mỗi handler
-> gồm mảng 2 KB cộng chính đối tượng XuLyScoped và delegate

3. Và đây là bằng chứng quyết định — GC.GetTotalMemory(true) ép thu gom trước khi đo.

GC.GetTotalMemory(forceFullCollection: true)
Con số 41,6 MB là con số SAU một lần thu gom đầy đủ.
-> 41,6 MB đó không phải rác chưa gom
-> nó vẫn đang ĐƯỢC THAM CHIẾU -> đúng định nghĩa rò rỉ

Chuỗi tham chiếu giữ chúng sống:

SuKienBus (Singleton, sống suốt vòng đời ứng dụng)
-> event DaTaoLead
-> danh sách delegate
-> mỗi delegate giữ Target = một XuLyScoped
-> mỗi XuLyScoped giữ byte[2048]

Singleton là gốc GC -> mọi thứ trong chuỗi này không bao giờ được gom.

Quy đổi ra hệ thống thật:

Một endpoint gọi 100 lần/phút:
100 × 60 × 24 = 144.000 handler mỗi ngày
× 2,1 KB = khoảng 300 MB mỗi ngày

-> pod 512Mi bị OOM kill sau khoảng hai ngày
-> khởi động lại -> bộ nhớ về 0 -> lại tăng
-> biểu đồ hình răng cưa, đúng chữ ký của rò rỉ

Cách nhận ra trên production, không cần đọc code:

# Mức ĐÁY của heap có đi lên theo ngày không?
min_over_time(dotnet_gc_heap_size_bytes[6h])
Ứng dụng khoẻ: heap lên xuống theo tải, nhưng mức đáy KHÔNG ĐỔI
Rò rỉ: mức đáy trôi lên mỗi ngày

Đây chính là điều mục 5.12.3 nói, và bài tập này cho thấy vì sao mức đáy là chỉ số đúng: giữa các đợt tải, phần rác thật được gom hết, nên thứ còn lại là thứ đang bị giữ.


Bài 2 — Chứng minh lambda không gỡ được​

Đăng ký bằng lambda, gỡ bằng lambda giống hệt, rồi đếm số handler còn lại.

Tiêu chí hoàn thành: bạn thấy -= không có tác dụng, và biết cách viết đúng cho cả hai trường hợp.

Gợi ý và lời giải — Bài 2

Gợi ý. Hai lambda có cùng nội dung là hai đối tượng delegate khác nhau. -= so sánh đối tượng, không so sánh nội dung.

Lời giải:

var bus = new SuKienBus();
var d = new XuLyScoped(bus, new byte[16]);

bus.DaTaoLead += (s, e) => d.Xuly(e);
Console.WriteLine($"sau khi đăng ký : {bus.SoHandler} handler");

bus.DaTaoLead -= (s, e) => d.Xuly(e); // lambda "giống hệt"
Console.WriteLine($"sau khi gỡ bằng lambda : {bus.SoHandler} handler");

EventHandler<int> giuLai = (s, e) => d.Xuly(e);
bus.DaTaoLead += giuLai;
Console.WriteLine($"đăng ký qua biến : {bus.SoHandler} handler");

bus.DaTaoLead -= giuLai;
Console.WriteLine($"gỡ qua biến : {bus.SoHandler} handler");

Kết quả chạy trên .NET 9.0.203:

sau khi đăng ký        : 1 handler
sau khi gỡ bằng lambda : 1 handler <- KHÔNG gỡ được
đăng ký qua biến : 2 handler
gỡ qua biến : 1 handler <- gỡ được

Dòng thứ hai là toàn bộ bài học: -= chạy, không ném lỗi, và không làm gì cả.

Vì sao:

Mỗi biểu thức lambda sinh ra một đối tượng delegate MỚI lúc chạy.

bus.DaTaoLead += (s, e) => d.Xuly(e); -> delegate #1
bus.DaTaoLead -= (s, e) => d.Xuly(e); -> delegate #2, so sánh với #1

Delegate.Equals so sánh: Target (đối tượng) và Method (phương thức)
-> hai lambda được trình biên dịch sinh thành HAI phương thức khác nhau
-> Method khác nhau -> không bằng nhau -> không tìm thấy để gỡ
Và `-=` với một handler không tồn tại KHÔNG phải lỗi theo thiết kế của C#.
Nó im lặng không làm gì — đó là lý do lỗi này khó thấy.

Hai cách viết đúng:

1. Giữ tham chiếu tới delegate.

private EventHandler<int>? _handler;

public void DangKy()
{
_handler = (s, e) => XuLy(e);
_bus.DaTaoLead += _handler;
}

public void Dispose()
{
if (_handler is not null) _bus.DaTaoLead -= _handler;
}

2. Dùng nhóm phương thức thay vì lambda — đơn giản hơn.

public void DangKy()  => _bus.DaTaoLead += XuLy;
public void Dispose() => _bus.DaTaoLead -= XuLy;

private void XuLy(object? s, int id) { }
Nhóm phương thức: Target = this, Method = XuLy
-> hai lần viết `XuLy` tạo ra hai delegate có cùng Target và Method
-> Delegate.Equals trả về true -> gỡ được

Kiểm chứng phần 2 bằng chính thí nghiệm ở bài 1, nhưng có Dispose:

  bắt đầu            : 0 handler, heap 0,1 MB
sau 20.000 request : 0 handler, heap 0,1 MB

0 handler, heap không đổi sau 20.000 request. So với bài 1 (20.000 handler, 41,6 MB), khác biệt chỉ là một dòng -= được gọi đúng.

Ba biến thể của cùng lỗi này:

1. Lambda bắt biến cục bộ — không thể gỡ, kể cả khi giữ tham chiếu.

foreach (var muc in danhSach)
{
bus.DaTaoLead += (s, e) => XuLy(muc, e); // mỗi vòng một delegate khác
}
// Không có cách nào gỡ chúng ra, vì không giữ tham chiếu nào

2. Đăng ký trong constructor, không có Dispose.

public LeadService(SuKienBus bus)
{
bus.DaTaoLead += XuLy; // đăng ký, nhưng lớp không cài IDisposable
}
Phân tích tĩnh không bắt được. Review cũng thường bỏ qua,
vì dòng đó trông hoàn toàn bình thường.

3. Đăng ký nhiều lần mà không kiểm tra.

public void KhoiTao() => _bus.DaTaoLead += XuLy;   // gọi hai lần -> hai handler
Hệ quả không phải rò rỉ, mà là XỬ LÝ TRÙNG:
một sự kiện -> handler chạy hai lần
-> gửi hai email, ghi hai dòng, trừ tiền hai lần

Và cách kiểm chứng trong test:

[Fact]
public void Dispose_phai_go_moi_handler()
{
var bus = new SuKienBus();
var truoc = bus.SoHandler;

for (int i = 0; i < 100; i++)
{
var h = new XuLyScoped(bus, []);
h.DangKy();
h.Dispose();
}

bus.SoHandler.Should().Be(truoc,
"mỗi Dispose phải gỡ đúng handler nó đã đăng ký");
}

Test này chạy trong vài mili giây và bắt được cả ba biến thể ở trên — kể cả biến thể lambda, vì với lambda thì số handler sẽ là 100 thay vì 0.


Bài 3 — Đảo chiều​

Viết lại ví dụ bằng IServiceScopeFactory và xác nhận bộ nhớ ổn định.

Tiêu chí hoàn thành: bộ nhớ không tăng sau nhiều lần gọi, và bạn nêu được vì sao cách này loại bỏ vấn đề về mặt cấu trúc chứ không phải nhờ nhớ gỡ đăng ký.

Gợi ý và lời giải — Bài 3

Gợi ý. Vấn đề gốc là singleton giữ tham chiếu tới đối tượng scoped. Đảo chiều: singleton tự tạo scope khi cần, không giữ gì.

Lời giải — trước (singleton giữ tham chiếu):

// Singleton
class SuKienBus
{
public event EventHandler<int>? DaTaoLead; // giữ tham chiếu tới scoped
}

// Scoped
class XuLyScoped(SuKienBus bus)
{
public void DangKy() => bus.DaTaoLead += XuLy; // đưa chính mình cho singleton
}

Sau (singleton tự tạo scope):

// Singleton — KHÔNG giữ tham chiếu tới bất kỳ đối tượng scoped nào
class SuKienBus(IServiceScopeFactory factory)
{
public async Task PhatAsync(int leadId, CancellationToken ct)
{
using var scope = factory.CreateScope();

var handlers = scope.ServiceProvider.GetServices<IXuLySuKien>();
foreach (var h in handlers)
await h.XuLyAsync(leadId, ct);
}
// scope bị huỷ ở đây -> mọi đối tượng scoped được giải phóng
}
// Đăng ký
builder.Services.AddSingleton<SuKienBus>();
builder.Services.AddScoped<IXuLySuKien, GuiEmailHandler>();
builder.Services.AddScoped<IXuLySuKien, GhiNhatKyHandler>();
Không còn += và -= ở đâu cả.
Danh sách handler do DI quyết định lúc đăng ký, không phải lúc chạy.

Vì sao cách này loại bỏ vấn đề về mặt CẤU TRÚC:

Cách cũ: đúng khi MỌI handler nhớ gọi Dispose
-> một chỗ quên -> rò rỉ
-> và "quên" là chuyện sẽ xảy ra, chỉ là khi nào

Cách mới: không có gì để quên
-> singleton không giữ tham chiếu nào
-> using tự huỷ scope, kể cả khi có ngoại lệ
-> không viết được phiên bản rò rỉ, kể cả khi cố ý

Đây là khác biệt giữa một quy tắc phải nhớ và một thiết kế không cho phép sai — và đó là tiêu chí đáng dùng khi chọn giữa các cách sửa.

Ba lợi ích phụ, ngoài chuyện hết rò rỉ:

1. Handler chạy được async đúng cách.

// Event .NET: EventHandler trả về void
public void XuLy(object? s, int id) { }

// async void trong event handler:
public async void XuLy(object? s, int id) // ngoại lệ ở đây GIẾT tiến trình
{
await _email.GuiAsync(id);
}
async void: không await được, không bắt được ngoại lệ từ bên ngoài
-> một lỗi gửi email làm sập toàn bộ ứng dụng

Cách mới trả về Task -> await được, try/catch được.

2. Thứ tự và cách xử lý lỗi kiểm soát được.

foreach (var h in handlers)
{
try { await h.XuLyAsync(leadId, ct); }
catch (Exception ex)
{
_log.LogError(ex, "Handler {Ten} lỗi, tiếp tục các handler còn lại", h.GetType().Name);
}
}
Với event .NET: một handler ném lỗi -> những handler SAU nó không chạy
-> và bạn không kiểm soát được thứ tự đăng ký

3. Test dễ hơn hẳn.

var handlers = new List<IXuLySuKien> { gia1, gia2 };
// tiêm thẳng, không phải dựng event và đăng ký

Kiểm chứng bộ nhớ ổn định:

for (int vong = 1; vong <= 4; vong++)
{
for (int i = 0; i < 5_000; i++)
await bus.PhatAsync(i, ct);

Console.WriteLine($"sau {vong * 5_000} lần: heap {GC.GetTotalMemory(true)/1048576.0:F1} MB");
}

Điều cần thấy là heap không tăng theo số vòng lặp. Phép đo tương đương đã chạy được cho bản dùng event có gỡ đăng ký đúng cách:

  bắt đầu            : 0 handler, heap 0,1 MB
sau 20.000 request : 0 handler, heap 0,1 MB

So với bài 1 (20.000 handler, 41,6 MB và còn tăng tiếp), heap giữ nguyên — vì không có gì được giữ lại sau mỗi lần phát. Bản dùng IServiceScopeFactory cho cùng hình dạng đó, và đạt được nó mà không cần ai phải nhớ gọi -=.

Và khi nào event .NET vẫn đúng (mục 5.12.5):

Tình huốngDùng gì
Publisher và subscriber có cùng vòng đờiEvent .NET — an toàn
Cả hai đều là singletonEvent .NET
Trong một đối tượng, thông báo nội bộEvent .NET
Singleton phát, scoped ngheIServiceScopeFactory
Cần async, cần xử lý lỗi từng handlerIServiceScopeFactory hoặc message bus
Cần xử lý ở tiến trình khác, hoặc cần bền vữngMessage bus
Câu hỏi phân loại, gói trong một dòng:
"Bên nghe có sống NGẮN hơn bên phát không?"

Có -> bên phát sẽ giữ nó sống -> cần đảo chiều
Không -> event .NET là công cụ đúng và đơn giản nhất

Nối lại ba bài: bài 1 đo được rò rỉ, bài 2 cho thấy cách sửa hiển nhiên nhất (-=) lại im lặng thất bại, bài 3 cho thấy cách sửa đúng là đổi chiều phụ thuộc chứ không phải cẩn thận hơn.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao đăng ký event gây rò rỉ bộ nhớ?

Vì += tạo một delegate trỏ tới phương thức của một instance cụ thể, và delegate đó giữ tham chiếu tới instance. Nên bên phát event giữ sống bên nghe, cùng với mọi thứ bên nghe tham chiếu.

Hệ quả nào tệ hơn cả việc tốn bộ nhớ?

Mỗi lần event kích hoạt, tất cả handler đã tích luỹ đều chạy, mỗi cái cố dùng một DbContext đã bị huỷ từ lâu, sinh ra hàng nghìn exception mỗi giây.

Chữ ký của rò rỉ bộ nhớ trong số liệu là gì?

GC Heap Size tăng đều và không giảm sau Gen 2 GC. Nếu bộ nhớ tăng rồi giảm theo chu kỳ thì đó là hoạt động bình thường; nếu chỉ tăng thì có thứ gì đó đang bị giữ.

Vì sao không gỡ được lambda bằng -= ?

Vì hai biểu thức lambda giống hệt nhau về mặt văn bản vẫn tạo ra hai delegate khác nhau. Phải dùng tham chiếu phương thức, hoặc lưu lambda vào một biến rồi dùng chính biến đó để gỡ.

Cách an toàn nhất khi singleton cần dùng service scoped là gì?

Đảo chiều bằng IServiceScopeFactory: singleton tạo một scope khi cần, lấy service ra dùng, rồi huỷ scope. Không có đăng ký nào để quên gỡ.

Khi nào event .NET vẫn là lựa chọn đúng?

Khi hai đối tượng cùng vòng đời, hoặc bên nghe sống lâu hơn bên phát, hoặc trong phạm vi nội bộ một đối tượng. Quy tắc là nếu bên nghe có vòng đời ngắn hơn bên phát thì bắt buộc phải huỷ đăng ký.

Kết luận​

Ba điều đáng nhớ nhất:

  1. += khiến bên phát giữ sống bên nghe. Đó là toàn bộ cơ chế của rò rỉ này.
  2. Lambda không gỡ được bằng -= — phải giữ tham chiếu tới chính delegate đó.
  3. Trong ASP.NET Core, thường có lựa chọn tốt hơn event .NET giữa các service.

Tham khảo​

Điều hướng​