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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ