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

в 14:52, , рубрики: .net, Ascon.NetMemoryProfiler, memory leaks, testing, Блог компании АСКОН, Тестирование IT-систем

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

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

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

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

Этим вопросом мы задались, в очередной раз получив 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:

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

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 проектах для контроля утечек памяти. Мы будем рады пожеланиям и предложениям.

Автор: Хиндикайнен Алексей

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js