CodeGym /Các khóa học /C# SELF /Tối ưu hóa lập trình theo sự kiện

Tối ưu hóa lập trình theo sự kiện

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

1. Giới thiệu

Trong hầu hết ứng dụng tiêu chuẩn, các sự kiện chạy nhanh và gần như "miễn phí" — CLR (Common Language Runtime) được tối ưu tốt cho việc này. Tuy nhiên, khi ứng dụng lớn lên, có nhiều events, chuỗi subscriber dài, và yêu cầu hiệu năng tăng, bạn sẽ nhận ra: ngay cả một cấu trúc "đơn giản" như event cũng có thể trở thành cổ chai. Đặc biệt thấy rõ trong hệ thống có nhiều cập nhật real-time, giao diện người dùng (UI), hoặc khi xử lý hàng trăm ngàn thông báo từ sensor trong ứng dụng IoT.

Trong bài này chúng ta sẽ xem:

  • Cách event và delegates ảnh hưởng đến hiệu năng.
  • Những điểm nghẽn phổ biến.
  • Cách viết code event nhanh và tránh các vấn đề gây giảm hiệu năng.

Cấu trúc nội bộ của sự kiện trong .NET

Như đã nhắc, sự kiện là một lớp bọc quanh delegate. Delegate là một đối tượng đặc biệt chứa danh sách các phương thức (invocation list) sẽ được gọi khi invoke. Mỗi lần gọi event, CLR sẽ duyệt danh sách này và gọi đồng bộ tất cả phương thức. (Bất đồng bộ chỉ xuất hiện nếu bạn tự thêm code bất đồng bộ vào.)

Sơ đồ minh họa:


[Publisher] ----- (event) ---> [Delegate (Invocation List)] --> [Handler 1]
                                                           --> [Handler 2]
                                                           --> [Handler N]

2. Chi phí của delegates và events: phân tích từng phần

Chi phí lưu trữ

  • Mỗi delegate là một đối tượng đầy đủ.
  • Mỗi handler (phương thức người đăng ký) tạo thêm một delegate nữa.
  • Càng nhiều subscriber — càng nhiều đối tượng, càng tốn bộ nhớ.

Trong các trường hợp đơn giản, leak hoặc overhead gần như không đáng kể. Nhưng nếu có hàng nghìn handler — thì phải cân nhắc!

Chi phí gọi

  • Gọi event = duyệt invocation list.
  • Mỗi phương thức được gọi đồng bộ (lần lượt).
  • Nếu handler làm việc nặng hoặc ngủ lâu, điều đó làm chậm các handler khác.

Ví dụ: triển khai đơn giản


public class Counter
{
    public event EventHandler Counted;

    public void Increment()
    {
        // ... bỏ qua logic đếm
        // Các subscriber được gọi đồng bộ!
        Counted?.Invoke(this, EventArgs.Empty);
    }
}

Nếu có 1000 subscriber, mỗi handler gọi Thread.Sleep(10), thì việc gọi event có thể mất khoảng 10 giây...

3. Subscriber "nặng" — kẻ thù của hiệu năng

Tại sao handler nên "nhẹ"?

  • Event được gọi đồng bộ, thread gọi sẽ đợi xong tất cả handler.
  • Một handler chậm sẽ làm chậm toàn bộ chuỗi.
  • Nếu handler có thể ném ngoại lệ — các handler sau có thể không được gọi (nếu bạn không bọc bằng try/catch).

Minh họa


class Program
{
    static void Main()
    {
        var publisher = new Counter();
        // Nhanh
        publisher.Counted += (s, e) => Console.WriteLine("First");
        // Chậm
        publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
        // Thêm một handler
        publisher.Counted += (s, e) => Console.WriteLine("Last");

        // Đo thời gian
        var watch = System.Diagnostics.Stopwatch.StartNew();
        publisher.Increment();
        watch.Stop();
        Console.WriteLine($"Tất cả handler đã được gọi trong {watch.ElapsedMilliseconds} ms.");
    }
}

Thử chạy — bạn sẽ thấy pause rõ rệt. Handler đầu gần như ngay lập tức, handler thứ hai gây "delay", rồi mới đến handler thứ ba.

Kết luận thực tế

  • Đừng bỏ logic nghiệp vụ nặng trực tiếp vào handler của event!
  • Nhất thiết tách công việc nặng ra thread khác, task hoặc handler bất đồng bộ.

4. Ngoại lệ trong handler: bẫy cho hiệu năng

Nếu một trong các subscriber ném exception, việc xử lý event sẽ bị gián đoạn — các handler sau có thể không được gọi!


publisher.Counted += (s, e) => throw new Exception("Lỗi!");
publisher.Counted += (s, e) => Console.WriteLine("Bạn sẽ không thấy dòng này.");

Để tránh điều đó và không để một "quả táo hỏng" làm treo cả chuỗi, hãy duyệt thủ công và bảo vệ từng handler bằng try/catch.

Phiên bản nâng cao khi gọi event


protected virtual void OnCounted()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            try
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            }
            catch (Exception ex)
            {
                Console.WriteLine($"Lỗi trong handler: {ex.Message}");
                // Ghi log, hoặc xử lý lỗi đặc biệt
            }
        }
    }
}

Điều này làm cho event "bền" hơn: dù một subscriber bị lỗi — các subscriber khác vẫn được gọi.

5. Sự kiện bất đồng bộ (fire-and-forget)

Nếu event có thể chậm — đôi khi ta muốn chạy handler trên thread hoặc task riêng để không chặn luồng chính.

Phương án 1: chạy mỗi handler trong một task riêng


protected virtual void OnCountedAsync()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            // Fire-and-forget: không chờ hoàn thành!
            System.Threading.Tasks.Task.Run(() =>
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            });
        }
    }
}

Nhưng! Cẩn thận với song song

  • Nếu subscribers dùng chung resource — có thể xảy ra race conditions.
  • Exception trong fire-and-forget khó bắt.
  • Nếu cần đợi tất cả subscriber hoàn thành — phải gom các task và dùng Task.WhenAll.

Với UI (WinForms/WPF) — không bao giờ gọi handler ngoài UI-thread, nếu không sẽ nhận InvalidOperationException.

Tóm lại — event bất đồng bộ cần thiết kế cẩn trọng!

6. Tối ưu lưu trữ và gọi event

Sự kiện "rỗng": tiết kiệm bộ nhớ

Nếu lớp của bạn có nhiều event, mà phần lớn hiếm khi dùng (ví dụ nhiều event trong component UI), có mẹo: dùng EventHandlerList.

Cách hoạt động

Controls trong .NET (ví dụ WinForms) không lưu một delegate riêng cho mỗi event, mà chứa tất cả event trong một cấu trúc (EventHandlerList) — chỉ tạo khi ít nhất có một handler đăng ký.

Ví dụ tạo EventHandlerList thủ công

using System.ComponentModel; // EventHandlerList nằm ở đây!

class MyControl
{
    private readonly EventHandlerList _events = new EventHandlerList();

    private static readonly object EventMyEvent = new object();

    public event EventHandler MyEvent
    {
        add    { _events.AddHandler(EventMyEvent, value); }
        remove { _events.RemoveHandler(EventMyEvent, value); }
    }

    protected virtual void OnMyEvent()
    {
        var handler = (EventHandler)_events[EventMyEvent];
        handler?.Invoke(this, EventArgs.Empty);
    }
}

Tại sao cần: tiết kiệm bộ nhớ, không tạo delegate thừa cho hàng trăm event "rỗng".

7. An toàn luồng: tránh race và deadlock

Events trong .NET bản thân chúng KHÔNG an toàn luồng! Khi một thread đang subscribe hoặc unsubscribe, thread khác có thể đang raise event. Điều này dẫn đến delegate có thể trở thành null ngay trước khi gọi, gây NullReferenceException.

Best practices

  • Dùng toán tử ?. (Counted?.Invoke(...)) — bảo vệ khỏi null.
  • Với trường hợp phức tạp — khoá truy cập event bằng lock.

Ví dụ


private readonly object _lockObj = new object();
private EventHandler _myEvent;

public event EventHandler MyEvent
{
    add { lock (_lockObj) { _myEvent += value; } }
    remove { lock (_lockObj) { _myEvent -= value; } }
}

protected virtual void OnMyEvent()
{
    EventHandler handler;
    lock (_lockObj)
    {
        handler = _myEvent;
    }
    handler?.Invoke(this, EventArgs.Empty);
}

Khi nào cần độ phức tạp này?

  • Trong ứng dụng đa luồng (ví dụ server, parser đa luồng...).
  • Nếu subscribe/unsubscribe từ nhiều thread khác nhau, và raise event từ thread khác.

8. Accessor add/remove để kiểm soát và tối ưu

Trong trường hợp đặc biệt (ví dụ cần log mọi đăng ký hoặc giới hạn số subscriber) có thể tự implement event qua accessors:


private EventHandler _event;
public event EventHandler MyEvent
{
    add
    {
        if (_event == null || _event.GetInvocationList().Length < 10)
            _event += value;
        else
            Console.WriteLine("Giới hạn: không thể thêm hơn 10 subscriber.");
    }
    remove { _event -= value; }
}

Điều này cho phép:

  • Chèn logic tuỳ chỉnh.
  • Làm event an toàn luồng.
  • Kiểm tra giới hạn hoặc log subscribe/unsubscribe.

9. Những chi tiết hữu ích

Lambda, closure và hiệu năng

Lambda tiện để subscribe "vội":


var button = new Button();
button.Click += (s, e) => Console.WriteLine("Button clicked");

Nhưng nếu lambda capture biến — sẽ tạo closure, có thể tăng bộ nhớ tiêu thụ. Với hầu hết UI-case không đáng lo, nhưng trong code low-level nên để ý số lượng closure và vòng đời của đối tượng bị capture.

Thú vị:
Nếu thêm hai lambda giống hệt nhau liên tiếp, đó vẫn là hai delegate khác nhau, và method sẽ chạy hai lần.

Profiling event và delegate

Khi app lớn, cần profile event như mọi phần khác của code.

Làm sao đo tốc độ của event?

  • Dùng Stopwatch để đo thời gian từ gọi event đến khi xử lý xong.
  • Dùng công cụ profiling bộ nhớ (ví dụ dotMemory, công cụ trong Visual Studio) để tìm các subscriber không được unsubscribe và còn treo trong memory.
  • Để tìm "zombie-subscriber" tìm các invocation list dài trên các object sống lâu.

Bảng "Tối ưu & bẫy"

Vấn đề/Tình huống Giải pháp
Nhiều event sống lâu (và vô dụng) Dùng EventHandlerList
Subscriber làm chậm mọi thứ Chuyển logic nặng sang task/thread riêng
An toàn luồng Sao chép delegate trước khi gọi, lock khi thêm/bớt
Exception trong handler Bắt try/catch quanh từng handler
Rò rỉ bộ nhớ do "zombie-subscriber" Luôn unsubscribe, implement IDisposable, profiling

Sơ đồ: "Vòng đời của event tối ưu"


+----------------+       +------------------+       +---------------------+
| Subscriber tạo |  -->  | Đăng ký (+=)     |  -->  | Vào Invocation List |
+----------------+       +------------------+       +---------------------+
                                |                                ^
                                |                                |
                   Hủy đăng ký (-=) |                     Ngoại lệ  |
                                v                                |
+----------------+       +--------------------+      +----------------------+
| Subscriber Dispose |  -->  | Bị xoá khỏi list  |  --> | Không còn là zombie  |
+----------------+       +--------------------+      +----------------------+

10. Giải thích "quản lý sự kiện" khi phỏng vấn

Nếu gặp câu hỏi "Điểm yếu của event trong C# là gì?" hoặc "Khi nào cần tối ưu event?", bạn nên biết:

  • Event tốt cho loose coupling, nhưng không hiệu quả khi có lượng đăng ký lớn và handler nặng.
  • Không an toàn luồng theo mặc định.
  • Cần unsubscribe thủ công (nếu không sẽ rò rỉ bộ nhớ).
  • Với producer/consumer lớn — dùng EventHandlerList và accessor tuỳ chỉnh add/remove.
  • Không thường xuyên cần kiểm soát sâu — phần lớn trường hợp pattern mặc định là đủ.

Trong bài tiếp theo mình sẽ đi vào các scenario nâng cao và ví dụ thực tế về lập trình event-delegate, để bạn thấy các tối ưu này hoạt động như thế nào trong bài toán thực.

Những hiểu nhầm và anti-pattern thường gặp

  • Cho rằng event trong .NET luôn nhanh — nó nhanh cho đến khi có nhiều subscriber hoặc handler nặng.
  • Hy vọng GC sẽ "dọn hết" — không, nếu không unsubscribe, object sẽ sống mãi!
  • Dùng event để liên kết xa giữa các tầng nghiệp vụ — tốt hơn nên dùng pattern rõ ràng (ví dụ Mediator).
Bình luận
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION