1. Введение
Когда мы пишем
worker.WorkCompleted += listener.OnWorkCompleted;
мы на самом деле добавляем указатель на метод в "список вызова" (multicast delegate) события. Этот "список" внутри события — просто последовательность методов, которые будут вызваны при генерации события. В C# событие реализовано поверх делегата, поддерживающего множество подписчиков.
Представьте рассылку: у вас есть список подписчиков (email-адреса). Когда вы отправляете рассылку (вызываете событие), все подписчики получают письмо. Если кто-то отпишется, его удаляют из списка и он больше не получает писем.
Как добавить или убрать подписчика
Подписка (+=) и отписка (-=) работают с делегатом внутри события. Вот пример с лямбдой, которую можно и подписать, и отписать:
EventHandler<WorkCompletedEventArgs> handler = (sender, e) =>
{
Console.WriteLine($"[Лямбда] Работа завершена: {e.Message}");
};
worker.WorkCompleted += handler; // Подписываем
worker.WorkCompleted -= handler; // Отписываем
В случае обычных методов отписка выглядит так же:
worker.WorkCompleted += listener.OnWorkCompleted;
worker.WorkCompleted -= listener.OnWorkCompleted;
Обратите внимание: если вы подписали один и тот же метод несколько раз, он вызовется столько же раз и для отписки вам нужно столько же раз вызвать -=.
2. Зачем вообще управлять им вручную?
Почему важно управлять подписчиками?
В реальных приложениях, особенно долгоживущих (например, настольных или серверных), неправильное управление подписками может привести к утечкам памяти. Если объект-подписчик уже никому не нужен, но всё ещё "висит" в списке подписчиков события, он не будет удалён сборщиком мусора — потому что на него всё ещё есть ссылка из делегата события.
Иллюстрация
| Действие | Итог для подписчика |
|---|---|
| += (подписались) | Добавлен в список |
| -= (отписались) | Удалён из списка |
| Объект подписчика удалён | Если НЕ отписан! — НЕ удалён, т.к. все ещё есть ссылка в событии |
| Объект подписчика удалён | Если ОТПИСАН — удалится нормально |
Как узнать, кто подписан на событие?
События инкапсулируют список подписчиков, поэтому извне класса-издателя вы не можете получить этот список напрямую — можно только добавить (+=) или убрать (-=) обработчики.
Однако внутри класса, где объявлено событие на базе делегата (например, EventHandler), можно получить текущий список подписчиков с помощью метода GetInvocationList():
// Внутри класса-издателя
if (WorkCompleted != null)
{
foreach (Delegate subscriber in WorkCompleted.GetInvocationList())
{
Console.WriteLine($"Обработчик: {subscriber.Method.Name}, Объект: {subscriber.Target}");
}
}
Такой приём редко необходим в повседневной разработке, но может быть полезен для отладки или реализации методов массовой отписки всех подписчиков.
3. Безопасный вызов событий: "мины" и как их обойти
Что может пойти не так при вызове события?
Всё выглядит просто: вы вызываете
WorkCompleted?.Invoke(this, args);
и всё прекрасно работает... Большую часть времени! Но есть нюансы. Вот они:
1. Многопоточная опасность
В многопоточном приложении возможна ситуация, когда между проверкой события на null и вызовом обработчиков другой поток изменил подписки. Например:
1) Поток A проверяет: WorkCompleted != null.
2) В это же время поток B отписывается от события (-=), и список обработчиков становится пустым.
3) Поток A пытается вызвать WorkCompleted.Invoke(...) — возникает NullReferenceException, потому что обработчиков уже нет.
Это классическая гонка данных при работе с событиями.
2. Неожиданные исключения в обработчиках
Если один из подписчиков выбрасывает исключение во время обработки события, вызов остальных обработчиков прерывается. То есть событие «ломается» на первом же исключении, и остальные подписчики не получают уведомления. Чтобы избежать этого, рекомендуется оборачивать вызов каждого обработчика в try-catch, если важно, чтобы все получили сигнал.
3. Нежелательная утечка контекста
Обработчик события часто является экземплярным методом, который захватывает ссылку на объект-подписчик (this). Если подписчик забыл отписаться от события издателя, ссылка на него сохраняется в списке делегатов издателя. В результате сборщик мусора не сможет освободить этот объект — возникает утечка памяти.
Как безопасно вызывать событие?
1) Копировать делегат в локальную переменную
Вызов по локальной переменной гарантирует, что во время вызова список подписчиков не изменится:
// Старый добрый способ
var handler = WorkCompleted;
if (handler != null)
{
handler(this, args);
}
Или более современно, с оператором «?.» (null-conditional):
WorkCompleted?.Invoke(this, args);
В большинстве случаев этого достаточно, так как компилятор C# "понимает" эту конструкцию и делает внутреннее копирование ссылки (см. официальная документация).
2) Защита от исключений обработчиков
Если критично, чтобы все обработчики события были вызваны (даже если один упал), используйте перебор вручную:
var handler = WorkCompleted;
if (handler != null)
{
foreach (EventHandler<WorkCompletedEventArgs> subscriber in handler.GetInvocationList())
{
try
{
subscriber(this, args);
}
catch (Exception ex)
{
// Логируем, но не даём событию "завалиться" целиком
Console.WriteLine($"Ошибка в обработчике: {ex.Message}");
}
}
}
Такой вариант редко нужен для обычных UI-сценариев, но востребован в библиотеках, логгерах и сложных системах.
3) Предотвращаем утечки памяти
Если подписчик "живет" меньше, чем издатель (например, окно подписалось на событие приложения), он обязан отписаться:
worker.WorkCompleted -= listener.OnWorkCompleted;
В противном случае garbage collector не сможет освободить listener, даже если на него больше нет "явных" ссылок.
4. Практический пример: менеджер массовых подписок и отписок
Давайте расширим наше учебное приложение. Представим, что у нас есть несколько слушателей — и мы хотим динамически подписывать и снимать их, по мере работы программы.
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}");
}
}
В основной программе:
var worker = new Worker();
var listeners = new List<WorkListener>
{
new WorkListener("Иван"),
new WorkListener("Мария"),
new WorkListener("Денис")
};
// Подписываем всех слушателей
foreach (var listener in listeners)
worker.WorkCompleted += listener.OnWorkCompleted;
// Вызываем событие
worker.DoWork();
// Массовая отписка
foreach (var listener in listeners)
worker.WorkCompleted -= listener.OnWorkCompleted;
// Проверяем, что никто больше не реагирует
worker.DoWork();
В консоли после первой работы появится 3 сообщения, после второй — ни одного.
5. Советы по безопасной работе с событиями
- Отписывайтесь вовремя, если жизненный цикл подписчика меньше жизненного цикла издателя.
- Если реализуете паттерн «долгоживущий издатель — временный подписчик», всегда делайте отписку, например, в Dispose(), при закрытии окна или другом явном окончании жизни объекта.
- Для одноразовых событий можно использовать анонимный обработчик-лямбду и тут же внутри него отписаться:
EventHandler<WorkCompletedEventArgs> handler = null;
handler = (s, e) =>
{
Console.WriteLine("Событие обработано один раз!");
worker.WorkCompleted -= handler;
};
worker.WorkCompleted += handler;
- Не сохраняйте ссылки на подписчиков или обработчики для проверки «кто подписан» — это не нужно в обычной бизнес-логике. Делайте это только для целей отладки.
6. Частые ошибки и как их избежать
Ошибка №1: забыли отписаться от события — утечка памяти.
Если подписчик не отписался, особенно в больших приложениях с множеством событий и подписчиков, объекты могут оставаться в памяти дольше, чем нужно. Такая ошибка часто долго не проявляется явно, но приводит к росту потребления памяти и ухудшению производительности.
Ошибка №2: вызов события без проверки на null.
Если у события нет подписчиков и попытаться вызвать его напрямую, возникнет NullReferenceException. В новых версиях C# помогает оператор безопасного вызова ?., но если вы работаете со старым кодом или перебираете обработчики вручную, не забывайте проверять событие на null.
Ошибка №3: исключение в одном из обработчиков прерывает вызов остальных.
Если один из обработчиков выбрасывает исключение, последующие обработчики не будут вызваны. Если важно, чтобы все подписчики получили уведомление, перебирайте обработчики в цикле и оборачивайте вызов каждого в блок try/catch.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ