Избавляемся от утечек памяти в WPF

    imageВ DevExpress мы тратим много сил на бизнес компоненты для WPF и Silverlight. У нас есть своя линейка контролов, в список которых недавно вошел DXPivotGrid – замена инструмента PivotTable из Excel. В процессе разработки новых компонентов, мы стараемся по-максимуму использовать существующий код. Например, базовые классы от версии PivotGrid для WinForms. Часто это рождает проблемы, с которыми ты не сталкивался, разрабатывая под .NET 2.0. Когда я писал PivotGrid для WPF, мне пришлось решить проблемы с утечками памяти из-за подписки (точнее, «неотписки») на события.


    Компания Microsoft ввела понятие weak event'а в .NET 3.0: это вариация стандартных событий, которые не держат прямую ссылку на обработчик события. С другой стороны, обычные обработчики – это две ссылки: одна на объект, а вторая – на метод внутри объекта. Ничего нового, но есть нюанс.

    Обработчик события не будет обработан сборщиком мусора, пока он не отпишется от события. Учитывая, что в WPF не применяется интерфейс IDisposable, это превращается в большую проблему.

    Как решение, Microsoft предлагает слабо-связанные обработчики событий (weak events — «обработчики слабых событий» в переводе Microsoft). Сборщик мусора может обработать объекты, подписывающиеся на такие события, даже если подписка все ещё существует.

    Есть два способа сделать слабое событие: использовать WeakEventManager или RoutedEvent.

    WeakEventManager


    Класс WeakEventManager позволяет превратить существующее событие в слабое событие. В моем проекте это было нужно для подписки на события из ядра продукта, которое должно быть совместимо с .NET 2.0.

    Слабые события создаются при помощи двух классов: WeakEventManager (диспетчер) и WeakEventListener (прослушиватель). Диспетчер события подписывается на него и передает вызовы прослушивателям, т.е. является прослойкой между источником и получателем, разрывая жесткую связь.

    Это шаблон диспетчера событий.

    public class MyEventManager : WeakEventManager {
        static MyEventManager CurrentManager {
            get {
                // Создание статического диспетчера событий
                Type managerType = typeof(MyEventManager);
                MyEventManager currentManager =
                    (MyEventManager)WeakEventManager.GetCurrentManager(managerType);
                if(currentManager == null) {
                    currentManager = new MyEventManager();
                    WeakEventManager.SetCurrentManager(managerType, currentManager);
                }
                return currentManager;
            }
        }
    
        MyEventManager() { }
    
        // Измените "T" на действительный тип источника событий
        public static void AddListener(T source, IWeakEventListener listener) {
            CurrentManager.ProtectedAddListener(source, listener);
        }
        // Измените "T" на действительный тип источника событий
        public static void RemoveListener(T source, IWeakEventListener listener) {
            CurrentManager.ProtectedRemoveListener(source, listener);
        }
        protected override void StartListening(object source) {
            // Подпишитесь на событие
            // Например, ((T)source).Event += EventHandler;
        }
        protected override void StopListening(object source) {
            // Отпишитесь от события
            // Например, ((T)source).Event -= EventHandler;
        }
        // Обработчик события – измените тип аргумента
        // на корректный для вашего события
        void EventHandler(object sender, EventArgs e) {
            base.DeliverEvent(sender, e);
        }
    }


    А это сниппет с шаблоном для Visual Studio: https://gist.github.com/777559. Вешается на команду «wem».

    Этот шаблон можно использовать для любого диспетчера слабых событий. Вы должны изменить имя класса источника, способ подписки и скорректировать тип параметров в методе EventHandler.

    Каждый объект, желающий подписаться на слабое событие, должен реализовать интерфейс IWeakEventListener. Этот интерфейс содержит единственный метод ReceiveWeakEvent. Вы должны проверить тип диспетчера событий, вызвать обработчик и вернуть true. Если вы не можете определить тип диспетчера, вы должны вернуть false. В этом случае будет вызвано исключение System.ExecutionEngineException с несколько непонятным текстом причины ошибки. По нему становится ясно, что в диспетчерах или прослушивателях есть ошибка.

    Вот шаблон реализации интерфейса IWeakEventListener:

    class MyEventListener : IWeakEventListener {
        bool IWeakEventListener.ReceiveWeakEvent(Type managerType, object sender, EventArgs e) {
            if(managerType == typeof(MyEventManager)) {
                // Обработайте событие
                return true;    // Уведомление, что событие корректно обработано
            }
            return false;   // Что-то пошло не так
        }
    }


    Плюсы и минусы

    • Этот тип слабых событий вызывается практически мгновенно
    • Вы можете определить есть ли прослушиватели и не вызывать событие, если их нет. Для меня это было полезно в событиях, которые нужно вызывать очень часто или для событий с тяжелыми аргументами.
    • Могут использоваться для не UIElements. Полезно, если вы хотите использовать старый код из WPF.
    • Очень громоздки в создании – каждое событие требует своего диспетчера.


    Routed Events (Перенаправленные события)


    Перенаправленные события – это инфраструктура для событий, обрабатываемых в XAML (например, в EventTrigger) или проходящих по визуальному дереву.

    Их главные преимущества:

    • Это слабые события.
    • Они могут вызывать обработчики на нескольких уровнях логического дерева.


    В MSDN есть хорошая статья про них: Routed Events Overview и поэтому я не хочу повторять их здесь. Но упомяну два их основных недостатка.

    Тяжелый вызов и нет информации о количестве подписчиков

    Это часть метода UIElement.RaiseEventImpl, вызывающего перенаправленное событие:

    internal static void RaiseEventImpl(DependencyObject sender, RoutedEventArgs args)
    {
        EventRoute route = EventRouteFactory.FetchObject(args.RoutedEvent);
        if (TraceRoutedEvent.IsEnabled)
        {
            TraceRoutedEvent.Trace(TraceEventType.Start, TraceRoutedEvent.RaiseEvent, new object[] { args.RoutedEvent, sender, args, args.Handled });
        }
        try
        {
            args.Source = sender;
            BuildRouteHelper(sender, route, args);
            route.InvokeHandlers(sender, args);
            args.Source = args.OriginalSource;
        }
        finally
        {
            if (TraceRoutedEvent.IsEnabled)
            {
                TraceRoutedEvent.Trace(TraceEventType.Stop, TraceRoutedEvent.RaiseEvent, new object[] { args.RoutedEvent, sender, args, args.Handled });
            }
        }
        EventRouteFactory.RecycleObject(route);
    }


    Выглядит нормально, пока не взглянуть внутрь методов BuildRouteHelper и InvokeHandlers, каждый из которых длиннее 100 строк. И вся эта сложность для вызова единственного события. Такая сложность делает этот подход неприменимым для часто вызываемых событий.

    Они могут быть добавлены только в наследников класса UIElement.

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

    В итоге


    Если вы не ограничены старым кодом и вызовы событий у вас не многочислены, то используйте RoutedEvents. Если вызовов много или у вас есть общий с .NET 2.0 код, то придется писать WeakEventManager для каждого. Громоздко, но придется.

    Оба эти способа будут работать в MediumTrust. Если это требование для вас не важно, то ждите решения №3 в следующей серии.
    Developer Soft
    74,43
    Компания
    Поделиться публикацией

    Комментарии 35

      +4
      Спасибо, сам недавно фиксил баги с мемори-ликами в чужом коде. Вот, кстати, неплохая статья, с обзором основных моментов.
        +3
        Спасибо, хороший списочек.
        Для поиска ликов я использую .NET Memory Profiler. В нем видно, кто держит ссылку на объект. Из этого можно понять как от лика избавиться. Обычно это проще и быстрее, чем перебирать возможные варианты.
          +1
          Да, есть такой инструмент, платный правда.
          Я использовал CLRProfiler и SOS.dll.
          А список — чтобы понимать в чем принципиально может быть проблема.
        0
        А почему в WPF не применяется IDisposable? не знаете?
          +1
          Могу только догадываться. Думаю, что мелкие хотели как лучше, чтобы никому не приходилось вызывать Dispose ни у кого. Например, при изменении деревьев.
            0
            IDisposable задумывался как интерфейс для детерминистического освобождения неуправляемых ресурсов, таких как file-, mutex-handle, sockets.

            WPF — это вроде бы целиком managed library с COM-оберткой на DirectX 3D.
              +1
              Но WPF не ограничивает использование файлов, сокетов или просто подписок на события (мой случай). Соответственно, если у вас есть элемент, который их использует, то вам нужно найти способ узнать, когда ресурсы можно освободить.
                +1
                Вообще IDisposable автор поста упомянул не к месту. IDisposable нужен тогда, когда есть необходимость управлять временем «жизни», или работоспособности, объекта, не важно оборачивает он неуправляемый ресурс, или нет. С событиями проблема в том, что время существования в памяти одного объекта попадает в нежелательную зависимость от времени существования другого. Тут IDisposable никак не поможет. Единственный выход без использования слабых ссылок — управлять явно подписками на события и заблаговременно отписываться. Но сложность контроля подписок сопоставима по сложности с управлением памятью в неуправляемых средах исполнения. Думаю те, кто писал сложные системы, между частями которых передавались блоки памяти, понимают о чем я.
                  +2
                  Тут IDisposable никак не поможет


                  Отписывание от событий зачастую происходит в Dispose
                    0
                    Ну это уже смотря какие паттерны использовать. Мне, например, ни разу не приходилось отписываться от событий из реализации Dispose, я предпочитаю классы, которые реализуют IDisposeable делать пассивными.
                      0
                      Что подразумевается под «пассивным классом»?
                      В принципе можно добавить отдельный интерфейс с методом Unsubscribe, чтобы уведомить класс, что ему надо отписаться от событий, но поскольку IDisosable и так поддерживается во многих местах, то проще отписываться именно в Dispose.
                        0
                        Под пассивным я имел ввиду класс, в котором реализован минимум логики по обработке собственных данных. Например реализация IEnumerator.
                          0
                          К сожалению, не всегда класс сам может узнать когда он уже не нужен, соответственно его об этом надо уведомить.
            +1
            Да, с этой проблемой мне сталкиваться приходилось. Хотелось бы сделать небольшую поправку, касательно RoutedEvent — поддержка вводится в классе UIElement, а не в классе DependencyObject.
            Еще одна довольно забавная штука — у Microsoft есть еще одна реализация слабых событий — CommandManager.InvalidateRequerySuggested.
            Забавно то, что эта реализация хотя и повсеместно используется в WPF, но содержит ошибку — в WeakReferenc'ы оборачиваются сами делегаты, а не их Target'ы. Похоже, что разработчики WPF нашли ошибку слишком поздно, и вместо переделки решили вставить костыль — каждый кто подписывается на InvalidateRequerySuggested обязан сохранять сильную ссылку на делегат в экземпляре объекта…
            Ну а я, как обычно, пошел своим путём, и реализовал класс слабого делегата при помощи малоизвестной фичи .net framework под названием «не привязанный делегат» :) Но подожду того, что напишет ivann, сравним.
              –1
              Если вы имеете ввиду unbound delegate, то их в C# как бы еще нет.
                +2
                Да, именно его. Есть с версии 2.0.
                  0
                  Ну да, делегаты на статические методы есть с .NET 2.0. Не догадался, что вы именно их подразумевали.

                  А каким макаром они позволяют решить проблему жестких ссылок на события?

                    0
                    Почитайте справку повнимательнее. Фраза «Threat method as static» не обозначает, что нельзя с экземплярными методами работать как со статическими.
                +1
                Да, вы правы UIElement. Спасибо.
                  0
                  Да, можно поподробнее на тему как в делегате обернуть target в WeakReference?
                    0
                    Пишу не в редакторе, могут быть синтаксические ошибки.

                    object o = new object();
                    MethodInfo mi = new Func(o.ToString).Method; // Работает быстрее рефлексии.
                    Func<object, string> unboundDelegate = (Func<object, string>)Delegate.CreateDelegate(
                    typeof(Func<object, string>), null, mi);
                    WeakReference wr = new WeakReference(o);
                    o = null; // Больше на объект ссылок нет!

                    // Попытка вызова выглядит так:
                    object strongRef = wr.Target;
                    if (null != strongRef) Console.WriteLine(unboundDelegate(strongRef));
                      0
                      Это частный случай, когда у вас есть живые методы и полный контроль над вызовом делегата.

                      А как быть, если у вас уже есть EventHandler, который надо преобразовать в weak EventHandler.

                      Помимо этого, как быть, когда ваш WeakEventHandler обнаруживает свой target в мусорке? Как от события отписываться, если не знаешь, что это за событие и где?
                        0
                        Ну как, просто всё. Все названные задачи решаются, просто нужно написать класс инкапсулирующий список пар WeakDeference+Delegate (unbound). Я думаю очевидно как… По поводу EventHandler — это Delegate, а у делегата есть свойства Target и Method, а собственно больше ничего не надо. Минус в том, что для того, чтобы подписаться на слабое событие нужно создать экземпляр делегата.
                        В итоге я написал шаблон, по которому генерируются классы слабых шаблонных делегатов под заданное число параметров. Типа как WeakAction<T1...Tn>.
                  0
                  плюсанул, добавил в избранное, теперь у меня есть весомый аргумент в холиварах C++ vs C#…
                  С# обратно пропорциональный язык: простые вещи в нем делаются сложно, но при этом сложные — просто…
                  сори за офтоп.
                    0
                    Зачем же холивары? Это просто разные языки с разной областью применения.
                      0
                      я знаю… но судя по неделям ненависти к С++, и постоянным холиварам на ГК, это знают не все. и основной аргумент используемый оппонентами это отсутствие утечек памяти…
                        0
                        Ха, нет… Так могут говорить только те, кто ничего серьезного не писал. Другое дело, что порой их проще находить, но это уже вопрос отдельный.
                          +1
                          И вообще я, как человек, который поровну писал и пишет на плюсах и шарпе, смотрю на эти холивары с улыбкой:)
                        0
                        А в C++ отписка от событий как происходит? Как-то еще проще?
                          +3
                          Просто в с++ легко наделать ликов и без событий;)
                        0
                        А что уважаемое сообщество скажет на счет того, что утечки памяти от неотписанных событий — миф?
                        Только что наткнулся в msdn на msdn.microsoft.com/en-us/library/ms366768.aspx

                        The Memory Leak Myth
                        A popular myth is that events cause memory leaks because the listener keeps the source open. This is not true. Although you should always strive to unsubscribe from events, it's not always possible, and it would often mean trying to force many classes to be IDisposable.

                        Ian Griffiths demonstrates how objects are correctly GCed even when events are left with subscribers, here:

                        www.interact-sw.co.uk/iangblog/2004/07/07/circulareventrefs

                        The myth echoes around the internet and even comes from Microsoft (MSDN) themselves:

                        Ordinary events in the common language runtime (CLR) 2.0 are bidirectional strong references between the event source and the listener and as such can keep an object (either source or listener) alive that otherwise should be dead already. This is why a WeakEvent class was added in the .NET Framework 3.0. It is important that you become familiar with the WeakEvent pattern, which is not yet well known, but is required to implement the Observer pattern successfully. The WeakEvent pattern has been used in the Windows® Presentation Foundation (WPF) Data Binding implementation to prevent leakage due to data binding.

                        The bold in the pullquote above is wrong. You can use the VS debugger to look at the pack of delegates inside the handler and see there's only a one-way reference to the Target method.

                        Even circular references are okay; as long as there are no reference to the objects referencing each other then they are seen as an island and they'll be collected.
                          0
                          накидал такой код для проверки, действительно, объект нормально удаляется коллектором, утечек нет:

                          using System;

                          namespace ConsoleApplication1
                          {
                            /// <summary>
                            /// то что должно остаться живым, так как есть подписчики
                            /// </summary>
                            class A
                            {
                              public event EventHandler Event = delegate { };

                              public void Fire()
                              {
                                Event(this, null);
                              }

                              ~A()
                              {
                                Console.WriteLine(«a finalized»);
                              }
                            }

                            /// <summary>
                            /// слушатель события
                            /// </summary>
                            class B
                            {
                              public void f(A a)
                              {
                                if (a != null)
                                {
                                  Console.WriteLine(«subscrising»);
                                  a.Event += new EventHandler(a_H);
                                }
                              }

                              void a_H(object sender, EventArgs e)
                              {
                                Console.WriteLine(«event rised»);
                              }

                              ~B()
                              {
                                Console.WriteLine(«b finalized»);
                              }
                            }

                            class Program
                            {
                              static void Main(string[] args)
                              {
                                A a = new A();
                                B b = new B();
                                
                                // подписываемся на событие
                                // в теории обект А должен оставться живым даже после сборки мусора,
                                // так как объект B подписался на его событие
                                b.f(a);
                                a.Fire();

                                // а — мусор
                                a = null;

                                // собираем мусор
                                GC.Collect();
                                GC.WaitForPendingFinalizers();
                                GC.Collect();
                                Console.WriteLine(«collected»);

                                // видим:
                                // subscrising
                                // event rised
                                // a finalized
                                // collected

                                // финализатор для А вызвался, хоть в В есть ссылка на событие из А

                                // а — все равно мусор, ничего не происходит
                                b.f(a);
                                Console.ReadKey();

                                // после завершения вызовется финализатор В
                              }
                            }
                          }

                          * This source code was highlighted with Source Code Highlighter.

                          где ошибка?
                            +2
                            В вашем коде утечки нет, т.к. «a» держит ссылку на «b», а не наоборот. Попробуйте обнулить «b», вместо «a» и вы увидите, что «b» не соберется.
                              0
                              спасибо, понятно.
                              в общем, листенер события (В) действительно не держит объект с событием (А), как сказано в msdn.
                              но объект с событием (А) удерживает листенера (В), что логично, так как когда событие срабатывает, все, что на него подписалось должно отработать. там может быть задумано, листенер не должен чиститься.
                              ошибка — ожидать, что это должно произойти.
                            0
                            По английски всё правильно написано, читайте внимательнее.

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

                          Самое читаемое