Что такое events, multicast delegates, утечки памяти через события?
event против публичного делегата
public class Thermometer
{
public Action<double>? OnChangedField; // публичное поле-делегат — опасно
public event EventHandler<TemperatureEventArgs>? Changed; // событие
private double _value;
public double Value
{
get => _value;
set
{
_value = value;
Changed?.Invoke(this, new TemperatureEventArgs(value)); // вызвать может только владелец
}
}
}
public sealed record TemperatureEventArgs(double Value);
var t = new Thermometer();
t.OnChangedField = null; // кто угодно сотрёт всех подписчиков
t.OnChangedField?.Invoke(-273); // кто угодно вызовет «событие»
t.Changed += (s, e) => Console.WriteLine(e.Value); // OK
// t.Changed = null; // ошибка компиляции
// t.Changed(t, args); // ошибка компиляцииКлючевое слово event генерирует приватное поле и два метода-аксессора add / remove (с потокобезопасным Interlocked.CompareExchange).
Multicast delegate
- Делегаты неизменяемы:
+=создаёт новый делегат с расширенным списком. Поэтому копия, взятая в?.Invoke, безопасна при одновременной отписке. - Обработчики вызываются синхронно и последовательно в потоке, вызвавшем событие.
- Если обработчик бросит исключение — остальные не вызовутся. Для надёжности можно вызывать вручную:
foreach (EventHandler<TemperatureEventArgs> handler in Changed?.GetInvocationList() ?? [])
{
try { handler(this, args); }
catch (Exception ex) { _logger.LogError(ex, "Подписчик упал"); }
}Как возникает утечка памяти
public class PriceFeed // синглтон — живёт всё время работы приложения
{
public event EventHandler<decimal>? PriceChanged;
}
public class PriceWidget // создаётся и «закрывается» много раз
{
private readonly byte[] _bigCache = new byte[1_000_000];
public PriceWidget(PriceFeed feed)
{
feed.PriceChanged += OnPriceChanged; // подписались...
}
private void OnPriceChanged(object? s, decimal p) { /* обновить UI */ }
// ...и никогда не отписались
}Направление ссылки важно: издатель → подписчик. Поэтому:
- долгоживущий издатель + короткоживущий подписчик = утечка (подписчики копятся);
- короткоживущий издатель + долгоживущий подписчик = нет утечки (издатель соберётся вместе со своим списком).
Помимо памяти, «мёртвые» подписчики продолжают получать события и выполнять код — это ещё и баги, и лишняя нагрузка на CPU.
Как избежать утечек
1. Явная отписка через IDisposable
public sealed class PriceWidget : IDisposable
{
private readonly PriceFeed _feed;
public PriceWidget(PriceFeed feed)
{
_feed = feed;
_feed.PriceChanged += OnPriceChanged;
}
private void OnPriceChanged(object? s, decimal p) { }
public void Dispose() => _feed.PriceChanged -= OnPriceChanged;
}2. Слабые события
WeakReference не мешает GC собрать объект. В WPF есть WeakEventManager<TSource, TArgs>; в своих библиотеках можно реализовать хранение подписчиков через WeakReference<T> или ConditionalWeakTable.
WeakEventManager<PriceFeed, decimal>.AddHandler(feed, nameof(PriceFeed.PriceChanged), OnPriceChanged);3. Альтернативы событиям
IObservable<T>/ Rx:SubscribeвозвращаетIDisposable— естественно управлять временем жизни.Channel<T>или внутренняя шина сообщений (MediatR notifications) — нет прямых ссылок издателя на подписчика.- В ASP.NET Core
IOptionsMonitor.OnChangeвозвращаетIDisposable— его обязательно нужно освобождать.
Как найти такую утечку
dotnet-countersпоказывает стабильный ростgc-heap-sizeи Gen 2.- Снять два дампа (
dotnet-gcdump collect) с интервалом и сравнить: растёт число экземпляровPriceWidget. - В инструменте (Visual Studio, dotMemory, PerfView) посмотреть путь до корня (GC root path):
static PriceFeed → PriceChanged → PriceWidget.
Стандартный паттерн событий в .NET
public class OrderService
{
public event EventHandler<OrderPlacedEventArgs>? OrderPlaced;
protected virtual void OnOrderPlaced(OrderPlacedEventArgs e) =>
OrderPlaced?.Invoke(this, e); // virtual — наследник может переопределить поведение
public void Place(Order order)
{
// ... сохранение
OnOrderPlaced(new OrderPlacedEventArgs(order.Id));
}
}
public sealed class OrderPlacedEventArgs(int orderId) : EventArgs
{
public int OrderId { get; } = orderId;
}Вопросы на засыпку
Почему раньше писали var handler = Changed; if (handler != null) handler(...)?
Между проверкой Changed != null и вызовом другой поток мог отписать последний обработчик → NullReferenceException. Копия в локальную переменную решала проблему. Оператор ?.Invoke делает то же самое компактно.
Могут ли события быть асинхронными?
Обработчик может быть async void, но вызывающий не узнает о его завершении и не поймает исключения. Для асинхронных уведомлений лучше свой делегат Func<T, Task> и вызов всех обработчиков с await (или Task.WhenAll).
Что вернёт вызов multicast-делегата Func<int>?
Результат последнего обработчика в списке, остальные результаты теряются. Чтобы собрать все — перебирать GetInvocationList().
Утекает ли память, если подписать метод статического класса на событие экземпляра?
Нет: у статического метода Target == null, издатель не удерживает никаких экземпляров. Утечка возникает, когда долгоживущий объект держит ссылки на короткоживущие.
Итог
event — безопасная обёртка над multicast-делегатом по паттерну Observer. Издатель хранит сильные ссылки на подписчиков, поэтому подписка на долгоживущий объект без отписки — одна из самых частых причин утечек памяти в .NET.