Слабые события в C#

ОГЛАВЛЕНИЕ

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

Введение

При использовании нормальных событий C# регистрация обработчика события создает жесткую ссылку от источника события на слушающий объект.
 


Если объект-источник имеет более долгое время жизни, чем слушатель, и слушателю больше не нужны события, когда нет других ссылок на него, использование нормальных событий .NET вызывает утечку памяти: объект-источник удерживает в памяти объекты-слушатели, которые должны быть удалены сборщиком мусора.

Есть много разных подходов к этой проблеме. Данная статья объясняет некоторые из них и рассматривает их плюсы и минусы. Подходы были разделены на две категории: сначала предполагается, что источник события – существующий класс с нормальным событием C#; после этого разрешается изменение источника события, чтобы позволить разные подходы.

Чем именно являются события?

Многие программисты считают события списком делегатов – это неверно. Делегаты сами могут быть групповыми (многоадресными):
EventHandler eh = Method1;
eh += Method2;
Так что же такое события? По сути, они похожи на свойства: они инкапсулируют поле делегата и ограничивают доступ к нему. Открытое поле делегата (или открытое свойство делегата) может означать, что другие объекты могут очистить список обработчиков событий, или возбуждать событие – но нужно, чтобы только создавший событие объект мог делать это.

В сущности, свойства – пара методов get (прочитать)/set (установить). События – пара методов add (добавить)/remove (удалить).
public event EventHandler MyEvent {
   add { ... }
   remove { ... }
}
Только добавление и удаление обработчиков public (открытое). Другие классы не могут запрашивать список обработчиков, не могут очистить список, или не могут вызвать событие.
К путанице иногда приводит то, что C# имеет сокращенный синтаксис:
public event EventHandler MyEvent;
Это разворачивается в:
private EventHandler _MyEvent; // подразумеваемое поле
// оно на самом деле называется не "_MyEvent", а "MyEvent",
// но вы не можете увидеть разницу между полем
// и событием.
public event EventHandler MyEvent {
  add { lock(this) { _MyEvent += value; } }
  remove { lock(this) { _MyEvent -= value; } }
}
Стандартные события C# зафиксированы на this (этот)! Это можно проверить с помощью дизассемблера - методы add и remove декорированы [MethodImpl(MethodImplOptions.Synchronized)], что эквивалентно фиксации на this.
Регистрация и снятие с регистрации событий поточно-ориентированы. Но возбуждение события с ориентацией на многопоточное исполнение оставлено на усмотрение программиста, пишущего код, который возбуждает событие, и часто делает это неверно: код возбуждения, вероятно, используемый большинством, не ориентирован на многопоточное исполнение:
if (MyEvent != null)
   MyEvent(this, EventArgs.Empty);
   // может прерваться выполнение с исключением NullReferenceException
   // когда одновременно удаляется обработчик последнего события.
Вторая наиболее часто встречающаяся стратегия – первое считывание делегата события в локальную переменную.
EventHandler eh = MyEvent;
if (eh != null) eh(this, EventArgs.Empty);
Является ли это поточно-ориентированным? Ответ: когда как. Согласно модели памяти в спецификации C#, это не поточно-ориентировано. Компилятору JIT разрешается убирать локальную переменную, смотрите Понимание воздействия методов низкой блокировки в многопоточных приложениях. Однако среда выполнения Microsoft .NET имеет более устойчивую модель памяти (начиная с версии 2.0), и в ней этот код поточно-ориентированный. Он также является поточно-ориентированным в Microsoft .NET 1.0 и 1.1, но это не описанная в документации деталь реализации.

Корректное решение, согласно спецификации ECMA, должно бы перемещать присваивание локальной переменной в блок lock(this) или использовать поле volatile (временный) для хранения делегата.
EventHandler eh;
lock (this) { eh = MyEvent; }
if (eh != null) eh(this, EventArgs.Empty);
Это означает, что нужно различать события, являющиеся поточно-ориентированными, и события, не являющиеся таковыми.


Часть 1: Слабые события стороны слушателя

В этой части предполагается, что событие – нормальное событие C# (жесткие ссылки на обработчики событий), и любая очистка должна выполняться на слушающей стороне.

Решение 0: Только снятие с регистрации

void RegisterEvent()
{
    eventSource.Event += OnEvent;
}
void DeregisterEvent()
{
    eventSource.Event -= OnEvent;
}
void OnEvent(object sender, EventArgs e)
{
    ...
}
Простое и эффективное, это то, что нужно использовать при наличии возможности. Но часто заведомо невозможно гарантировать, что метод DeregisterEvent будет вызываться всегда, когда объект больше не используется. Можно попробовать использовать шаблон удалить, хотя он обычно предназначен для неуправляемых ресурсов. Сборщик мусора не будет работать: программа чистки памяти не вызовет его, так как источник события все еще хранит ссылку на наш объект!

Плюсы
Простой, если проект уже имеет пометку, что он удаляется.

Минусы
Явное управление памятью трудное, код может забыть вызвать Dispose (удалить).

Решение 1: Снятие с регистрации, когда событие вызвано

void RegisterEvent()
{
    eventSource.Event += OnEvent;
}

void OnEvent(object sender, EventArgs e)
{
    if (!InUse) {
        eventSource.Event -= OnEvent;
        return;
    }
    ...
}
Теперь не требуется, чтобы кто-то сообщал нам, когда слушатель больше не используется: он просто проверяет это сам, когда вызывается событие. Однако если нельзя использовать решение 0, то обычно также невозможно определить "InUse(используемый)" изнутри объекта слушателя. И с учетом того, что вы читаете эту статью, вы, скорее всего, столкнетесь с одним из этих случаев.
Но данное решение уже имеет существенный недостаток по сравнению с решением 0: если событие вообще не возбуждается, то происходит утечка объектов-слушателей. Представьте, что большое количество объектов регистрирует статическое событие "SettingsChanged (параметры измерены)" – все эти объекты нельзя будет удалить до тех пор, пока параметр не изменится – что может вообще не случиться за все время жизни программы.

Плюсы
Отсутствуют.

Минусы
Утечки, если событие вообще не возбуждается; обычно "InUse" трудно определить.

Решение 2: Обертка со слабой ссылкой

Это решение почти совпадает с предыдущим, за исключением того, что код обработки события перемещен в класс-обертку, передающий вызовы экземпляру слушателя, на который ссылается слабая ссылка. Эта слабая ссылка позволяет легко определить, существует ли еще слушатель.


EventWrapper ew;
void RegisterEvent()
{
    ew = new EventWrapper(eventSource, this);
}
void OnEvent(object sender, EventArgs e)
{
    ...
}
sealed class EventWrapper
{
    SourceObject eventSource;
    WeakReference wr;
    public EventWrapper(SourceObject eventSource,
                        ListenerObject obj) {
        this.eventSource = eventSource;
        this.wr = new WeakReference(obj);
        eventSource.Event += OnEvent;
   }
   void OnEvent(object sender, EventArgs e)
   {
        ListenerObject obj = (ListenerObject)wr.Target;
        if (obj != null)
            obj.OnEvent(sender, e);
        else
            Deregister();
    }
    public void Deregister()
    {
        eventSource.Event -= OnEvent;
    }
}

Плюсы
Позволяет очистку памяти от объекта слушателя.

Минусы
Утечка экземпляра обертки, если событие вообще не возбуждается; написание класса-обертки для каждого обработчика события создает много повторяющегося кода.

Решение 3: Снятие с регистрации в сборщике мусора

Имейте в виду, что мы сохранили ссылку на EventWrapper и имеем открытый метод Deregister. Мы можем добавить сборщик мусора в слушатель и использовать его, чтобы снять с регистрации из события.
~ListenerObject() {
    ew.Deregister();
}
Это должно предотвратить утечку памяти, но имеет свою цену: финализируемые объекты накладны для программы чистки памяти. Если нет ссылок на объект слушателя (не считая слабой ссылки), он переживет первую сборку мусора (и перейдет в более высокое поколение), запустит финализатор, и затем будет удален только после следующей сборки мусора (нового поколения).

Финализаторы выполняются в потоке финализатора; это может вызвать проблемы, если регистрация/снятие с регистрации событий на источнике события не поточно-ориентирована. Помните: стандартные события, генерируемые компилятором C#, не поточно-ориентированы!

Плюсы
Позволяет очистку памяти от объекта слушателя; нет утечки экземпляров обертки.

Минусы
Финализатор задерживает удаление слушателя; требует поточно-ориентированного источника события; много повторяющегося кода.

Решение 4: Многократно используемая обертка

Загруженный код содержит многократно используемую версию класса-обертки. Он работает путем вычисления лямбда-выражений для частей кода, которые нужно приспособить для конкретного использования: регистрация обработчика события, снятие с регистрации обработчика событий, передача события закрытому методу.
eventWrapper = WeakEventHandler.Register(
    eventSource,
    (s, eh) => s.Event += eh, // регистрирующий код
    (s, eh) => s.Event -= eh, // код, снимающий с регистрации
    this, // слушатель события
    (me, sender, args) => me.OnEvent(sender, args) // перенаправляющий код
);

Возвращенный eventWrapper предоставляет единственный открытый метод: Deregister (снять с регистрации). Нужно быть осторожным с лямбда-выражениями, так как они собираются в делегаты, которые могут содержать дополнительные ссылки на объект. Вот почему слушатель события передается обратно как "me". Если бы мы написали (me, sender, args) => this.OnEvent(sender, args), то лямбда-выражение захватило бы переменную "this", вызвав генерацию замкнутого объекта. Так как WeakEventHandler хранит ссылку на перенаправляемый делегат, это привело бы к созданию жесткой ссылки из обертки на слушатель. К счастью, можно проверить, захватывает ли делегат какие-либо переменные: компилятор создаст метод экземпляра для лямбда-выражений, захватывающих переменные, и статический метод для лямбда-выражений, не захватывающих переменные. WeakEventHandler проверяет это при помощи Delegate.Method.IsStatic, и генерирует исключение в случае его неправильного использования.

Этот подход допускает многократное использование, но все же требует класс-обертку для каждого типа делегата. Хотя можно значительно продвинуться путем использования System.EventHandler и System.EventHandler<T>, вы можете захотеть автоматизировать работу с множеством разных типов делегатов. Это можно сделать во время компиляции при помощи генерации кода, или во время выполнения с помощью System.Reflection.Emit.

Плюсы
Позволяет очистку памяти от объекта слушателя; не слишком много лишнего кода.

Минусы
Происходит утечка экземпляра обертки, если событие вообще не возбуждается.

Решение 5: Менеджер слабых событий

WPF имеет встроенную поддержку для слабых событий на стороне слушателя с использованием класса WeakEventManager. Он работает подобно предыдущим решениям с оберткой, за исключением того, что единственный экземпляр WeakEventManager служит в качестве обертки между несколькими передатчиками и несколькими слушателями. Поэтому единственный экземпляр WeakEventManager может предотвратить утечку, если событие вообще не вызывается: регистрация другого события в WeakEventManager может запустить удаление старых событий. Эти удаления программируются при помощи диспетчера WPF, они происходят только в потоках, выполняющих цикл обработки сообщений WPF.

Также WeakEventManager имеет ограничение, отсутствующее у предыдущих решений: он требует, чтобы параметры передатчика были заданы правильно. Если он используется для прикрепления к button.Click, будут переданы только события с sender==button. Некоторые реализации событий могут просто прикреплять обработчики к другому событию:
public event EventHandler Event {
    add { anotherObject.Event += value; }
    remove { anotherObject.Event -= value; }
}
Такие события нельзя использовать с WeakEventManager.

Есть один класс WeakEventManager в расчете на событие, каждый с экземпляром в расчете на поток. Рекомендуемый шаблон для определения этих событий содержит много шаблонного кода: смотрите "Шаблоны слабых событий" в MSDN [^].
К счастью, это можно упростить при помощи обобщенных шаблонов:
public sealed class ButtonClickEventManager
    : WeakEventManagerBase<ButtonClickEventManager, Button>
{
    protected override void StartListening(Button source)
    {
        source.Click += DeliverEvent;
    }

    protected override void StopListening(Button source)
    {
        source.Click -= DeliverEvent;
    }
}
DeliverEvent принимает (object, EventArgs), тогда как событие Click (щелчок мышью) предоставляет (object, RoutedEventArgs). Несмотря на то что отсутствует преобразование между типами делегатов, C# поддерживает создании делегатов из групп методов [^].

Плюсы
Позволяет очистку памяти от объекта слушателя; не происходит утечка экземпляров обертки.

Минусы
Связан с диспетчером WPF, нельзя свободно использовать в потоках без пользовательского интерфейса.


Часть 2: Слабые события стороны источника

Рассмотрим способы реализации слабых событий путем изменения источника событий.
Все они имеют общее преимущество над слабыми событиями стороны слушателя: регистрацию/снятие с регистрации обработчиков легко сделать поточно-ориентированными.

Решение 0: Интерфейс

WeakEventManager также стоит упомянуть в данном разделе: как обертка, он прикрепляется ("сторона слушателя") к нормальным событиям C#, но также предоставляет ("сторона источника") слабое событие клиентам.
В WeakEventManager это интерфейс IWeakEventListener. Слушающий объект реализует интерфейс, а источник имеет слабую ссылку на слушателя и вызывает метод интерфейса.


 
Плюсы
Простой и эффективный.

Минусы
Когда слушатель обрабатывает несколько событий, вы в итоге получаете множество условий в методе HandleWeakEvent, из которых нужно отбирать тип события и источник события.

Решение 1: Слабая ссылка на делегат

Это другой подход к слабым событиям, используемый в WPF: CommandManager.InvalidateRequery выглядит как нормальное событие .NET, но не является таковым. Он хранит только слабую ссылку на делегат, поэтому регистрация в то статическое событие не вызывает утечек памяти.


 
Это простое решение, но потребитель события с легкостью может забыть о нем и ошибиться:
CommandManager.InvalidateRequery += OnInvalidateRequery;

//или

CommandManager.InvalidateRequery += new EventHandler(OnInvalidateRequery);

Проблема в том, что CommandManager хранит только слабую ссылку на делегат, а слушатель вообще не хранит ссылки на него. Во время следующего запуска GC делегат будет удален, и OnInvalidateRequery больше не будет вызываться, даже если объект слушателя все еще используется. Чтобы гарантировать достаточно долгое существование делегата, слушатель обязан хранить ссылку на него.

 

class Listener {
    EventHandler strongReferenceToDelegate;
    public void RegisterForEvent()
    {
        strongReferenceToDelegate = new EventHandler(OnInvalidateRequery);
        CommandManager.InvalidateRequery += strongReferenceToDelegate;
    }
    void OnInvalidateRequery(...) {...}
}
WeakReferenceToDelegate в загруженном исходном коде показывает пример реализации события, являющегося поточно-ориентированным, и очищает список обработчиков, когда добавляется другой обработчик.

Плюсы
Отсутствует утечка экземпляров делегатов.

Минусы
Легко ошибиться: забывание жесткой ссылки на делегат приводит к тому, что события возбуждаются только до следующей сборки мусора. Это может вызывать трудно обнаруживаемые ошибки.

Решение 2: объект + механизм переадресации данных

В то время как решение 0 было основано на WeakEventManager, это решение основано на обертке WeakEventHandler: регистрируется пара object (объект),ForwarderDelegate.

 

eventSource.AddHandler(this,
    (me, sender, args) => ((ListenerObject)me).OnEvent(sender, args));

Плюсы
Простое и эффективное.

Минусы
Необычная сигнатура для регистрации событий; переадресация лямбда-выражений требует преобразования типов.

Решение 3: Разумное слабое событие

SmartWeakEvent в загруженном исходном коде предоставляет событие, выглядящее как нормальное событие .NET, но хранящее слабые ссылки на слушатель события. Он не страдает от проблемы "должен хранить ссылку на делегат".

void RegisterEvent()
{
    eventSource.Event += OnEvent;
}
void OnEvent(object sender, EventArgs e)
{
    ...
}

Определение события:

SmartWeakEvent<EventHandler> _event
   = new SmartWeakEvent<EventHandler>();

public event EventHandler Event {
    add { _event.Add(value); }
    remove { _event.Remove(value); }
}

public void RaiseEvent()
{
    _event.Raise(this, EventArgs.Empty);
}

Как это работает? Путем использования свойств Delegate.Target и Delegate.Method каждый делегат разделяется на цели (хранится как слабая ссылка) и MethodInfo. Когда возбуждается событие, метод вызывается при помощи Отражения.

 

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

int localVariable = 42;
eventSource.Event += delegate { Console.WriteLine(localVariable); };

При этом целевой объект делегата - замкнутое выражение, которое может быть сразу же удалено, так как на него нет других ссылок. Однако SmartWeakEvent может обнаружить эту ситуацию и сгенерирует исключение, поэтому устранять проблемы будет нетрудно, так как обработчик события снимается с регистрации раньше, чем это должно было произойти.

if (d.Method.DeclaringType.GetCustomAttributes(
  typeof(CompilerGeneratedAttribute), false).Length != 0)
    throw new ArgumentException(...);

 

Плюсы
Выглядит как настоящее слабое событие; почти нет лишнего кода.

Минусы
Вызов с использованием отражения медленный; не работает в неполном доверии, так как использует отражение на закрытых методах.

Решение 4: Быстрое разумное слабое событие

Функциональность и использование тождественны SmartWeakEvent, но производительность намного выше.
Здесь показаны результаты программы оценки производительности события с двумя зарегистрированными делегатами (один метод экземпляра и один статический метод):

нормальное (сильное) событие...   16948785 вызовов в секунду
разумное слабое событие...           91960 вызовов в секунду
быстрое разумное слабое событие...    4901840 вызовов в секунду

Как это работает? Отражение больше не используется для вызова метода. Вместо этого компилируется переадресующий метод (подобно "переадресующему коду" в предыдущих решениях) во время выполнения при помощи System.Reflection.Emit.DynamicMethod.

Плюсы
Выглядит как настоящее слабое событие; почти нет лишнего кода.

Минусы
Не работает в неполном доверии, так как использует отражение на закрытых методах.

Советы

•    Для всего, что выполняется в потоке пользовательского интерфейса в приложениях WPF (например, специальные элементы управления, прикрепляющие события к объектам-моделям) – используйте WeakEventManager.
•    Если нужно предоставить слабое событие – используйте FastSmartWeakEvent.
•    Если нужно потреблять событие – используйте WeakEventHandler.