CodeGym /Kursy /C# SELF /Subskrybenci i bezpieczne wywoływanie zdarzeń

Subskrybenci i bezpieczne wywoływanie zdarzeń

C# SELF
Poziom 53, Lekcja 2
Dostępny

1. Wprowadzenie

Kiedy piszemy

worker.WorkCompleted += listener.OnWorkCompleted;

to tak naprawdę dodajemy wskaźnik na metodę do "listy wywołań" (multicast delegate) zdarzenia. Ta "lista" wewnątrz zdarzenia — to po prostu sekwencja metod, które zostaną wywołane przy wygenerowaniu zdarzenia. W C# zdarzenie jest zbudowane na delegacie, który obsługuje wielu subskrybentów.

Wyobraź sobie mailing: masz listę subskrybentów (adresy email). Kiedy wysyłasz mailing (wywołujesz zdarzenie), wszyscy subskrybenci dostają wiadomość. Jeśli ktoś się wypisze, usuwa się go z listy i już nie dostaje maili.

Jak dodać albo usunąć subskrybenta

Subskrypcja (+=) i odsubskrybowanie (-=) działają na delegacie wewnątrz zdarzenia. Oto przykład z lambdą, którą można zarówno zasubskrybować, jak i odsubskrybować:

EventHandler<WorkCompletedEventArgs> handler = (sender, e) =>
{
    Console.WriteLine($"[Lambda] Praca zakończona: {e.Message}");
};

worker.WorkCompleted += handler; // Subskrybujemy
worker.WorkCompleted -= handler; // Odsubskrybowujemy

W przypadku zwykłych metod odsubskrybowanie wygląda tak samo:

worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted -= listener.OnWorkCompleted;

Zwróć uwagę: jeśli podpisałeś tę samą metodę kilka razy, zostanie wywołana tyle razy, ile razy ją dodałeś, i żeby ją usunąć musisz wywołać -= tyle samo razy.

2. Po co w ogóle to ręcznie zarządzać?

Dlaczego ważne zarządzanie subskrybentami?

W realnych aplikacjach, szczególnie długożyjących (np. desktopowych albo serwerowych), nieprawidłowe zarządzanie subskrypcjami może prowadzić do wycieków pamięci. Jeśli obiekt-subskrybent nie jest już potrzebny, ale wciąż "wisi" na liście subskrybentów zdarzenia, nie zostanie usunięty przez GC — bo nadal istnieje referencja z delegata zdarzenia.

Ilustracja

Akcja Skutek dla subskrybenta
+= (zapisano) Dodany do listy
-= (wypisano) Usunięty z listy
Obiekt subskrybenta usunięty Jeśli NIE wypisany! — NIE usunięty, bo wciąż jest referencja w zdarzeniu
Obiekt subskrybenta usunięty Jeśli WYPISANY — zostanie normalnie usunięty

Jak sprawdzić, kto jest subskrybowany na zdarzenie?

Zdarzenia enkapsulują listę subskrybentów, więc z zewnątrz klasy-publishera nie możesz dostać tej listy bezpośrednio — możesz tylko dodać (+=) lub usunąć (-=) handlery.

Jednak wewnątrz klasy, gdzie zadeklarowano zdarzenie na bazie delegata (np. EventHandler), można pobrać aktualną listę subskrybentów używając GetInvocationList():

// Wewnątrz klasy-publishera
if (WorkCompleted != null)
{
    foreach (Delegate subscriber in WorkCompleted.GetInvocationList())
    {
        Console.WriteLine($"Handler: {subscriber.Method.Name}, Obiekt: {subscriber.Target}");
    }
}

Taki trik rzadko jest potrzebny w codziennej pracy, ale może się przydać do debugowania albo implementacji masowego odsubskrybowania wszystkich subskrybentów.

3. Bezpieczne wywoływanie zdarzeń: "miny" i jak je obejść

Co może pójść nie tak przy wywołaniu zdarzenia?

Wszystko wygląda prosto: wywołujesz

WorkCompleted?.Invoke(this, args);

i większość czasu działa... Ale są niuanse. Oto one:

1. Wielowątkowe niebezpieczeństwo

W aplikacji wielowątkowej możliwa jest sytuacja, kiedy między sprawdzeniem zdarzenia na null a wywołaniem ktoś inny zmieni subskrypcje. Na przykład:

1) Wątek A sprawdza: WorkCompleted != null.
2) W tym czasie wątek B odsubskrybowuje zdarzenie (-=), i lista handlerów robi się pusta.
3) Wątek A próbuje wywołać WorkCompleted.Invoke(...) — występuje NullReferenceException, bo handlerów już nie ma.

To klasyczne race condition przy pracy ze zdarzeniami.

2. Niespodziewane wyjątki w handlerach

Jeśli jeden z subskrybentów rzuci wyjątek podczas obsługi zdarzenia, wywołanie kolejnych handlerów zostaje przerwane. Czyli zdarzenie "psuje się" na pierwszym wyjątku i reszta subskrybentów nie dostanie powiadomienia. Żeby tego uniknąć, dobrze opakować wywołanie każdego handlera w try-catch, jeśli zależy ci, żeby wszyscy dostali sygnał.

3. Niechciany wyciek kontekstu

Handler zdarzenia często jest metodą instancyjną, która trzyma referencję do obiektu-subskrybenta (this). Jeśli subskrybent zapomni się odsubskrybować od publishera, referencja na niego zostanie w liście delegatów publishera. W rezultacie GC nie będzie mógł zwolnić tego obiektu — mamy memory leak.

Jak bezpiecznie wywołać zdarzenie?

1) Skopiuj delegata do lokalnej zmiennej

Wywołanie przez lokalną zmienną gwarantuje, że w trakcie wywołania lista subskrybentów się nie zmieni:

// Stary, pewny sposób
var handler = WorkCompleted;
if (handler != null)
{
    handler(this, args);
}

A albo nowocześniej, z operatorem "?.":

WorkCompleted?.Invoke(this, args);

W większości przypadków to wystarcza, bo kompilator C# "rozumie" tę konstrukcję i robi wewnętrzne skopiowanie referencji (zob. oficjalną dokumentację).

2) Ochrona przed wyjątkami handlerów

Jeśli krytyczne jest, żeby wszyscy wszyscy handlerzy zdarzenia zostali wywołani (nawet jeśli jeden padnie), użyj ręcznej iteracji:

var handler = WorkCompleted;
if (handler != null)
{
    foreach (EventHandler<WorkCompletedEventArgs> subscriber in handler.GetInvocationList())
    {
        try
        {
            subscriber(this, args);
        }
        catch (Exception ex)
        {
            // Logujemy, ale nie pozwalamy żeby całe zdarzenie "padło"
            Console.WriteLine($"Błąd w handlerze: {ex.Message}");
        }
    }
}

Taki wariant rzadko jest potrzebny dla zwykłych UI-scenariuszy, ale często używa się go w bibliotekach, loggerach i złożonych systemach.

3) Zapobieganie wyciekom pamięci

Jeśli subskrybent "żyje" krócej niż publisher (np. okno subskrybuje zdarzenie aplikacji), musi się odsubskrybować:

worker.WorkCompleted -= listener.OnWorkCompleted;

W przeciwnym razie garbage collector nie będzie w stanie zwolnić listener, nawet jeśli nie ma już "jawnych" referencji do niego.

4. Praktyczny przykład: manager masowych subskrypcji i odsubskrypcji

Rozszerzmy nasze ćwiczeniowe app. Załóżmy, że mamy kilka listenerów — i chcemy dynamicznie ich podłączać i odłączać w czasie działania programu.

public class WorkListener
{
    private readonly string _name;

    public WorkListener(string name)
    {
        _name = name;
    }

    public void OnWorkCompleted(object sender, WorkCompletedEventArgs e)
    {
        Console.WriteLine($"Listener {_name}: {e.Message}");
    }
}

W głównej części programu:

var worker = new Worker();

var listeners = new List<WorkListener>
{
    new WorkListener("Iwan"),
    new WorkListener("Marija"),
    new WorkListener("Denis")
};

// Subskrybujemy wszystkich listenerów
foreach (var listener in listeners)
    worker.WorkCompleted += listener.OnWorkCompleted;

// Wywołujemy zdarzenie
worker.DoWork();

// Masowe odsubskrybowanie
foreach (var listener in listeners)
    worker.WorkCompleted -= listener.OnWorkCompleted;

// Sprawdzamy, że nikt już nie reaguje
worker.DoWork();

W konsoli po pierwszym DoWork pojawią się 3 wiadomości, po drugim — żadnej.

5. Wskazówki do bezpiecznej pracy ze zdarzeniami

  • Odsysubskrybowuj na czas, jeśli cykl życia subskrybenta jest krótszy niż cykl życia publishera.
  • Jeśli implementujesz pattern "długożyjący publisher — tymczasowy subscriber", zawsze wypisuj się, np. w Dispose(), przy zamykaniu okna lub innym końcu życia obiektu.
  • Dla jednorazowych zdarzeń możesz użyć anonimowego handlera-lambdy i odsubskrybować się od razu wewnątrz niej:
EventHandler<WorkCompletedEventArgs> handler = null;
handler = (s, e) => 
{
    Console.WriteLine("Zdarzenie obsłużone raz!");
    worker.WorkCompleted -= handler;
};
worker.WorkCompleted += handler;
  • Nie trzymaj referencji do subskrybentów ani handlerów tylko po to, żeby sprawdzać "kto jest subskrybowany" — to zwykle niepotrzebne w biznesowej logice. Rób to tylko do debugowania.

6. Częste błędy i jak ich unikać

Błąd nr 1: zapomniano się odsubskrybować — memory leak.
Jeśli subskrybent nie odsubskrybuje się, szczególnie w dużych aplikacjach z wieloma zdarzeniami i subskrybentami, obiekty mogą zostać w pamięci dłużej niż trzeba. Taki błąd długo może nie dawać o sobie znać, ale prowadzi do wzrostu użycia pamięci i spadku wydajności.

Błąd nr 2: wywołanie zdarzenia bez sprawdzenia na null.
Jeśli zdarzenie nie ma subskrybentów i spróbujesz wywołać je bezpośrednio, dostaniesz NullReferenceException. W nowych wersjach C# pomaga operator bezpiecznego wywołania ?., ale jeśli pracujesz ze starym kodem albo iterujesz handlerami ręcznie, nie zapomnij sprawdzić zdarzenia na null.

Błąd nr 3: wyjątek w jednym z handlerów przerywa wywołanie kolejnych.
Jeśli jeden handler rzuci wyjątkiem, następne nie zostaną wywołane. Jeśli zależy ci, żeby wszyscy subskrybenci dostali powiadomienie, iteruj po handlerach i opakowuj wywołanie każdego w blok try/catch.

2
Zadanie
C# SELF, poziom 53, lekcja 2
Niedostępne
Bezpieczne wywołanie zdarzenia z obsługą wyjątków
Bezpieczne wywołanie zdarzenia z obsługą wyjątków
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION