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.
GO TO FULL VERSION