Skip to content

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ả 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ớ 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ỉ ​

csharp
// 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ỉ ​

text
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ể):

text
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  -> OOM

Và 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:

bash
dotnet-counters monitor -p <pid> --counters System.Runtime

Một màn hình kiểu này là dấu hiệu đáng ngờ (số liệu minh hoạ):

text
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ớ:

bash
dotnet-gcdump collect -p <pid> -o before.gcdump
# ... để chạy thêm 30 phút ...
dotnet-gcdump collect -p <pid> -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.11.4 — Ba cách sửa ​

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

csharp
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:

csharp
// 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:

csharp
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:

csharp
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ố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.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):

csharp
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:

text
  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 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ỡ.

text
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,6 → 21,0 → 31,4 → 41,8 MB.

text
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à delegate

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

text
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:

text
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):

text
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:

promql
# Mức ĐÁY của heap có đi lên theo ngày không?
min_over_time(dotnet_gc_heap_size_bytes[6h])
text
Ứ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:

text
  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 MB

0 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:

csharp
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");
text
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ả.

text
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:

csharp
public void Subscribe() => bus.LeadCreated += Handle;
public void Dispose()   => bus.LeadCreated -= Handle;
text
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ỡ được

2. Giữ tham chiếu tới delegate khi buộc phải dùng lambda:

csharp
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ỡ.

csharp
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ào

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

csharp
public LeadService(EventBus bus)
{
    bus.LeadCreated += Handle;        // đăng ký, nhưng lớp không cài IDisposable
}
text
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.

csharp
public void Initialize() => bus.LeadCreated += Handle;   // gọi hai lần -> hai handler
text
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 (project xUnit như bài 0.4, tham chiếu tới project chứa EventBus):

csharp
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:

text
Assert.Equal() Failure: Values differ
Expected: 0
Actual:   100

Bà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):

csharp
// 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):

csharp
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:

text
  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 MB

Heap đứ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 -=.

text
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:

text
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:

csharp
// 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);
}
text
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

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

csharp
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");
    }
}
text
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ố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
text
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 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:

  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 đó, hoặc dùng nhóm phương thức.
  3. 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 ​

Điều hướng ​

Phát hành theo giấy phép MIT.