среда, 23 сентября 2015 г.

Элегантная реализация «слабых событий»

Камрад @v2_matveev указал на прекрасную реализацию слабых событий в коде Roslyn-а.

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

Решается эта проблема разными способами. Самый правильный способ – не использовать события в синглтонах. Второй способ – отписываться от событий вовремя. Третий способ – хипстерский, воспользоваться той или иной реализацией Weak Event Pattern-а, например, с помощью WeakEventManager или чего-то подобного. Но раз уж зашла речь за слыбые события, то их можно реализовать по разному и далее показана самая изящная реализация.

Вначале демонстрируем проблему: добавляем синглтон с событиями и короткоживущий объект, который на них подписывается:

 

class LongLivedEventProvider
{
   
public static readonly LongLivedEventProvider Instance =
 
       
new LongLivedEventProvider
();
   
public event EventHandler<EventArgs
> Event;

   
public void
RaiseEvent()
    {
        Event
?.Invoke(this, EventArgs.
Empty);
    }
}

class ShortLivedEventHandler
{
   
public void
Subscribe()
    {
       
EventHandler<EventArgs> handler = (sender, args) =>
EventHandler();
       
// Отсутствие отписки от события приведет к утеччке памяти
        LongLivedEventProvider.Instance.Event +=
handler;
    }
   
private void
EventHandler()
    {
       
Console.WriteLine("Обрабатываем событие!");
    }
}

Следующий пример показывает проблему в действии:

var shortLived = new ShortLivedEventHandler();
// Подписываемся на событие обычным образом!
shortLived.Subscribe();
// Создаем слабую ссылку, чтобы отслеживать время жизни короткоживущего объекта
var firstWeakReference = new WeakReference(shortLived, false
);

Console.WriteLine("Зажигаем событие");
LongLivedEventProvider.Instance.
RaiseEvent();

Console.WriteLine("А жив ли обджект? " + firstWeakReference.
IsAlive);

Console.WriteLine("Собираем мусор");
GC.
Collect();

Console.WriteLine("А жив ли обджек? " + firstWeakReference.IsAlive);

Запускаем код в релизном режиме и без отладчика (в противном случае время жизни объекта shortLived будет продлено и пример не покажет результата). И получаем:

Зажигаем событие
Обрабатываем событие
А жив ли обджект? True
Собираем мусор
А жив ли обджект? True

Наш объект выжил, что является ожидаемым поведением

ПРИМЕЧАНИЕ
Если вы, вдруг, подумали, что тут нужно поставить скобочки или каким-то еще способом указать среде исполнения, что ссылка shortLived должна быть недостижима, то, поздравляю, вы открыли для себя что-то новое. Делать этого не нужно. Подробности в заметке “О сборке мусора и достижимости объектов”.

Для реализация своих «слабых подписчиков», нам нужен следующий класс:

internal static class WeakEventHandler<TArgs>
{
   
public static EventHandler<TArgs> Create<THandler
>(
       
THandler handler, Action<THandler, object, TArgs
> invoker)
       
where THandler : class
    {
       
var weakEventHandler = new WeakReference<THandler
>(handler);

       
return (sender, args) =>
        {
           
THandler
thandler;
           
if (weakEventHandler.TryGetTarget(out thandler))
            {
                invoker(thandler, sender, args);
            }
        };
    }
}

И метод SubscribeWeakly в классе ShortLivedEventHandler:

public void SubscribeWeakly()
{
   
// Не используем 'this' в лямбде.
    // Неявно захваченный 'this' будет строгой ссылкой
    var handler = WeakEventHandler<EventArgs>.
Create(
       
this, (@this, o, args) => @this.
EventHandler());

   
// Теперь можно и не отписываться от события.
    // Во всяком случае текущий объект будет собыран сборщиком
    LongLivedEventProvider.Instance.Event += handler;
}

Теперь, если мы будем подписываться с помощью SubscribeWeakly, то синглтон уже не будет держать корневую ссылку на короткоживующий объект, что позволит последнему покинуть этот мир своевременно:

var shortLived = new ShortLivedEventHandler();
var firstWeakReference = new WeakReference(shortLived, false
);

shortLived
.SubscribeWeakly();
LongLivedEventProvider.Instance.
RaiseEvent();

GC.Collect();
// Теперь свойство IsAlive вернет false!
Console.WriteLine("А жив ли обджект? " + firstWeakReference.IsAlive);

Идея этого решения в следующем: метод WeakEventHandler.Create заменяет экземплярный обработчик события статическим с явным протаскиванием параметра экземпляра. Обратите внимание, что лямбда в методе SubscribeWeakly не захватывает this, а вызывает экземплярный метод HandleEvent лишь через параметр @this, который был получен у слабой ссылки.

Несмотря на то, что этот подход устраняет проблему продления времени жизни объектов ShortLivedEventHandler, он не решает проблему увеличения числа подписчиков в глобальном объекте. LongLivedEventProvider все еще будет хранить все обработчики событий, просто делать они ничего не будут. Что также является утечкой памяти. И при «зажигании» события, долгоживущему объекту все равно придется перебирать и вызвать все обработчики, что также скажется на производительности, если приложение работает 24/7.

Это я все к тому, что этот подход все равно не применим для синглтонов, которые по-прежнему НЕ ДОЛЖНЫ содержать событий, но может применяться в подходах со среднеживущими объектами.

Дополнительные ссылки

13 комментариев:

  1. >> при «зажигании» события, долгоживущему объекту все равно придется перебирать и вызвать все обработчики, что также скажется на производительности, если приложение работает 24/7.

    Навскидку можно проблему решить так:
    internal static class WeakEventHandler
    {
    public static EventHandler Create(
    THandler handler, Action invoker,
    Action> unsubscribe) // доп. параметр
    where THandler : class
    {
    var weakEventHandler = new WeakReference(handler);

    EventHandler result = null;
    result = (sender, args) =>
    {
    THandler thandler;
    if (weakEventHandler.TryGetTarget(out thandler))
    {
    invoker(thandler, sender, args);
    }
    else
    {
    unsubscribe(result); //!!!!!!!!!!!!!!
    }
    };
    return result;
    }
    }
    var handler = WeakEventHandler.Create(this,
    (@this, o, args) => @this.EventHandler(),
    h => LongLivedEventProvider.Instance.Event -= h);
    LongLivedEventProvider.Instance.Event += handler;

    ОтветитьУдалить
    Ответы
    1. Да, вполне себе решение.

      Удалить
    2. Рабочее, но есть подводный камень. Лямбда для отписки обычно делает capture долгоживущего event provider'а. В данном случае это синглтон, и такой опасности нет, но чаще это все же какой-то сервис или что-то подобное. Так вот, если вместе с лямбдой отписки в одном scope присутствует другая лямбда, которая делает capture того короткоживущего, что мы подписываем - получится implicit lambda capture (я, правда, не в курсе, может быть Roslyn делает несколько объектов capture уже, и эта проблема решена?), и наша подписка станет вполне себе сильной, а не weak. Еще больше проблему усугубляет, что это _крайне_ просто сделать случайно, и не заметить. А найти потом через год в профайлере, что память отжирается, и не освобождается.

      Удалить
  2. Полезная информация. Спасибо. Но, имхо, "честнее" и правильней отписываться от событий.

    ОтветитьУдалить
  3. К сожалению данный метод не работает если код скомпилировать в VS 2013
    firstWeakReference.IsAlive возвращает True

    ОтветитьУдалить
    Ответы
    1. Только что проверил. Все работае.

      Напомню, что нужно запускать в Release-е и через Ctrl + F5 (без отладчика - Debug -> Start Without Debugger).

      И тогда все заработает.

      Удалить
    2. Запуск без отладчика помог. Спасибо.

      Удалить
    3. Наличие отладчика продлевает время жизни переменных до конца метода (как минимум), чтобы не было сюрпризов, что остановился на последней строке, а локальная переменная уже дохлая.

      Удалить
  4. Получается, не очевидно.
    Т.е. выполнение обработчика зависит от сборщика мусора.

    LongLivedEventProvider.Instance.RaiseEvent(); // handler отработает
    GC.Collect();
    LongLivedEventProvider.Instance.RaiseEvent(); // ничего не произойдет

    ОтветитьУдалить
  5. Но ведь в этом и смысл слабого события. Чтобы ничего не происходило, если подписанный объект недоступен и собран сборщиком. Если такое поведение не нужно, то просто используйте обычные события:))

    ОтветитьУдалить
    Ответы
    1. Я думаю имелось в виду то, что даже после зануления ссылки на ShortLivedEventHandler события все равно будут хэндлится, пока не отработает GC. А т.к. вместо ручного вызова GC.Collect лучше полагаться на CLR, то сработает ли хэндлер или нет зависит от того, успеет ли сработать GC до вызова RaiseEvent...

      Удалить
  6. С этим подходом есть небольшая проблема. Есть люди, которые guidelines то ли не читают, то ли сознательно по каким-то причинам не следуют, и объявляют события не типа EventHandler where T : EventArgs. Классический пример из кода Майкрософт - INotifyPropertyChanged.PropertyChanged. А также весь WPF. С такими событиями приведенный подход работать, к сожалению, не будет.

    ОтветитьУдалить
    Ответы
    1. Я не совсем понял, в каком именно месте INotifyPropertyChanged.PropertyChanged наршуает гайдлайны? Там используется свой собственный делегат PropertyChangedEventHandler, но PropertyChangedEventArgs наследуют от EventArgs.

      Вообще-то ведь нет гайдлайнов, которые бы говорили, что НУЖНО обязательно использовать класс EventHandler. Каждый вправе объявлять свой тип делегатов, если они следуют соглашению: первый параметр object sender, а второй - наследник от EventArgs-ов.

      Удалить