CodeGym /Kursy /C# SELF /Optymalizacja programowania opartego na zdarzeniach

Optymalizacja programowania opartego na zdarzeniach

C# SELF
Poziom 54 , Lekcja 2
Dostępny

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).
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION