События и паттерн Observer в C#

↑ 2.1 C♯: продвинутый язык · ← Предыдущая · → Следующая

Обязательно~7 мин чтенияУровень: middle

Зачем это на собесе

события — встроенная реализация Observer. Вопросы: чем event отличается от публичного делегата, почему события — источник утечек, какие альтернативы для слабой связанности в бэкенде.

Объяснение

event поверх делегата

public sealed class ReportJob
{
    public event EventHandler<ReportProgressEventArgs>? ProgressChanged;   // событие
    public event EventHandler? Completed;
 
    public async Task RunAsync(CancellationToken ct)
    {
        for (var page = 1; page <= total; page++)
        {
            await RenderPageAsync(page, ct);
            OnProgressChanged(new ReportProgressEventArgs(page, total));
        }
        Completed?.Invoke(this, EventArgs.Empty);
    }
 
    private void OnProgressChanged(ReportProgressEventArgs e) => ProgressChanged?.Invoke(this, e);   // ?. — потокобезопасный вызов
}
 
public sealed class ReportProgressEventArgs(int page, int total) : EventArgs
{
    public int Page { get; } = page;
    public int Total { get; } = total;
}
 
// подписка и отписка
job.ProgressChanged += OnProgress;
job.ProgressChanged -= OnProgress;

Чем event отличается от публичного поля-делегата

public Action? OnDone;               // поле: снаружи можно
obj.OnDone = null;                   //   — затереть всех подписчиков
obj.OnDone?.Invoke();                //   — вызвать событие от имени объекта
 
public event Action? Done;           // событие: снаружи можно только += и -=

event — инкапсуляция: вызывать и сбрасывать может только владелец.

Конвенция .NET

  • Сигнатура (object? sender, TEventArgs e), EventHandler/EventHandler<T>.
  • Защищённый виртуальный метод OnXxx для вызова (наследники могут переопределить).
  • Имена: Changed, Completed, Changing (до события).

Утечки памяти через события

Источник события держит сильную ссылку на подписчика (через делегат). Если долгоживущий источник (singleton-сервис, статическое событие) и короткоживущий подписчик не отписался — подписчик не соберётся GC.

public sealed class Widget : IDisposable
{
    private readonly ISettingsService _settings;          // singleton
    public Widget(ISettingsService s) { _settings = s; _settings.Changed += OnChanged; }
    public void Dispose() => _settings.Changed -= OnChanged;   // обязательно отписаться
    private void OnChanged(object? sender, EventArgs e) { }
}

Альтернативы в бэкенде

В серверном коде классические события используют редко. Вместо них:

  • Доменные события + mediator (MediatR INotification) — обработчики разрешаются из DI, без ручных подписок.
  • Channels или IObservable<T> (Rx) — поток событий внутри процесса.
  • Брокер сообщений (Kafka, RabbitMQ) — между сервисами.
  • IProgress<T> — для отчёта о прогрессе асинхронных операций.
public async Task GenerateAsync(IProgress<int>? progress, CancellationToken ct)
{
    for (var i = 1; i <= 100; i++) { await StepAsync(ct); progress?.Report(i); }
}

Нюансы и подводные камни

  • Обработчики вызываются синхронно в потоке, вызвавшем событие. Медленный обработчик тормозит источник.
  • async void обработчики событий — единственное допустимое место для async void, но исключение в них роняет процесс. Оборачивайте в try/catch.
  • Исключение в одном подписчике прерывает остальных.
  • Событие не гарантирует доставку: если процесс упал, событие потеряно. Для надёжности нужны outbox и брокер.
  • Отписка лямбдой не работает, если лямбду не сохранили: -= (s, e) => ... — это новый делегат.

Тестирование

[Fact]
public async Task Raises_progress_for_each_page()
{
    var job = new ReportJob(totalPages: 3);
    var pages = new List<int>();
    job.ProgressChanged += (_, e) => pages.Add(e.Page);
    await job.RunAsync(default);
    pages.Should().Equal(1, 2, 3);
}

Вопросы с ответами

Связанные темы