CodeGym /Các khóa học /C# SELF /Người đăng ký và gọi sự kiện an toàn

Người đăng ký và gọi sự kiện an toàn

C# SELF
Mức độ , Bài học
Có sẵn

1. Giới thiệu

Khi chúng ta viết

worker.WorkCompleted += listener.OnWorkCompleted;

thì thực chất ta đang thêm một con trỏ tới phương thức vào "invocation list" (multicast delegate) của sự kiện. "Danh sách" này bên trong event — chỉ đơn giản là một chuỗi các phương thức sẽ được gọi khi event được phát. Trong C# sự kiện được xây dựng trên delegate, cho phép nhiều subscriber.

Hãy tưởng tượng như gửi bản tin: bạn có một danh sách người nhận (email). Khi bạn gửi bản tin (gọi sự kiện), tất cả người nhận đều nhận được. Nếu ai đó hủy đăng ký, họ bị loại khỏi danh sách và sẽ không nhận nữa.

Cách thêm hoặc gỡ subscriber

Phép đăng ký (+=) và hủy đăng ký (-=) thao tác trên delegate bên trong event. Đây là ví dụ với một lambda mà bạn có thể đăng ký và hủy:

EventHandler<WorkCompletedEventArgs> handler = (sender, e) =>
{
    Console.WriteLine($"[Lambda] Công việc hoàn thành: {e.Message}");
};

worker.WorkCompleted += handler; // Đăng ký
worker.WorkCompleted -= handler; // Hủy đăng ký

Với các phương thức bình thường, việc hủy đăng ký cũng tương tự:

worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted -= listener.OnWorkCompleted;

Lưu ý: nếu bạn đăng ký cùng một phương thức nhiều lần, nó sẽ được gọi tương ứng số lần đó và để hủy bạn phải gọi -= cùng số lần.

2. Tại sao phải quản lý thủ công?

Tại sao cần quản lý các subscriber?

Trong ứng dụng thực tế, đặc biệt là ứng dụng sống lâu (ví dụ desktop hoặc server), quản lý subscription không đúng có thể dẫn tới leak bộ nhớ. Nếu đối tượng subscriber không còn cần thiết nhưng vẫn "treo" trong danh sách subscriber, nó sẽ không bị garbage collector thu dọn — vì vẫn còn tham chiếu từ delegate của event.

Minh họa

Hành động Kết quả đối với subscriber
+= (đăng ký) Được thêm vào danh sách
-= (hủy đăng ký) Bị xóa khỏi danh sách
Đối tượng subscriber bị xóa Nếu KHÔNG hủy đăng ký! — KHÔNG bị xóa, vì vẫn còn tham chiếu trong sự kiện
Đối tượng subscriber bị xóa Nếu ĐÃ HỦY ĐĂNG KÝ — sẽ bị xóa bình thường

Làm sao biết ai đang đăng ký sự kiện?

Sự kiện đóng gói danh sách subscriber, nên từ ngoài lớp publisher bạn không thể đọc trực tiếp danh sách đó — chỉ có thể thêm (+=) hoặc bớt (-=) handler.

Tuy nhiên bên trong lớp nơi event được khai báo trên delegate (ví dụ EventHandler) bạn có thể lấy danh sách subscriber hiện tại bằng phương thức GetInvocationList():

// Trong lớp phát hành
if (WorkCompleted != null)
{
    foreach (Delegate subscriber in WorkCompleted.GetInvocationList())
    {
        Console.WriteLine($"Bộ xử lý: {subscriber.Method.Name}, Đối tượng: {subscriber.Target}");
    }
}

Cách này ít khi cần trong phát triển hàng ngày, nhưng hữu ích cho debug hoặc khi hiện thực chức năng hủy đăng ký hàng loạt.

3. Gọi sự kiện an toàn: những "cạm bẫy" và cách né

Chuyện gì có thể xảy ra khi gọi sự kiện?

Mọi thứ có vẻ đơn giản: bạn gọi

WorkCompleted?.Invoke(this, args);

và đa số thời gian mọi thứ ổn... Nhưng có vài điểm cần lưu ý. Đây là chúng:

1. Nguy cơ đa luồng

Trong ứng dụng đa luồng có thể xảy ra trường hợp giữa lúc kiểm tra event khác null và khi gọi handler, một thread khác đã thay đổi subscription. Ví dụ:

1) Thread A kiểm tra: WorkCompleted != null.
2) Cùng lúc đó thread B hủy đăng ký (-=), và danh sách handler trở nên rỗng.
3) Thread A cố gọi WorkCompleted.Invoke(...) — xảy ra NullReferenceException, vì lúc này không còn handler.

Đây là race condition kinh điển khi làm việc với sự kiện.

2. Ngoại lệ bất ngờ trong handler

Nếu một trong các subscriber ném ngoại lệ khi xử lý sự kiện, việc gọi các handler còn lại sẽ bị gián đoạn. Tức là sự kiện "bị dập" ngay khi gặp ngoại lệ, và các subscriber khác sẽ không được thông báo. Để tránh điều này, nên bọc mỗi lời gọi handler trong try-catch nếu bạn muốn mọi người đều nhận được tín hiệu.

3. Rò rỉ tham chiếu (context)

Handler thường là phương thức instance, nắm giữ tham chiếu tới đối tượng subscriber (this). Nếu subscriber quên hủy đăng ký khỏi publisher, tham chiếu tới nó vẫn nằm trong list delegate của publisher. Kết quả là garbage collector không thể giải phóng đối tượng đó — dẫn đến leak bộ nhớ.

Làm sao gọi sự kiện an toàn?

1) Sao chép delegate vào biến cục bộ

Gọi qua biến cục bộ đảm bảo trong lúc gọi danh sách subscriber không bị thay đổi:

// Cách cũ nhưng hiệu quả
var handler = WorkCompleted;
if (handler != null)
{
    handler(this, args);
}

Hoặc cách hiện đại hơn, với toán tử «?.» (null-conditional):

WorkCompleted?.Invoke(this, args);

Trong hầu hết trường hợp điều này đủ vì compiler C# hiểu và thực hiện sao chép nội bộ (xem tài liệu chính thức).

2) Bảo vệ khỏi ngoại lệ của handler

Nếu quan trọng rằng tất cả handler đều được gọi (dù một handler bị lỗi), hãy duyệt thủ công:

var handler = WorkCompleted;
if (handler != null)
{
    foreach (EventHandler<WorkCompletedEventArgs> subscriber in handler.GetInvocationList())
    {
        try
        {
            subscriber(this, args);
        }
        catch (Exception ex)
        {
            // Ghi log, nhưng không để toàn bộ sự kiện bị "sập"
            Console.WriteLine($"Lỗi trong handler: {ex.Message}");
        }
    }
}

Cách này ít cần cho UI bình thường, nhưng hữu dụng trong thư viện, logger và hệ thống phức tạp.

3) Ngăn rò rỉ bộ nhớ

Nếu subscriber "sống" ngắn hơn publisher (ví dụ cửa sổ đăng ký vào event của ứng dụng), nó phải hủy đăng ký:

worker.WorkCompleted -= listener.OnWorkCompleted;

Nếu không garbage collector sẽ không giải phóng listener, ngay cả khi không còn tham chiếu "rõ ràng" nào tới nó.

4. Ví dụ thực hành: trình quản lý đăng ký/hủy đăng ký hàng loạt

Giờ mở rộng ứng dụng học của chúng ta. Giả sử có vài listener — và ta muốn đăng ký/hủy họ linh hoạt trong quá trình chạy.

public class WorkListener
{
    private readonly string _name;

    public WorkListener(string name)
    {
        _name = name;
    }

    public void OnWorkCompleted(object sender, WorkCompletedEventArgs e)
    {
        Console.WriteLine($"Listener {_name}: {e.Message}");
    }
}

Trong chương trình chính:

var worker = new Worker();

var listeners = new List<WorkListener>
{
    new WorkListener("Ivan"),
    new WorkListener("Mariya"),
    new WorkListener("Denis")
};

// Đăng ký tất cả listeners
foreach (var listener in listeners)
    worker.WorkCompleted += listener.OnWorkCompleted;

// Gọi sự kiện
worker.DoWork();

// Hủy đăng ký hàng loạt
foreach (var listener in listeners)
    worker.WorkCompleted -= listener.OnWorkCompleted;

// Kiểm tra rằng không ai phản hồi nữa
worker.DoWork();

Trên console sau lần chạy đầu sẽ có 3 thông báo, sau lần chạy thứ hai — không có thông báo nào.

5. Mẹo làm việc an toàn với sự kiện

  • Hủy đăng ký kịp thời nếu vòng đời của subscriber ngắn hơn vòng đời của publisher.
  • Nếu bạn thực hiện pattern "publisher sống lâu — subscriber tạm thời", luôn hủy đăng ký, ví dụ trong Dispose(), khi đóng cửa sổ hoặc khi đối tượng kết thúc vòng đời rõ ràng.
  • Cho sự kiện một lần (one-shot) có thể dùng anonymous lambda và ngay trong đó hủy đăng ký:
EventHandler<WorkCompletedEventArgs> handler = null;
handler = (s, e) => 
{
    Console.WriteLine("Sự kiện đã được xử lý một lần!");
    worker.WorkCompleted -= handler;
};
worker.WorkCompleted += handler;
  • Đừng lưu tham chiếu tới subscriber hoặc handler chỉ để "kiểm tra ai đang đăng ký" — không cần thiết trong business logic bình thường. Chỉ làm vậy cho mục đích debug.

6. Lỗi thường gặp và cách tránh

Lỗi #1: quên hủy đăng ký khỏi sự kiện — leak bộ nhớ.
Nếu subscriber không hủy đăng ký, đặc biệt trong ứng dụng lớn có nhiều event và subscriber, đối tượng có thể ở lại trong bộ nhớ lâu hơn cần thiết. Lỗi này thường không biểu hiện ngay lập tức nhưng dẫn tới tăng mức tiêu thụ bộ nhớ và hiệu năng kém đi.

Lỗi #2: gọi sự kiện mà không kiểm tra null.
Nếu event không có subscriber và cố gọi trực tiếp, sẽ xảy ra NullReferenceException. Trong các phiên bản C# mới có toán tử gọi an toàn ?., nhưng nếu bạn đang làm việc với mã cũ hoặc duyệt handler thủ công, nhớ kiểm tra event khác null.

Lỗi #3: ngoại lệ trong một handler làm gián đoạn việc gọi các handler còn lại.
Nếu một handler ném ngoại lệ thì các handler tiếp theo sẽ không được gọi. Nếu quan trọng rằng mọi subscriber đều nhận thông báo, duyệt handler trong vòng lặp và bọc mỗi lần gọi bằng try/catch.

2
Nhiệm vụ
C# SELF, mức độ, bài học
Đã khóa
Gọi sự kiện an toàn với xử lý ngoại lệ
Gọi sự kiện an toàn với xử lý ngoại lệ
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION