1. Wprowadzenie
W większości typowych aplikacji zdarzenia działają szybko i prawie "za darmo" — CLR (Common Language Runtime) jest świetnie zoptymalizowany do ich obsługi. Jednak gdy aplikacja robi się duża, zdarzeń jest dużo, łańcuchy subskrybentów długie, a wymagania co do wydajności rosną, nagle okazuje się, że nawet taka "prosta" konstrukcja jak zdarzenia może stać się wąskim gardłem. Szczególnie widoczne jest to w systemach z dużą ilością real-time-aktualizacji, interfejsach użytkownika (UI) lub przy obsłudze setek tysięcy powiadomień z sensorów w aplikacjach IoT.
W tym wykładzie omówimy:
- Jak zdarzenia i delegaty wpływają na wydajność.
- Jakie są wąskie gardła.
- Jak pisać szybki kod zdarzeniowy i unikać problemów wpływających na wydajność.
Wewnętrzna struktura zdarzeń w .NET
Jak wspomniano, zdarzenie to wrapper wokół delegata. Delegat to specjalny obiekt zawierający listę metod (invocation list), które są wywoływane przy invokacji. Przy każdym wywołaniu zdarzenia CLR iteruje tę listę i wywołuje wszystkie metody synchronicznie. (Asynchroniczność pojawia się tylko jeśli ręcznie dodasz async kod.)
Schemat poglądowy:
[Wydawca] ----- (event) ---> [Delegate (Invocation List)] --> [Handler 1]
--> [Handler 2]
--> [Handler N]
2. Koszt delegatów i zdarzeń: rozbiór na atomy
Koszt przechowywania
- Każdy delegat to pełnoprawny obiekt.
- Każdy handler (metoda-subscriber) tworzy kolejny delegat.
- Im więcej subskrybentów — tym więcej obiektów, tym więcej pamięci.
W prostych przypadkach wycieków lub overhead prawie nie ma. Ale jeśli handlerów są tysiące — warto się zastanowić!
Koszt wywołania
- Wywołanie zdarzenia = iteracja invocation list.
- Każda metoda jest wywoływana synchronicznie (jedna po drugiej).
- Jeśli handler wykonuje ciężką pracę lub długo "śpi", spowalnia wszystkich pozostałych.
Przykład: prosta implementacja
public class Counter
{
public event EventHandler Counted;
public void Increment()
{
// ... logikę liczenia pomijamy
// Subskrybenci są wywoływani synchronicznie!
Counted?.Invoke(this, EventArgs.Empty);
}
}
Jeśli mamy 1000 subskrybentów, u których handlery używają Thread.Sleep(10), wywołanie zdarzenia zajmie około 10 sekund...
3. "Ciężcy" subskrybenci — wróg wydajności
Dlaczego handlery powinny być "lekkie"?
- Zdarzenia wywoływane są synchronicznie, wątek wywołujący czeka na zakończenie wszystkich handlerów.
- Jeden wolny handler spowalnia cały łańcuch.
- Jeśli handler może "wybuchnąć" wyjątkiem — pozostałe mogą nie zostać wywołane (jeśli nie zabezpieczysz wywołania przez try/catch).
Demonstracja
class Program
{
static void Main()
{
var publisher = new Counter();
// Szybki
publisher.Counted += (s, e) => Console.WriteLine("First");
// Wolny
publisher.Counted += (s, e) => System.Threading.Thread.Sleep(2000);
// Jeszcze jeden
publisher.Counted += (s, e) => Console.WriteLine("Last");
// Pomiar czasu
var watch = System.Diagnostics.Stopwatch.StartNew();
publisher.Increment();
watch.Stop();
Console.WriteLine($"Wszystkie handlery wykonane w {watch.ElapsedMilliseconds} ms.");
}
}
Uruchom — zobaczysz zauważalną pauzę. Pierwszy handler — prawie natychmiast, drugi to "opóźnienie", dopiero potem trzeci.
Wniosek praktyczny
- Nie wkładaj ciężkiej logiki bezpośrednio do handlerów zdarzeń!
- Lepiej przenieść taką pracę do osobnego wątku, taska lub użyć asynchronicznego handlera.
4. Wyjątki w handlerach: pułapki dla wydajności
Jeśli jeden z subskrybentów rzuca wyjątek, obsługa zdarzeń zostaje przerwana — kolejne handlery mogą nie zostać wywołane!
publisher.Counted += (s, e) => throw new Exception("Error!");
publisher.Counted += (s, e) => Console.WriteLine("Nie zobaczysz tej linii.");
Aby tego uniknąć i nie blokować działania z powodu jednego "złego jabłka", użyj ręcznej iteracji z ochroną każdego handlera.
Zaawansowana wersja wywołania zdarzeń
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($"Błąd w handlerze: {ex.Message}");
// Logowanie, albo specjalna obsługa błędu
}
}
}
}
To sprawia, że zdarzenie jest bardziej "odporne": nawet jeśli jeden subskrybent padnie — reszta nadal działa.
5. Asynchroniczne (fire-and-forget) zdarzenia
Jeśli zdarzenie może być wolne — czasem chcesz uruchomić handlery w osobnych wątkach lub taskach, żeby nie blokować głównego wątku.
Opcja 1: uruchomienie każdego handlera w osobnym tasku
protected virtual void OnCountedAsync()
{
var handlers = Counted?.GetInvocationList();
if (handlers != null)
{
foreach (var handler in handlers)
{
// Fire-and-forget: nie czekamy na zakończenie!
System.Threading.Tasks.Task.Run(() =>
{
((EventHandler)handler)(this, EventArgs.Empty);
});
}
}
}
Ale! Uważaj na równoległość
- Jeśli subskrybenci używają wspólnego zasobu — możliwe race conditions.
- Wyjątki w fire-and-forget handlerach trudno złapać.
- Jeśli ważne jest poczekanie na zakończenie wszystkich subskrybentów — zbieraj taski i użyj Task.WhenAll.
Dla UI (WinForms/WPF) — nigdy nie wywołuj handlerów poza wątkiem UI, inaczej dostaniesz InvalidOperationException.
Ogólnie — asynchroniczne zdarzenia wymagają przemyślanego designu!
6. Optymalizacja przechowywania i wywołań zdarzeń
"Puste" zdarzenia: oszczędzamy pamięć
Jeśli w klasie masz dużo zdarzeń, z których większość jest rzadko używana (np. wiele eventów w komponencie UI), jest trik: EventHandlerList.
Jak to działa
Kontrolki .NET (np. w WinForms) nie trzymają oddzielnego delegata dla każdego zdarzenia, tylko umieszczają wszystkie zdarzenia w jednej strukturze (EventHandlerList) — tylko jeśli przynajmniej jeden handler się zapisze.
Przykład manualnego stworzenia EventHandlerList
using System.ComponentModel; // EventHandlerList tu mieszka!
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);
}
}
Po co to robić: oszczędzasz pamięć, nie tworząc niepotrzebnych delegatów dla setek "pustych" zdarzeń.
7. Bezpieczeństwo wątkowe: unikanie race'ów i blokad
Zdarzenia w .NET same w sobie NIE są thread-safe! Gdy subskrypcja lub odsubskrypcja zachodzi, w tym samym czasie inny wątek może inicjować zdarzenie. To może spowodować, że delegat stanie się null tuż przed wywołaniem, co spowoduje NullReferenceException.
Najlepsze praktyki
- Używaj operatora ?. (Counted?.Invoke(...)) — chroni przed null.
- W trudniejszych przypadkach — blokuj dostęp do zdarzenia przez lock.
Przykład
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);
}
Kiedy taka złożoność jest potrzebna?
- W aplikacjach wielowątkowych (np. serwery, wielowątkowe parsery itp.).
- Jeśli subskrypcja/odsubskrypcja odbywa się z różnych wątków, a wywołanie zdarzenia z jeszcze innego.
8. Akcesory add/remove dla kontroli i optymalizacji
W specjalnych przypadkach (np. gdy trzeba logować wszystkie subskrypcje albo ograniczyć liczbę subskrybentów) można zaimplementować zdarzenie ręcznie przez akcesory:
private EventHandler _event;
public event EventHandler MyEvent
{
add
{
if (_event == null || _event.GetInvocationList().Length < 10)
_event += value;
else
Console.WriteLine("Ograniczenie: nie można mieć więcej niż 10 subskrybentów.");
}
remove { _event -= value; }
}
To pozwala:
- Wstrzykiwać customową logikę.
- Robić zdarzenia thread-safe.
- Sprawdzać limity albo logować subskrypcje/odsubskrypcje.
9. Przydatne niuanse
Wyrażenia lambda, closury i wydajność
Lambda są wygodne do podpinania "w locie":
var button = new Button();
button.Click += (s, e) => Console.WriteLine("Button clicked");
Ale jeśli lambda przechwytuje zmienne — tworzy się closure, co może zwiększyć zużycie pamięci. W większości przypadków UI nie jest to problemem, ale w kodzie niskopoziomowym warto pilnować liczby closure i lifetime przechwyconych obiektów.
Ciekawostka:
Jeśli dodasz dwie identyczne lambdy pod rząd, będą to dwa różne obiekty-delegaty i metoda wykona się dwukrotnie.
Profilowanie zdarzeń i delegatów
Gdy aplikacja jest duża i złożona, profilowanie zdarzeń trzeba robić tak samo jak każdego innego kodu.
Jak zmierzyć szybkość zdarzenia?
- Użyj Stopwatch do pomiaru czasu między wywołaniem zdarzenia a zakończeniem obsługi.
- Użyj narzędzi do profilowania pamięci (np. dotMemory, wbudowane narzędzia Visual Studio), żeby znaleźć subskrybentów, którzy nie zostali odsubskrybowani i wiszą w pamięci.
- Aby znaleźć "zombie-subskrybentów" szukaj długich list invocation list u długożyjących obiektów.
Tabela "Optymalizacje i pułapki"
| Problem/Scenariusz | Rozwiązanie |
|---|---|
| Wiele długożyjących (i bezużytecznych) zdarzeń | Używać EventHandlerList |
| Subskrybent spowalnia wszystkich | Przenosić ciężką logikę do taska/osobnego wątku |
| Bezpieczeństwo wątkowe | Kopiować delegata przed wywołaniem, lock przy dodawaniu/usuwaniu |
| Wyjątki w handlerach | Łapać w try/catch wokół każdego handlera |
| Wycieki pamięci od "zombie-subskrybentów" | Zawsze odsubskrybowywać, implementować IDisposable, profilować |
Diagram: "Cykl życia zoptymalizowanego zdarzenia"
+----------------+ +------------------+ +---------------------+
| Subskrybent utw | --> | Subskrypcja (+=) | --> | Trafił do Invocation |
+----------------+ +------------------+ +---------------------+
| ^
| |
Odsubskrypcja (-=) | Wyjątek |
v |
+----------------+ +--------------------+ +----------------------+
| Subskrybent Dispo | --> | Usunięty z wywołań | --> | Nie jest już zombie |
+----------------+ +--------------------+ +----------------------+
10. Jak wytłumaczyć "zarządzanie zdarzeniami" na rozmowie kwalifikacyjnej
Jeśli padnie pytanie "Czym nieefektywne są eventy w C#?" lub "Kiedy trzeba optymalizować zdarzenia?", wiesz, że:
- Zdarzenia dobre są do loose coupling, ale nieefektywne przy masowej subskrypcji i ciężkich handlerach.
- Domyślnie nie są thread-safe.
- Wymagają ręcznego odsubskrybowania (inaczej wycieki pamięci).
- Dla masowych producentów i subskrybentów — EventHandlerList i własne akcesory add/remove.
- Głęboka kontrola rzadko jest potrzebna — większość przypadków pokrywa standardowy wzorzec.
W następnej lekcji przejdziemy do analizy zaawansowanych scenariuszy i praktycznych przykładów eventowo-delegatowego programowania, gdzie zobaczysz, jak wszystkie te optymalizacje działają na realnych zadaniach.
Częste mity i antipatterny
- Myśleć, że zdarzenia w .NET zawsze są szybkie — są szybkie, dopóki nie będzie dużo subskrybentów lub ciężkich handlerów.
- Liczyć na to, że GC wszystko sam "posprząta" — nie, jeśli nie odsubskrybujesz, obiekt będzie żyć dalej!
- Używać zdarzeń do dalekich połączeń między warstwami logiki biznesowej — lepiej stosować jawne wzorce (np. Mediator).
GO TO FULL VERSION