5.11 - Sự cố thực tế: rò rỉ bộ nhớ do event
Tóm tắt: Một rò rỉ bộ nhớ không có dòng code nào sai cú pháp: một service sống theo request đăng ký vào event của một service sống suốt ứng dụng, 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, bộ nhớ tăng đều theo số request cho tới khi tiến trình bị kill. 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 sống lâu vô tình giữ sống hàng chục nghìn object ngắn hạn, cùng với mọi thứ chúng tham chiếu — kể cảDbContextvà 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ớ khi hai bên có vòng đời khác nhau.
- Tái hiện rò rỉ bằng một console app và đo được nó.
- Huỷ đăng ký đúng cách với
IDisposable, và viết test bắt lỗi quên huỷ. - Đảo chiều phụ thuộc bằng
IServiceScopeFactoryđể loại bỏ vấn đề về mặt cấu trúc.
Cần biết trước: bài 5.4 (event, rò rỉ do quên -=). Bài này dùng hai khái niệm của DI sẽ học kỹ ở bài 7.4: service singleton được tạo một lần và sống suốt ứng dụng; service scoped được tạo mới cho mỗi request HTTP và bị huỷ (Dispose) khi request kết thúc.
Nội dung bài học
5.11.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(int productId, int quantity)
=> StockChanged?.Invoke(this, new StockChangedEventArgs(productId, quantity));
}
// Scoped — tạo MỚI cho mỗi request HTTP
public class NotificationService
{
private readonly CrmDbContext _db;
public NotificationService(StockTracker tracker, CrmDbContext 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.11.2 — Vì sao rò rỉ
StockTracker (singleton, sống mãi)
└─ event StockChanged
├─ handler của NotificationService #1 -> giữ NotificationService #1
├─ handler của NotificationService #2 -> giữ NotificationService #2
├─ ... 50.000 handler nữa
└─ mỗi NotificationService lại giữ một CrmDbContext+= 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 CrmDbContext của chúng.
Diễn biến điển hình của một sự cố như vậy (số liệu minh hoạ, không phải đo từ một hệ thống cụ thể):
Sau 1 giờ: 180 MB, ~2.000 handler
Sau 3 giờ: 890 MB, ~24.000 handler
Sau 6 giờ: 2.300 MB, ~51.000 handler -> OOMVà một hệ quả tệ hơn cả bộ nhớ: mỗi lần tồn kho thay đổi, event gọi các handler theo thứ tự đăng ký. Handler đầu tiên thuộc về một request đã kết thúc từ lâu, nên nó dùng một DbContext đã bị Dispose và ném ObjectDisposedException. Như bài 5.4 đã nói, một handler ném ngoại lệ thì mọi handler sau nó không chạy — kể cả handler hợp lệ của request hiện tại. Tính năng thông báo hỏng hoàn toàn, và mỗi lần cập nhật tồn kho lại sinh thêm một ngoại lệ.
5.11.3 — Nhận ra từ số liệu
Công cụ dotnet-counters (cài bằng dotnet tool install -g dotnet-counters) theo dõi số liệu của một tiến trình .NET đang chạy:
dotnet-counters monitor -p <pid> --counters System.RuntimeMột màn hình kiểu này là dấu hiệu đáng ngờ (số liệu minh hoạ):
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 35 <- mỗi lần cập nhật tồn kho một ngoại lệ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.3).
Xác nhận bằng cách so hai ảnh chụp bộ nhớ:
dotnet-gcdump collect -p <pid> -o before.gcdump
# ... để chạy thêm 30 phút ...
dotnet-gcdump collect -p <pid> -o after.gcdumpSo 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.11.4 — Ba cách sửa
Cách 1 — huỷ đăng ký qua IDisposable:
public class NotificationService : IDisposable
{
private readonly StockTracker _tracker;
private readonly CrmDbContext _db;
public NotificationService(StockTracker tracker, CrmDbContext db)
{
_tracker = tracker;
_db = db;
_tracker.StockChanged += OnStockChanged;
}
private void OnStockChanged(object? sender, StockChangedEventArgs e)
=> _db.Notifications.Add(new Notification($"Tồn kho {e.ProductId} còn {e.Quantity}"));
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 viết lại 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ỡ đượcCá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 void Update(int productId, int quantity)
{
using var scope = scopeFactory.CreateScope();
var notifier = scope.ServiceProvider.GetRequiredService<NotificationService>();
notifier.Notify(productId, quantity);
} // 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.4).
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: một danh sách handler do DI container tạo theo từng lần phát, như Bài tập 3 dưới đây. Thư viện MediatR làm sẵn việc này (từ v13 cần giấy phép thương mại với một số trường hợp sử dụng); tự viết như Bài 3 chỉ mất vài chục dòng.
- Xuyên tiến trình: gửi message qua một message broker (Module 17).
Cả hai đều không tạo tham chiếu kéo dài: handler được tạo khi cần và huỷ ngay sau đó (bài 16.8).
5.11.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ống | Vì sao an toàn |
|---|---|
| Hai object cùng vòng đời | Cù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át | Bê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.11.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.
- Giữa các service, ưu tiên danh sách handler do DI tạo hoặc message bus thay cho event .NET.
- 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 bus sự kiện (đóng vai singleton) và một handler (đóng vai scoped) đăng ký vào đó. Mô phỏng 20.000 request bằng vòng lặp, mỗi request tạo một handler mới, và theo dõi số handler cùng kích thước heap.
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. Không cần dựng web API: một console app mô phỏng đủ. Chạy bằng dotnet run -c Release -- leak (tham số dispose dùng ở Bài 2):
using System.Diagnostics;
var mode = args.Length > 0 ? args[0] : "leak";
static string Rss() => $"{Process.GetCurrentProcess().WorkingSet64 / 1048576.0:F1} MB";
var bus = new EventBus();
Console.WriteLine($" bắt đầu handler={bus.HandlerCount,6} | heap {GC.GetTotalMemory(true) / 1048576.0,5:F1} MB | RSS {Rss()}");
for (int round = 1; round <= 4; round++)
{
for (int i = 0; i < 5_000; i++)
{
var handler = new LeadCreatedHandler(bus, new byte[2048]); // mô phỏng một request
handler.Subscribe();
if (mode == "dispose") handler.Dispose(); // cuối request
}
Console.WriteLine($" sau {round * 5_000,6} request handler={bus.HandlerCount,6} | heap {GC.GetTotalMemory(true) / 1048576.0,5:F1} MB | RSS {Rss()}");
}
public class EventBus // đóng vai Singleton
{
public event EventHandler<int>? LeadCreated;
public int HandlerCount => LeadCreated?.GetInvocationList().Length ?? 0;
public void Publish(int leadId) => LeadCreated?.Invoke(this, leadId);
}
public sealed class LeadCreatedHandler(EventBus bus, byte[] data) : IDisposable // đóng vai Scoped
{
private readonly byte[] _data = data; // đại diện cho dữ liệu mà handler giữ
public void Subscribe() => bus.LeadCreated += Handle;
public void Handle(object? sender, int leadId) { }
public void Dispose() => bus.LeadCreated -= Handle;
}Kết quả chạy trên .NET 10 với tham số leak:
bắt đầu handler= 0 | heap 0,3 MB | RSS 26,4 MB
sau 5000 request handler= 5000 | heap 10,6 MB | RSS 37,2 MB
sau 10000 request handler= 10000 | heap 21,0 MB | RSS 51,2 MB
sau 15000 request handler= 15000 | heap 31,4 MB | RSS 62,0 MB
sau 20000 request handler= 20000 | heap 41,8 MB | RSS 73,0 MBBa đ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ạy2. Heap tăng tuyến tính: 10,6 → 21,0 → 31,4 → 41,8 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 LeadCreatedHandler và delegate3. Và đây là bằng chứng quyết định — GC.GetTotalMemory(true) ép thu gom trước khi đo.
Con số 41,8 MB là con số SAU một lần thu gom đầy đủ.
-> 41,8 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:
EventBus (Singleton, sống suốt vòng đời ứng dụng)
-> event LeadCreated
-> danh sách delegate
-> mỗi delegate giữ Target = một LeadCreatedHandler
-> mỗi LeadCreatedHandler giữ byte[2048]
EventBus còn được tham chiếu -> mọi thứ trong chuỗi này không bao giờ được gom.Quy đổi ra hệ thống thật (phép tính từ số đo trên, không phải số đo production):
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
-> container giới hạn 512 MiB bị OOM kill sau khoảng hai ngày
-> khởi động lại -> bộ nhớ về mức đầu -> 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.11.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ữ. (Câu truy vấn trên viết bằng PromQL của Prometheus, học ở Module 17; tên metric tuỳ thư viện xuất số liệu bạn dùng.)
Bài 2 — Gỡ đăng ký đúng, và bẫy của lambda
Chạy lại chương trình Bài 1 với tham số dispose. Sau đó đăng ký bằng lambda, gỡ bằng lambda giống hệt, rồi đếm số handler còn lại. Cuối cùng viết một unit test bắt được lỗi quên gỡ.
Tiêu chí hoàn thành: bạn thấy -= với lambda không có tác dụng, biết cách viết đúng, và có một test xUnit chạy được.
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 — gỡ bằng Dispose. Với tham số dispose, mỗi "request" gọi handler.Dispose() ở cuối:
bắt đầu handler= 0 | heap 0,3 MB | RSS 25,7 MB
sau 5000 request handler= 0 | heap 0,3 MB | RSS 34,6 MB
sau 10000 request handler= 0 | heap 0,3 MB | RSS 41,8 MB
sau 15000 request handler= 0 | heap 0,3 MB | RSS 41,8 MB
sau 20000 request handler= 0 | heap 0,3 MB | RSS 41,8 MB0 handler, heap không đổi sau 20.000 request. So với Bài 1 (20.000 handler, 41,8 MB), khác biệt chỉ là một dòng -= được gọi đúng. RSS tăng ở mấy nghìn request đầu rồi đứng yên: đó là runtime giữ lại vùng nhớ đã xin từ hệ điều hành để dùng tiếp, không phải rò rỉ.
Lời giải — bẫy lambda:
var bus = new EventBus();
var handler = new LeadCreatedHandler(bus, new byte[16]);
bus.LeadCreated += (s, e) => handler.Handle(s, e);
Console.WriteLine($"sau khi đăng ký : {bus.HandlerCount} handler");
bus.LeadCreated -= (s, e) => handler.Handle(s, e); // lambda "giống hệt"
Console.WriteLine($"sau khi gỡ bằng lambda : {bus.HandlerCount} handler");
EventHandler<int> kept = (s, e) => handler.Handle(s, e);
bus.LeadCreated += kept;
Console.WriteLine($"đăng ký qua biến : {bus.HandlerCount} handler");
bus.LeadCreated -= kept;
Console.WriteLine($"gỡ qua biến : {bus.HandlerCount} handler");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ỡ đượcDò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ả.
Mỗi biểu thức lambda sinh ra một đối tượng delegate MỚI lúc chạy.
bus.LeadCreated += (s, e) => handler.Handle(s, e); -> delegate #1
bus.LeadCreated -= (s, e) => handler.Handle(s, 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ỡHai cách viết đúng:
1. Dùng nhóm phương thức thay vì lambda — đơn giản nhất. Đó chính là cách LeadCreatedHandler ở Bài 1 làm:
public void Subscribe() => bus.LeadCreated += Handle;
public void Dispose() => bus.LeadCreated -= Handle;Nhóm phương thức: Target = this, Method = Handle
-> hai lần viết `Handle` tạo ra hai delegate có cùng Target và Method
-> Delegate.Equals trả về true -> gỡ được2. Giữ tham chiếu tới delegate khi buộc phải dùng lambda:
private EventHandler<int>? _handler;
public void Subscribe()
{
_handler = (s, e) => Handle(s, e);
bus.LeadCreated += _handler;
}
public void Dispose()
{
if (_handler is not null) bus.LeadCreated -= _handler;
}Ba biến thể của cùng lỗi này:
1. Lambda bắt biến cục bộ trong vòng lặp — không có tham chiếu nào để gỡ.
foreach (var item in items)
{
bus.LeadCreated += (s, e) => Process(item, 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ào2. Đăng ký trong constructor, không có Dispose.
public LeadService(EventBus bus)
{
bus.LeadCreated += Handle; // đăng ký, nhưng lớp không cài IDisposable
}Trình biên dịch 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 Initialize() => bus.LeadCreated += Handle; // gọi hai lần -> hai handlerHệ 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ầnVà cách kiểm chứng trong test (project xUnit như bài 0.4, tham chiếu tới project chứa EventBus):
public class LeadCreatedHandlerTests
{
[Fact]
public void Dispose_RemovesEveryHandler()
{
var bus = new EventBus();
var before = bus.HandlerCount;
for (int i = 0; i < 100; i++)
{
var h = new LeadCreatedHandler(bus, []);
h.Subscribe();
h.Dispose();
}
Assert.Equal(before, bus.HandlerCount); // mỗi Dispose phải gỡ đúng handler nó đã đăng ký
}
}Test này chạy trong vài mili-giây. Nếu ai đó sửa Subscribe và Dispose sang dạng lambda viết lại, test đỏ ngay với thông báo:
Assert.Equal() Failure: Values differ
Expected: 0
Actual: 100Bài 3 — Đảo chiều
Viết lại ví dụ bằng IServiceScopeFactory và xác nhận bộ nhớ ổn định. Console app cần gói Microsoft.Extensions.DependencyInjection (dotnet add package Microsoft.Extensions.DependencyInjection); ASP.NET Core có sẵn gói này.
Tiêu chí hoàn thành: bộ nhớ không tăng sau 20.000 lần phát sự kiện, 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
public class EventBus
{
public event EventHandler<int>? LeadCreated; // giữ tham chiếu tới scoped
}
// Scoped
public sealed class LeadCreatedHandler(EventBus bus)
{
public void Subscribe() => bus.LeadCreated += Handle; // đưa chính mình cho singleton
public void Handle(object? sender, int leadId) { }
}Sau (singleton tự tạo scope):
using System.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddSingleton<ScopedEventBus>();
services.AddScoped<ILeadCreatedHandler, SendWelcomeEmailHandler>();
services.AddScoped<ILeadCreatedHandler, WriteAuditLogHandler>();
using var provider = services.BuildServiceProvider();
var bus = provider.GetRequiredService<ScopedEventBus>();
static string Rss() => $"{Process.GetCurrentProcess().WorkingSet64 / 1048576.0:F1} MB";
Console.WriteLine($" bắt đầu | heap {GC.GetTotalMemory(true) / 1048576.0,5:F1} MB | RSS {Rss()}");
for (int round = 1; round <= 4; round++)
{
for (int i = 0; i < 5_000; i++) bus.Publish(i);
Console.WriteLine($" sau {round * 5_000,6} lần phát | heap {GC.GetTotalMemory(true) / 1048576.0,5:F1} MB | RSS {Rss()}");
}
public interface ILeadCreatedHandler { void Handle(int leadId); }
public sealed class SendWelcomeEmailHandler : ILeadCreatedHandler
{
private readonly byte[] _buffer = new byte[2048]; // cùng lượng dữ liệu như Bài 1
public void Handle(int leadId) { }
}
public sealed class WriteAuditLogHandler : ILeadCreatedHandler
{
public void Handle(int leadId) { }
}
// Singleton — KHÔNG giữ tham chiếu tới bất kỳ đối tượng scoped nào
public class ScopedEventBus(IServiceScopeFactory scopeFactory)
{
public void Publish(int leadId)
{
using var scope = scopeFactory.CreateScope();
foreach (var handler in scope.ServiceProvider.GetServices<ILeadCreatedHandler>())
handler.Handle(leadId);
} // scope bị huỷ ở đây -> mọi handler scoped được giải phóng
}Kết quả chạy trên .NET 10, chế độ Release:
bắt đầu | heap 0,3 MB | RSS 26,6 MB
sau 5000 lần phát | heap 0,3 MB | RSS 36,6 MB
sau 10000 lần phát | heap 0,3 MB | RSS 41,2 MB
sau 15000 lần phát | heap 0,3 MB | RSS 40,2 MB
sau 20000 lần phát | heap 0,3 MB | RSS 40,2 MBHeap đứng yên ở 0,3 MB dù mỗi lần phát tạo hai handler mới. So với Bài 1 (41,8 MB và còn tăng tiếp), không có gì được giữ lại sau mỗi lần phát — và không ai phải nhớ gọi -=.
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 bất đồng bộ đúng cách. Interface tự định nghĩa nên đổi được thành Task HandleAsync(int leadId, CancellationToken ct) và await từng handler (Module 6). Với event .NET, EventHandler trả về void:
// async void trong event handler:
public async void Handle(object? sender, int leadId) // ngoại lệ ở đây làm sập tiến trình
{
await _email.SendAsync(leadId);
}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ụng2. Thứ tự và cách xử lý lỗi kiểm soát được.
foreach (var handler in scope.ServiceProvider.GetServices<ILeadCreatedHandler>())
{
try { handler.Handle(leadId); }
catch (Exception ex)
{
// ứng dụng thật ghi log (bài 8.7)
Console.WriteLine($"Handler {handler.GetType().Name} lỗi: {ex.Message}, tiếp tục các handler còn lại");
}
}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. Mỗi handler là một lớp thường, test bằng cách gọi thẳng Handle; không phải dựng event và đăng ký.
Và khi nào event .NET vẫn đúng (mục 5.11.5):
| Tình huống | Dùng gì |
|---|---|
| Publisher và subscriber có cùng vòng đời | Event .NET — an toàn |
| Cả hai đều là singleton | Event .NET |
| Trong một đối tượng, thông báo nội bộ | Event .NET |
| Singleton phát, scoped nghe | IServiceScopeFactory |
Cần async, cần xử lý lỗi từng handler | IServiceScopeFactory hoặc message bus |
| Cần xử lý ở tiến trình khác, hoặc cần bền vững | Message 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ấtNố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 khi viết bằng lambda, bài 3 cho thấy cách sửa bền nhất là đổi chiều phụ thuộc chứ không phải cẩn thận hơn.
Tự kiểm tra
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, handler đầu tiên thuộc về một request đã kết thúc và dùng một DbContext đã bị huỷ, nên nó ném ObjectDisposedException. Một handler ném ngoại lệ thì mọi handler sau nó không chạy, kể cả handler hợp lệ của request hiện tại, nên tính năng hỏng hoàn toàn.
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:
+=khiến bên phát giữ sống bên nghe. Đó là toàn bộ cơ chế của rò rỉ này.- Lambda không gỡ được bằng
-=— phải giữ tham chiếu tới chính delegate đó, hoặc dùng nhóm phương thức. - Trong ASP.NET Core, thường có lựa chọn tốt hơn event .NET giữa các service: để singleton tạo scope và gọi handler, thay vì để handler đăng ký vào singleton.
Tham khảo
- Events (C# programming guide)
- Dependency injection in .NET
- Dependency injection guidelines — singleton dùng service scoped
- dotnet-gcdump
- dotnet-counters
Điều hướng
- Bài trước: 5.10 — Case study: refactor hàm tính giảm giá
- Bài tiếp theo: 5.12 — Ôn tập và đánh giá Module 5
- Về module: Trang mục lục