JavaRush /Курси /C# SELF /Оптимізація подієвого програмування

Оптимізація подієвого програмування

C# SELF
Рівень 54 , Лекція 2
Відкрита

1. Вступ

У більшості типових застосунків події працюють швидко й майже «безкоштовно» — CLR (Common Language Runtime) чудово оптимізована для їх обробки. Та коли застосунок зростає: подій більшає, ланцюжки підписників подовжуються, а вимоги до продуктивності підвищуються — раптом зʼясовується, що навіть така «проста» конструкція, як події, може створити вузьке місце. Особливо помітно це в системах із великою кількістю оновлень у реальному часі, в інтерфейсах користувача (UI) або під час обробки сотень тисяч сповіщень від сенсорів у IoT-застосунках.

У цій лекції розглянемо:

  • Як події й делегати впливають на продуктивність.
  • Які є вузькі місця.
  • Як писати швидкий подієвий код і уникати проблем, що шкодять продуктивності.

Внутрішній устрій подій у .NET

Як уже згадували, подія — це обгортка над делегатом. Делегат — це спеціальний обʼєкт, що містить список методів (список викликів, invocation list), які викликаються під час виклику. Під час кожного виклику події CLR проходить цим списком і синхронно викликає всі методи. Асинхронність зʼявляється лише тоді, коли ви вручну додаєте туди асинхронний код.

Наглядова схема:


[Видавець] ----- (event) ---> [Delegate (Invocation List)] --> [Обробник 1]
                                                             --> [Обробник 2]
                                                             --> [Обробник N]

2. Вартість делегатів і подій: розбираємо на атоми

Вартість зберігання

  • Кожен делегат — це повноцінний обʼєкт.
  • Кожен обробник (метод-підписник) створює ще один делегат.
  • Чим більше підписників — тим більше обʼєктів і тим більше памʼяті.

У простих випадках витоків або накладних витрат майже немає. Але якщо обробників тисячі — уже є про що замислитися!

Вартість виклику

  • Виклик події — це проходження списку викликів (invocation list).
  • Кожен метод викликається синхронно (один за одним).
  • Якщо обробник робить важку роботу або довго «спить», це гальмує всіх інших.

Приклад: проста реалізація


public class Counter
{
    public event EventHandler Counted;

    public void Increment()
    {
        // ... логіку підрахунку пропустимо
        // Підписники викликаються синхронно!
        Counted?.Invoke(this, EventArgs.Empty);
    }
}

Якщо у вас 1000 підписників, чиї обробники роблять Thread.Sleep(10), виклик події триватиме близько 10 секунд…

3. «Важкі» підписники — вороги продуктивності

Чому обробники мають бути «легкими»?

  • Події викликаються синхронно, потік-ініціатор чекає завершення усіх обробників.
  • Один повільний обробник гальмує весь ланцюжок.
  • Якщо обробник може «впасти» з винятком — інші можуть і не викликатися (якщо ви не захищаєте виклик try/catch).

Демонстрація


class Program
{
    static void Main()
    {
        var publisher = new Counter();
        // Швидкий
        publisher.Counted += (s, e) => Console.WriteLine("Перший");
        // Повільний
        publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
        // Ще один
        publisher.Counted += (s, e) => Console.WriteLine("Останній");

        // Замір часу
        var watch = System.Diagnostics.Stopwatch.StartNew();
        publisher.Increment();
        watch.Stop();
        Console.WriteLine($"Усі обробники викликалися за {watch.ElapsedMilliseconds} мс.");
    }
}

Спробуйте запустити — помітите паузу. Перший обробник — майже миттєво, другий — «затримка», і тільки потім третій.

Практичний висновок

  • Не вбудовуйте важку бізнес-логіку безпосередньо в обробники подій!
  • Краще винести таку роботу в окремий потік, Task або асинхронний обробник.

4. Винятки в обробниках: пастки для продуктивності

Якщо один із підписників кидає виняток, обробка події переривається — наступні обробники можуть не викликатися!


publisher.Counted += (s, e) => throw new Exception("Помилка!");
publisher.Counted += (s, e) => Console.WriteLine("Ви не побачите цей рядок.");

Щоб цього уникнути й не сповільнювати роботу через одне «погане яблуко», використовуйте ручне перебирання із захистом кожного обробника.

Покращений варіант виклику подій


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($"Помилка в обробнику: {ex.Message}");
                // Логування або спеціальна обробка помилки
            }
        }
    }
}

Це робить подію стійкішою: навіть якщо один підписник упав — решта працюють.

5. Асинхронні (fire-and-forget) події

Якщо подія може бути повільною, інколи варто запускати обробники в окремих потоках або через Task, щоб не гальмувати основний потік.

Варіант 1: запуск кожного обробника в окремому Task


protected virtual void OnCountedAsync()
{
    var handlers = Counted?.GetInvocationList();
    if (handlers != null)
    {
        foreach (var handler in handlers)
        {
            // Fire-and-forget: не чекаємо завершення!
            System.Threading.Tasks.Task.Run(() =>
            {
                ((EventHandler)handler)(this, EventArgs.Empty);
            });
        }
    }
}

Але! Обережно з паралелізмом

  • Якщо підписники використовують спільний ресурс — можливі гонки (race conditions).
  • Винятки у fire-and-forget обробниках складно перехоплювати.
  • Якщо важливо дочекатися завершення всіх підписників — потрібно зібрати Task і зробити Task.WhenAll.

Для UI (WinForms/WPF) — ніколи не викликайте обробники поза UI-потоком, інакше отримаєте InvalidOperationException.

Загалом — асинхронні події вимагають обережного й продуманого дизайну!

6. Оптимізація зберігання та виклику подій

«Порожні» події: заощаджуємо памʼять

Якщо у вашому класі багато подій, більшість із яких рідко використовується (наприклад, багато подій у UI-компоненті), є прийом: EventHandlerList.

Як це працює

.NET-контроли (наприклад, у WinForms) не тримають окремий делегат для кожної події, а складають усі події в одну структуру (EventHandlerList) — лише коли підписаний хоча б один обробник.

Приклад ручного створення EventHandlerList

using System.ComponentModel; // EventHandlerList живе тут!

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);
    }
}

Навіщо це потрібно: ви заощаджуєте памʼять, не створюючи зайві делегати для сотень «порожніх» подій.

7. Потокобезпечність: як уникати гонок і блокувань

Події в .NET самі по собі не є потокобезпечними. Поки підписник підписується або відписується, інший потік може ініціювати подію. Це може призвести до того, що делегат стане null просто перед викликом, що спричинить NullReferenceException.

Найкращі практики

  • Використовуйте оператор ?. (Counted?.Invoke(…)) — захищає від null.
  • Для складних випадків — блокуйте доступ до події через lock.

Приклад


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);
}

Коли потрібна така складність?

  • У багатопоточних застосунках (наприклад, сервери, багатопоточні парсери тощо).
  • Якщо підписка/відписка йде з різних потоків, а виклик події — ще з одного.

8. Аксесори add/remove для контролю та оптимізації

В окремих випадках (наприклад, якщо треба журналювати всі підписки або обмежити кількість підписників) можна реалізувати подію вручну через аксесори:


private EventHandler _event;
public event EventHandler MyEvent
{
    add
    {
        if (_event == null || _event.GetInvocationList().Length < 10)
            _event += value;
        else
            Console.WriteLine("Обмеження: не можна більше ніж 10 підписників.");
    }
    remove { _event -= value; }
}

Це дозволяє:

  • Додавати власну логіку.
  • Робити події потокобезпечними.
  • Перевіряти ліміти або вести журнал підписок/відписок.

9. Корисні нюанси

Лямбда-вирази, замикання і продуктивність

Лямбда-вирази зручні для підписки «на льоту»:


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

Але якщо лямбда захоплює змінні — створюється «замикання» (closure), що може збільшити споживання памʼяті. У більшості сценаріїв для UI це не критично, але для низькорівневого коду варто стежити за кількістю замикань і часом життя захоплених обʼєктів.

Цікавий факт:
Якщо додати два однакові лямбда-вирази поспіль, це будуть два різні обʼєкти-делегати, і метод виконається двічі.

Профілювання подій і делегатів

Коли застосунок стає великим і складним, профілювати події треба так само, як і будь-який інший код.

Як поміряти швидкість події?

  • Використовуйте Stopwatch для заміру часу між викликом події та завершенням обробки.
  • Використовуйте інструменти профілювання памʼяті (наприклад, dotMemory, вбудовані засоби Visual Studio), щоб знайти підписників, яких не відписали і які висять у памʼяті.
  • Щоб знайти «зомбі-підписників», шукайте довгі списки викликів (invocation list) у довгоживучих обʼєктів.

Таблиця «Оптимізацій і пасток»

Проблема/Сценарій Рішення
Багато довгоживучих (і зайвих) подій Використовувати EventHandlerList
Підписник сповільнює всіх Переносити важку логіку в Task або окремий потік
Потокобезпечність Копіювати делегат перед викликом; використовувати lock під час додавання/видалення
Винятки в обробниках Перехоплювати в try/catch навколо кожного обробника
Витоки памʼяті від «зомбі-підписників» Завжди відписуватися, реалізувати IDisposable, виконувати профілювання

Діаграма: «Життєвий цикл оптимізованої події»


+----------------+       +------------------+       +----------------------+
| Підписник      |  -->  | Підписка (+=)    |  -->  | Потрапив до Invocation |
| створений      |       |                  |       |                      |
+----------------+       +------------------+       +----------------------+
                                |                                ^
                                |                                |
                   Відписка (-=)|                        Виняток |
                                v                                |
+----------------+       +--------------------+      +----------------------+
| Підписник      |  -->  | Видалений із       |  --> | Більше не буде зомбі |
| Disposed       |       | виклику            |      |                      |
+----------------+       +--------------------+      +----------------------+

10. Як пояснити «керування подіями» на співбесіді

Якщо вам трапиться питання «Чим неефективні події в C#?» або «Коли знадобиться оптимізація подій?», ви вже знаєте:

  • Події добрі для слабкого звʼязування (loose coupling), але неефективні за масової підписки й важких обробників.
  • За замовчуванням події не є потокобезпечними.
  • Вимагають ручного відписування (інакше будуть витоки памʼяті).
  • Для масових видавців і підписників — EventHandlerList і власні аксесори add/remove.
  • Глибокий контроль потрібен рідко — для більшості завдань достатньо стандартного патерну.

У наступній лекції перейдемо до розбору просунутих сценаріїв і практичних прикладів подієво-делегатного програмування, де ви побачите, як усі ці оптимізації працюють на реальних завданнях.

Часті міфи та антипатерни

  • Вважати, що події в .NET завжди швидкі. Насправді — лише доки підписників небагато й обробники «легкі».
  • Очікувати, що GC усе сам «прибере». Ні: якщо не відписатися, обʼєкт може жити дуже довго.
  • Використовувати події для віддалених звʼязків між шарами бізнес-логіки — краще застосовувати явні патерни, як-от Mediator.
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ