Pull to refresh

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

Reading time11 min
Views78K
Original author: Daniel Grunwald

От переводчика


Недавно в проекте, где я работаю, мы столкнулись с проблемой утечки памяти. Прочитав множество статей — от рассказов по управлению памятью в .NET до практических рекомендаций по правильному освобождению ресурсов, я в том числе наткнулся на статью, в которой рассказывается, как корректно использовать события. Ее перевод я и хочу вам представить.
Это топик из песочницы, с которым я попал сюда на Хабр.


Введение


При использовании обычных событий в С# подписка на событие создает ссылку из объекта, содержащего событие, на объект-подписчик.



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

Существует множество подходов для решения этой проблемы. В этой статье будет рассмотрена часть их них, обсуждены преимущества и недостатки. Я разделил все подходы на две части: в одной мы будем подразумевать, что источник события — это уже существующий класс с обычным событием; в другой будем менять сам исходный объект, чтобы посмотреть на работу различных способов.

Что же представляют собой события?


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

EventHandler eh = Method1;
eh += Method2;

Что же тогда представляют собой события? Они похожи на свойства: внутри они содержат поле делегата, доступ напрямую к которому запрещен. Публичное поле делегата (или публичное свойство) может привести к тому, что список обработчиков события может быть очищен другим объектом, или что событие будет вызвано извне — в то время как мы хотим вызывать его только из исходного объекта.

Свойства представляют собой пару get/set методов. События же — это пара методов add/remove.

public event EventHandler MyEvent {
   add { ... }
   remove { ... }
}

Публичными должны быть только методы добавления и удаления обработчиков. В таком случае остальные классы не смогут получить список обработчиков, не смогут очистить его, или вызвать событие.

Иногда короткий синтаксис объявления событий в C# вводит в заблуждение:

public event EventHandler MyEvent;

На самом деле такая запись при компиляции разворачивается в:

private EventHandler _MyEvent; // закрытое поле обработчика
public event EventHandler MyEvent {
  add { lock(this) { _MyEvent += value; } }
  remove { lock(this) { _MyEvent -= value; } }
}

В С# события по умолчанию реализуются с помощью синхронизации, используя для нее объекты, в которых они объявлены. В этом можно убедиться с помощью дизассемблера — методы add и remove помечены атрибутом [MethodImpl(MethodImplOptions.Synchronized)], эквивалентным синхронизации с использованием текущего экземпляра объекта.

Подписка и отписка от события являются потокобезопасными операциями. Однако потокобезопасный вызов события оставлен на усмотрение разработчиков, и весьма часто они это делают некорректно:

if (MyEvent != null)
   MyEvent(this, EventArgs.Empty);
   // может быть вызвано исключение NullReferenceException в том случае,
   // если обработчик был удален из списка уже после проверки из другого потока

Еще один часто встречающийся вариант заключается в предварительном сохранении делегата в локальной переменной.

EventHandler eh = MyEvent;
if (eh != null) eh(this, EventArgs.Empty);

Является ли этот код потокобезопасным? Когда как. Согласно модели памяти, описанной в спецификации языка C#, этот пример не потокобезопасен: JIT-компилятор, оптимизируя код, может удалять локальные переменные. Однако, среда исполнения .NET (начиная с версии 2.0) имеет более сильную модель памяти, и в ней данный код является потокобезопасным.

Корректным решением, согласно спецификации ECMA, является присвоение локальной переменной в блоке lock(this) или использование volatile-поля для сохранения ссылки на делегат.

EventHandler eh;
lock (this) { eh = MyEvent; }
if (eh != null) eh(this, EventArgs.Empty);

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


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

Решение 0: Просто отпишитесь


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

Просто и эффективно, то, что вы и должны использовать по возможности. Однако не всегда возможно обеспечить вызов метода DeregisterEvent после того, как объект перестает использоваться. Вы можете попробовать использовать метод Dispose, несмотря на то, что он обычно используется для неуправляемых ресурсов. Финализатор в данном случае не сработает: сборщик мусора не будет вызывать его, потому что исходный объект ссылается на нашего подписчика!

Преимущества
Просто в использовании, если использование объекта подразумевает вызов Dispose.

Недостатки
Явное управление памятью — штука сложная. Метод Dispose могут и забыть вызвать.

Решение 1: Отпишитесь от события после его вызова


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

void OnEvent(object sender, EventArgs e)
{
    if (!InUse) {
        eventSource.Event -= OnEvent;
        return;
    }
    ...
}

Теперь нам не нужно заботиться, сообщит ли нам кто-нибудь о том, что объект-подписчик больше не используется. Мы сами это проверяем после вызова события. Однако если мы не можем использовать решение 0, то тогда, как правило, невозможно определить из самого объекта, используется ли он. И учитывая тот факт, что вы читаете эту статью, вы, вероятно, столкнулись с одним из этих случаев.

Стоит отметить, что это решение уже проигрывает решению 0: если событие не будет вызвано, тогда мы получим утечку памяти, занимаемой подписчиком. Представьте, что множество объектов подписались на статическое событие SettingsChanged. Тогда все эти объекты не будут убраны сборщиком мусора до тех пор, пока не сработает событие — а этого может никогда не случиться.

Преимущества
Нет.

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

Решение 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, при наличии множества разных типов делегатов вам захочется автоматизировать все это. Для этого можно использовать кодогенерацию или типы пространства System.Reflection.Emit.

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

Недостатки
Утечка памяти, занимаемой оберткой, в случае, если событие ни разу не сработает.

Решение 5: WeakEventManager


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

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

Такие события не могут быть использованы в WeakEventManager'е.

Один WeakEventManager на событие, по одному экземпляру на поток. Рекомендуемый шаблон для определения таких событий с заготовками кода можно посмотреть в статье «Шаблоны WeakEvent» в MSDN.

К счастью, мы можем упростить это с помощью обобщений (Generics):
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 стоит упомянуть и в этой части. В качестве обертки он присоединяется к обычным событиям (сторона подписчика), но также может предоставлять слабые события клиентам (сторона исходного объекта).

Существует интерфейс IWeakEventListener. На подписчиков, реализующих этот интерфейс, исходный объект будет ссылаться с помощью слабой ссылки, и вызывать реализованный метод ReceiveWeakEvent.

Преимущества
Просто и эффективно.

Недостатки
Когда объект подписан на множество событий, в реализации ReceiveWeakEvent вам придется писать кучу проверок на типы события и источника.

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


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



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

// или

CommandManager.InvalidateRequery += new EventHandler(OnInvalidateRequery);

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



class Listener {
    EventHandler strongReferenceToDelegate;
    public void RegisterForEvent()
    {
        strongReferenceToDelegate = new EventHandler(OnInvalidateRequery);
        CommandManager.InvalidateRequery += strongReferenceToDelegate;
    }
    void OnInvalidateRequery(...) {...}
}

Преимущества
Освобождается память, занимаемая делегатами.

Недостатки
Если забыть проставить «сильную» ссылку на делегат, то событие будет срабатывать до первой сборки мусора. Это может вызвать затруднение при поиске ошибок.

Решение 2: Объект + Forwarder


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


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

Преимущества
Просто и эффективно.

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

Решение 3: SmartWeakEvent


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: FastSmartWeakEvent


Функциональность и способ использования аналогичны решению со SmartWeakEvent, но производительность значительно выше.

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

Обычное («сильное») событие… 16 948 785 вызовов в секунду
Smart weak event… 91 960 вызовов в секунду
Fast smart weak event… 4 901 840 вызовов в секунду

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

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

Недостатки
Не работает при наличии частичного разрешения, поскольку выполняет приватные методы.

Предложения


  • Используйте WeakEventManager для всего, что работает в потоке графического интерфейса в WPF-приложениях (например, для пользовательских контролов, которые подписываются на события объектов модели)
  • Используйте FastSmartWeakEvent, если хотите предоставить слабое событие
  • Используйте WeakEventHandler, если хотите подписаться на событие
Tags:
Hubs:
+58
Comments18

Articles