JavaRush /Курсы /C# SELF /Подписчики и безопасный вызов событий

Подписчики и безопасный вызов событий

C# SELF
53 уровень , 2 лекция
Открыта

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.

2
Задача
C# SELF, 53 уровень, 2 лекция
Недоступна
Безопасный вызов события с обработкой исключений
Безопасный вызов события с обработкой исключений
Комментарии (1)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Slevin Уровень 59
5 марта 2026
Лекция - мусор. Повтор предыдущего, плюс 15 раз одно и то же "не забудьте отписаться, не забудьте отписаться"...👎