Регрессионные тесты на утечки памяти, или как написать memory profiler для .NET приложений

    Как правило, профилировщики памяти начинают использовать тогда, когда приложение уже гарантированно «течёт», пользователи активно шлют письма, пестрящие скриншотами диспетчера задач и нужно потратить уйму времени на профилирование и поиск причины. Наконец, когда разработчики обнаруживают и устраняют утечку, выпускают новую прекрасную версию приложения, лишенную прежних недостатков, есть риск, что через некоторое время утечка вернется, ведь приложение растет, а разработчики все также могут допускать ошибки.


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


    Почему бы нам не применить такой же подход к утечкам памяти?



    Этим вопросом мы задались, в очередной раз получив OutOfMemoryException во время прохождения регрессионных автотестов на x86 агентах.


    Пара слов про наш продукт: мы разрабатываем Pilot-ICE — систему управления инженерными данными. Приложение написано на .NET/WPF, а для регрессионного тестирования мы используем фреймворк Winium.Cruciatus, основанный на UIAutomation. Тесты «прокликивают» через UI весь доступный функционал приложения, проверяя логику работы.


    Идея внедрения тестов на утечки памяти следующая: в определенные моменты прохождения тестов подключаться к приложению и проверять количество экземпляров объектов определенных типов в памяти.


    Анализ существующих решений


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


    Особняком стоит dotMemory Unit – бесплатный фреймворк для юнит-тестирования, позволяющий анализировать утечки памяти в тестах. К сожалению, в нем анализ памяти ограничен процессом, выполняющим запуск тестов. Подключиться к внешнему процессу с помощью dotMemory Unit на данный момент возможности нет.


    Пишем свой профилировщик


    Итак, не найдя подходящего готового решения, было решено написать свой профилировщик памяти. Что он должен уметь делать:


    1. Вызывать сборку мусора в приложении
    2. Получать количество объектов заданного типа в памяти приложения
    3. Анализировать, что удерживает данные объекты от того, чтобы быть собранными GC (Garbage Collector).

    При этом хотелось сделать так, чтобы не пришлось модифицировать само тестируемое приложение.


    Сборка мусора


    Как вы знаете, для вызова сборки мусора в .NET приложении может быть использован метод GC.Collect(), запускающий сборку мусора сразу во всех поколениях. Данный метод не рекомендован к использованию в продакшн-коде, и профилирование памяти — чуть ли не единственный адекватный сценарий его использования. Сборка мусора перед профилированием нужна для устранения ложных срабатываний профилировщика на недостижимых объектах, до которых просто не успел дойти GC.
    Сложность состоит в том, что сборка мусора должна быть запущена во внешнем процессе, и для этого есть несколько возможных решений:


    1. Подключиться к процессу по debugger API и вызвать сборку мусора
    2. Внедриться в процесс и запустить сборку мусора оттуда
    3. Через ETW (Event Tracing for Windows) командой GCHeapCollect
      Мы выбрали второй путь как наиболее простой для реализации. Managed Injector был позаимствован у проекта Snoop for WPF. Он позволяет указав путь с сборке, классу и методу в ней загрузить эту сборку в домен внешнего приложения и запустить указанный метод. В нашем случае после внедрения в процесс запускается named pipe сервер, который по команде от клиента (профилировщик) запускает процесс сборки мусора.

    Анализ памяти приложения


    Для анализа памяти приложения мы использовали библиотеку CLR MD, предоставляющее API, сходное с расширением отладки SOS в WinDbg. С помощью него можно подключиться к процессу, обойти все объекты в кучах, получить список корневых ссылок (GC root) и зависимые от них объекты. По большому счету все, что нам необходимо — уже реализовано, нужно только всем этим правильно воспользоваться.


    Вот так можно получить количество объектов определенного типа в памяти с помощью CLR MD:


    public int CountObjects(int pid, string type)
    {
        using (var dataTarget = DataTarget.AttachToProcess(pid, msecTimeout: 5000))
        {
            var runtime = dataTarget.ClrVersions.First().CreateRuntime();
            return runtime.Heap.EnumerateObjects().Count(o => o.Type.Name == type);
        }
    }

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


    Continuous Integration


    Далее мы встроили все наработки в код регрессионных тестов. В тесты была добавлена информация об именах периодически утекающих типов и максимальном количестве экземпляров этого типа, которые могут находиться в памяти. Алгоритм проверки такой: после окончания теста сначала запускается сборка мусора, потом запускается анализ количества объектов интересующих нас типов, если их количество больше эталонного — рапортуется проблема и билд помечается как «упавший». Кроме того, собирается диагностическая информация о том, что держит эти объекты от сборки мусора и добавляется в артефакты билда. Вот как это выглядит для TeamCity:



    Sharing is caring. Встречайте Ascon.NetMemoryProfiler


    Получившееся решение вышло довольно общим, и мы решили поделиться им с сообществом. С кодом проекта можно ознакомиться в репозитории на github, кроме того решение в готовом для использования виде доступно в виде nuget пакета под названием Ascon.NetMemoryProfiler. Распространяется под лицензией Apache 2.0.
    Ниже пример использования API. Минималистичный, но описывающий практически весь предоставляемый функционал:


    // Присоединяемся с процессу MyApp
    // После присоединения, в приложении будет вызвана сборка мусора
    using (var session = Profiler.AttachToProcess("MyApp"))
    {
        // Ищем в памяти живые объекты типа "MyApp.Foo"
        var objects = session.GetAliveObjects(x => x.Type == "MyApp.Foo");
        // Получаем информацию, что удерживает объекты от сборки мусора
        var retentions = session.FindRetentions(objects);
    }

    Рассмотрим на примере простого приложения, как можно написать тест на утечки памяти. Сделаем тестовый проект, добавим в него пакет Ascon.NetMemoryProfiler.


    Install-Package Ascon.NetMemoryProfiler


    Напишем основу для теста:


    [TestFixture]
    public class MemoryLeakTests
    {
        [Test]
        public void MemoryLeakTest()
        {
            using (var session = Profiler.AttachToProcess("LeakingApp"))
            {
                var objects = session.GetAliveObjects(x => x.Type.EndsWith("LeakingObjectTypeName"));
                if (objects.Any())
                {
                    var retentions = session.FindRetentions(objects);
                    Assert.Fail(DumpRetentions(retentions));
                }
            }
        }
    
        private static string DumpRetentions(IEnumerable<RetentionsInfo> retentions)
        {
            StringBuilder sb = new StringBuilder();
            foreach (var group in retentions.GroupBy(x => x.Instance.TypeName))
            {
                var instances = group.ToList();
                sb.AppendLine($"Found {instances.Count} instances of {group.Key}");
                for (int i = 0; i < instances.Count; i++)
                {
                    var instance = instances[i];
                    sb.AppendLine($"Instance {i + 1}:");
                    foreach (var retentionPath in instance.RetentionPaths)
                    {
                        sb.AppendLine(retentionPath);
                        sb.AppendLine("----------------------------");
                    }
                }
            }
            return sb.ToString();
        }
    }

    Создадим новое WPF приложение, и добавим в него несколько окон и view-model, в которые намеренно внедрим разные варианты утечек памяти:


    Утечка через EventHandler


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


    public class EventHandlerLeakViewModel : INotifyPropertyChanged
    {
        public EventHandlerLeakViewModel()
        {
            Dispatcher.CurrentDispatcher.ShutdownStarted += OnShutdownStarted;
        }
    
        private void OnShutdownStarted(object sender, EventArgs e)
        {
        }
    
        //...
    }

    В данном случае время жизни Dispatcher.CurrentDispatcher совпадаем с временем жизни приложения, и EventHandlerLeakViewModel не будет освобождена даже после закрытия ассоциированного с ней окна.
    Проверим. Запускаем приложение, открываем окно, закрываем его, запускаем тест, предварительно указав в нем имя процесса и имя типа для поиска. Получаем результат:


    Found 1 instances of LeakingApp.EventHandlerLeakViewModel
    Instance 1:
    static var System.Windows.Application._appInstance
    LeakingApp.App
    MS.Win32.HwndWrapper
    System.Windows.Threading.Dispatcher
    System.EventHandler

    Исправить утечку можно, вовремя отписавшись от события (например, при закрытии окна), или воспользовавшись слабыми событиями (weak events).


    Утечка через WPF binding


    Довольно неочевидный способ получить утечку памяти в WPF приложении. Если целевой объект связывания не DependencyObject и не поддерживает интерфейс INotifyPropertyChanged, то данный объект будет жить в памяти вечно. Пример:


    <Grid d:DataContext="{d:DesignInstance local:BindingLeakViewModel}">
        <TextBlock Text="{Binding Title}" TextWrapping="Wrap" Margin="5"/>
    </Grid>

    public class BindingLeakViewModel
    {
        public BindingLeakViewModel()
        {
            Title = "Hello world.";
        }
        public string Title { get; set; }
    }

    Запустим тест. Получим такой результат:


    Found 1 instances of LeakingApp.BindingLeakViewModel
    Instance 1:
    static var System.ComponentModel.ReflectTypeDescriptionProvider._propertyCache
    System.Collections.Hashtable
    System.Collections.Hashtable+bucket[]
    System.ComponentModel.PropertyDescriptor[]
    System.ComponentModel.ReflectPropertyDescriptor
    System.Collections.Hashtable
    System.Collections.Hashtable+bucket[]

    Чтобы устранить такую утечку, необходимо поддержать интерфейс INotifyPropertyChanged у класса BindingLeakViewModel, либо определить связывание как одноразовое (OneTime).


    Утечка через WPF collection binding


    При связывании с коллекцией, не поддерживающей интерфейс INotifyCollectionChanged, коллекция никогда не будет собрана GC. Пример:


    <ItemsControl ItemsSource="{Binding Items}" 
                  d:DataContext="{d:DesignInstance local:CollectionLeakViewModel}">
        <ItemsControl.ItemTemplate>
            <DataTemplate DataType="local:MyCollectionItem">
                <TextBlock Text="{Binding Title}"/>
            </DataTemplate>
        </ItemsControl.ItemTemplate>
    </ItemsControl>

    public class CollectionLeakViewModel : INotifyPropertyChanged
    {
        public List<object> Items { get; }
    
        public CollectionLeakViewModel()
        {
            Items = new List<object>();
            Items.Add(new MyCollectionItem { Title = "Item 1" });
        }
    
        // ...
    }
    
    public class MyCollectionItem : INotifyPropertyChanged
    {
        public string Title { get; set; }
    
        // ...
    }

    Поправим тест, чтобы он искал экземпляры типа MyCollectionItem, и запустим его.


    Found 1 instances of LeakingApp.MyCollectionItem
    Instance 1:
    static var System.Windows.Data.CollectionViewSource.DefaultSource
    System.Windows.Data.CollectionViewSource
    System.Windows.Threading.Dispatcher
    System.Windows.Input.InputManager
    System.Collections.Hashtable
    System.Collections.Hashtable+bucket[]
    System.Windows.Input.InputProviderSite
    MS.Internal.SecurityCriticalDataClass<System.Windows.Input.IInputProvider>
    System.Windows.Interop.HwndStylusInputProvider
    MS.Internal.SecurityCriticalDataClass<System.Windows.Input.StylusWisp.WispLogic>
    System.Windows.Input.StylusWisp.WispLogic
    System.Collections.Generic.Dictionary<System.Object,System.Windows.Input.PenContexts>
    System.Collections.Generic.Dictionary+Entry<System.Object,System.Windows.Input.PenContexts>[]
    System.Windows.Input.PenContexts
    System.Windows.Interop.HwndSource
    LeakingApp.CollectionLeakView
    System.Windows.Controls.Border
    System.Windows.Documents.AdornerDecorator
    System.Windows.Controls.ContentPresenter
    System.Windows.Controls.StackPanel
    System.Windows.Controls.UIElementCollection
    System.Windows.Media.VisualCollection
    System.Windows.Media.Visual[]
    System.Windows.Controls.ItemsControl
    System.Windows.Controls.StackPanel
    System.Windows.Controls.ItemContainerGenerator
    System.Windows.Controls.ItemCollection
    System.Windows.Data.ListCollectionView

    Устранить утечку можно, использовав ObservableCollection вместо List.

    Заключение


    Регрессионные тесты на утечки в .NET приложении писать можно, и даже совсем не сложно, особенно если у вас уже есть автоматизированные тесты, работающие с реальным приложением.


    Ссылка на репозиторий и nuget пакет.


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

    АСКОН
    Russia's largest engineering software developer

    Comments 12

      +2
      Requirements: Microsoft visual C++ 2010 Redistributable installed
      Я так понимаю, что в .Net Core под Linux работать не будет? А жаль!
        0
        Этого требует managed injector, мы рассмотрим варианты решения.
        0
        Спасибо.

        using (var session = Profiler.AttachToProcess("LeakingApp")) { /*...*/ ;}

        По моему, это unit test'ом не называется: профайлинг процесса, как профайлинг (но техника интересная, конечно). Можно наверное назвать автоматическим профайлингом с проверкой постусловий. В юнит-тест (xunit, nunit, mstest) фреймоворки такое не запихаешь.

          0
          Вы правы, модульным (unit) такой тест не является (но мы его таким нигде и не называли).
          Вообще, перечисленные вами тестовые фреймворки никак не диктуют само содержание теста. Тест, написанный с их помощью, может быть модульным, интеграционным или даже UI тестом. Мы, например, пишем UI тесты на nunit в связке с Winium.Cruciatus. И посреди этих тестов вставляем проверки на утечки памяти, как описано в статье.
            0
            Да вы правы, я небрежен. regression и unit не одно и тоже.
            Однако вопрос о том насколько это автоматизированнае профилирование удобно держать в общем плейлисте. Ухаживание за процессами в которых живут объекты и в котором автопрофилирование исполняется — чистый abstaction leak и требует особого внимания. Впрочем у меня самого в общих плей листах — есть проверки на то что число объектов в глобальном пуле не растет (происходит переиспользование) — тоже abstraction leak, так как держится в уме некая уверенность что другие тесты на результат не влияют (хотя могут).
              0
              Соглашусь, мы имеем дело с «дырявой» абстракцией, ведь на уровне тестов, знающих только про названия кнопочек UI, мы начинаем оперировать именами классов кода приложения. Явное неудобство этого заключается в том, что переименовывая класс, необходимо не забыть поправить строчку в тесте. По поводу поддержки проверок утечек в общем плейлисте UI тестов — в минимальном варианте можно перед выходом из приложения по окончании всех тестов проверять, что все тяжелые объекты, которые должны быть в памяти максимум в одном экземпляре одновременно, проходят эту проверку. Примерами таких объектов могут быть view model от окон, которые физически невозможно открыть несколько одновременно.
          0
          А для UnitTest-ов есть inproc вариант?
            0
            Для inproc я бы посоветовал использовать dotMemory Unit. Он бесплатный и функционала в нем сильно больше.
            0
            Вполне можно скрестить с автоматизацией тестирования WPF via SpecFlow, например… И иметь что-то типа UI тестов — но при этом по памяти… ) Хм… А круто ))
              0
              Если сделаете, поделитесь опытом. Будет весьма интересно почитать.
              0

              В проекте на GitHub нет файлов ManagedInjector.exe, ManagedInjector64-4.0.dll, ManagedInjector32-4.0.dll, тогда как в структуру проекта они включены.
              Где их можно взять? Это то, что в поставке Snoop называется ManagedInjectorLauncher64-4.0.exe?

                0
                Недоглядел, прошу прощения. Нужные файлы добавлены. А так да, взяты из Snoop.

              Only users with full accounts can post comments. Log in, please.