- PVSM.RU - https://www.pvsm.ru -
Как правило, профилировщики памяти начинают использовать тогда, когда приложение уже гарантированно «течёт», пользователи активно шлют письма, пестрящие скриншотами диспетчера задач и нужно потратить уйму времени на профилирование и поиск причины. Наконец, когда разработчики обнаруживают и устраняют утечку, выпускают новую прекрасную версию приложения, лишенную прежних недостатков, есть риск, что через некоторое время утечка вернется, ведь приложение растет, а разработчики все также могут допускать ошибки.
Автоматизированное регрессионное тестирование ошибок уже давно стало мейнстримом индустрии разработки качественного ПО. Такие тесты помогают не допустить попадание ошибки к пользователю, а также по горячим следам разобраться, какое изменение в коде привело к ошибке, тем самым минимизировав время ее исправления.
Почему бы нам не применить такой же подход к утечкам памяти?
Этим вопросом мы задались, в очередной раз получив OutOfMemoryException во время прохождения регрессионных автотестов на x86 агентах.
Пара слов про наш продукт: мы разрабатываем Pilot-ICE [1] — систему управления инженерными данными. Приложение написано на .NET/WPF, а для регрессионного тестирования мы используем фреймворк Winium.Cruciatus [2], основанный на UIAutomation. Тесты «прокликивают» через UI весь доступный функционал приложения, проверяя логику работы.
Идея внедрения тестов на утечки памяти следующая: в определенные моменты прохождения тестов подключаться к приложению и проверять количество экземпляров объектов определенных типов в памяти.
Мы рассмотрели большинство популярных .NET профилировщиков памяти, и все они сохраняют снапшоты памяти в проприетарном формате, который может быть открыт для анализа исключительно в соответствующем просмотрщике. Никакой возможности для автоматизированного анализа снапшотов нами найдено не было ни в одном из них.
Особняком стоит dotMemory Unit – бесплатный фреймворк для юнит-тестирования, позволяющий анализировать утечки памяти в тестах. К сожалению, в нем анализ памяти ограничен процессом, выполняющим запуск тестов. Подключиться к внешнему процессу с помощью dotMemory Unit на данный момент возможности нет.
Итак, не найдя подходящего готового решения, было решено написать свой профилировщик памяти. Что он должен уметь делать:
При этом хотелось сделать так, чтобы не пришлось модифицировать само тестируемое приложение.
Как вы знаете, для вызова сборки мусора в .NET приложении может быть использован метод GC.Collect(), запускающий сборку мусора сразу во всех поколениях. Данный метод не рекомендован к использованию в продакшн-коде, и профилирование памяти — чуть ли не единственный адекватный сценарий его использования. Сборка мусора перед профилированием нужна для устранения ложных срабатываний профилировщика на недостижимых объектах, до которых просто не успел дойти GC.
Сложность состоит в том, что сборка мусора должна быть запущена во внешнем процессе, и для этого есть несколько возможных решений:
Для анализа памяти приложения мы использовали библиотеку CLR MD [4], предоставляющее 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);
}
}
Самый сложный, но вполне разрешимый момент — получение информации о том, что удерживает объект от того, чтобы быть собранным сборщиком мусора. Для этого необходимо обойти все деревья зависимостей корневых ссылок, запоминая по ходу обхода пути удержания.
Далее мы встроили все наработки в код регрессионных тестов. В тесты была добавлена информация об именах периодически утекающих типов и максимальном количестве экземпляров этого типа, которые могут находиться в памяти. Алгоритм проверки такой: после окончания теста сначала запускается сборка мусора, потом запускается анализ количества объектов интересующих нас типов, если их количество больше эталонного — рапортуется проблема и билд помечается как «упавший». Кроме того, собирается диагностическая информация о том, что держит эти объекты от сборки мусора и добавляется в артефакты билда. Вот как это выглядит для TeamCity:
Получившееся решение вышло довольно общим, и мы решили поделиться им с сообществом. С кодом проекта можно ознакомиться в репозитории на github [5], кроме того решение в готовом для использования виде доступно в виде nuget пакета [6] под названием 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, в которые намеренно внедрим разные варианты утечек памяти:
Пожалуй, самый распространенный вид утечки памяти. Объект-владелец события после подписки начинает хранить строгую ссылку на подписчика, тем самым не давая сборщику мусора убрать подписчика на все время жизни объекта-владельца события. Пример:
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 приложении. Если целевой объект связывания не 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).
При связывании с коллекцией, не поддерживающей интерфейс 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 приложении писать можно, и даже совсем не сложно, особенно если у вас уже есть автоматизированные тесты, работающие с реальным приложением.
Ссылка на репозиторий [5] и nuget пакет [6].
Скачивайте, используйте в ваших .NET проектах для контроля утечек памяти. Мы будем рады пожеланиям и предложениям.
Автор: Хиндикайнен Алексей
Источник [7]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/testing/269681
Ссылки в тексте:
[1] Pilot-ICE: http://pilotems.com/ru/
[2] Winium.Cruciatus: https://habrahabr.ru/company/2gis/blog/220337/
[3] Snoop for WPF: https://github.com/cplotts/snoopwpf
[4] CLR MD: https://github.com/Microsoft/clrmd
[5] репозитории на github: https://github.com/hindikaynen/NetMemoryProfiler
[6] nuget пакета: https://www.nuget.org/packages/Ascon.NetMemoryProfiler
[7] Источник: https://habrahabr.ru/post/343684/?utm_source=habrahabr&utm_medium=rss&utm_campaign=best
Нажмите здесь для печати.