Как правило, профилировщики памяти начинают использовать тогда, когда приложение уже гарантированно «течёт», пользователи активно шлют письма, пестрящие скриншотами диспетчера задач и нужно потратить уйму времени на профилирование и поиск причины. Наконец, когда разработчики обнаруживают и устраняют утечку, выпускают новую прекрасную версию приложения, лишенную прежних недостатков, есть риск, что через некоторое время утечка вернется, ведь приложение растет, а разработчики все также могут допускать ошибки. Автоматизированное регрессионное тестирование ошибок уже давно стало мейнстримом индустрии разработки качественного ПО. Такие тесты помогают не допустить попадание ошибки к пользователю, а также по горячим следам разобраться, какое изменение в коде привело к ошибке, тем самым минимизировав время ее исправления.  Почему бы нам не применить такой же подход к утечкам памяти?   Этим вопросом мы задались, в очередной раз получив OutOfMemoryException во время прохождения регрессионных автотестов на x86 агентах. Пара слов про наш продукт: мы разрабатываем Pilot-ICE — систему управления инженерными данными. Приложение написано на .NET/WPF, а для регрессионного тестирования мы используем фреймворк Winium.Cruciatus, основанный на UIAutomation. Тесты «прокликивают» через UI весь доступный функционал приложения, проверяя логику работы. Идея внедрения тестов на утечки памяти следующая: в определенные моменты прохождения тестов подключаться к приложению и проверять количество экземпляров объектов определенных типов в памяти. Мы рассмотрели большинство популярных .NET профилировщиков памяти, и все они сохраняют снапшоты памяти в проприетарном формате, который может быть открыт для анализа исключительно в соответствующем просмотрщике. Никакой возможности для автоматизированного анализа снапшотов нами найдено не было ни в одном из них.  Особняком стоит dotMemory Unit – бесплатный фреймворк для юнит-тестирования, позволяющий анализировать утечки памяти в тестах. К сожалению, в нем анализ памяти ограничен процессом, выполняющим запуск тестов. Подключиться к внешнему процессу с помощью dotMemory Unit на данный момент возможности нет. Итак, не найдя подходящего готового решения, было решено написать свой профилировщик памяти. Что он должен уметь делать: При этом хотелось сделать так, чтобы не пришлось модифицировать само тестируемое приложение. Как вы знаете, для вызова сборки мусора в .NET приложении может быть использован метод GC.Collect(), запускающий сборку мусора сразу во всех поколениях. Данный метод не рекомендован к использованию в продакшн-коде, и профилирование памяти — чуть ли не единственный адекватный сценарий его использования. Сборка мусора перед профилированием нужна для устранения ложных срабатываний профилировщика на недостижимых объектах, до которых просто не успел дойти GC.
Сложность состоит в том, что сборка мусора должна быть запущена во внешнем процессе, и для этого есть несколько возможных решений: Для анализа памяти приложения мы использовали библиотеку CLR MD, предоставляющее API, сходное с расширением отладки SOS в WinDbg. С помощью него можно подключиться к процессу, обойти все объекты в кучах, получить список корневых ссылок (GC root) и зависимые от них объекты. По большому счету все, что нам необходимо — уже реализовано, нужно только всем этим правильно воспользоваться. Вот так можно получить количество объектов определенного типа в памяти с помощью CLR MD: Самый сложный, но вполне разрешимый момент — получение информации о том, что удерживает объект от того, чтобы быть собранным сборщиком мусора. Для этого необходимо обойти все деревья зависимостей корневых ссылок, запоминая по ходу обхода пути удержания. Далее мы встроили все наработки в код регрессионных тестов. В тесты была добавлена информация об именах периодически утекающих типов и максимальном количестве экземпляров этого типа, которые могут находиться в памяти. Алгоритм проверки такой: после окончания теста сначала запускается сборка мусора, потом запускается анализ количества объектов интересующих нас типов, если их количество больше эталонного — рапортуется проблема и билд помечается как «упавший». Кроме того, собирается диагностическая информация о том, что держит эти объекты от сборки мусора и добавляется в артефакты билда. Вот как это выглядит для TeamCity:  Получившееся решение вышло довольно общим, и мы решили поделиться им с сообществом. С кодом проекта можно ознакомиться в репозитории на github, кроме того решение в готовом для использования виде доступно в виде nuget пакета под названием Ascon.NetMemoryProfiler. Распространяется под лицензией Apache 2.0.
Ниже пример использования API. Минималистичный, но описывающий практически весь предоставляемый функционал: Рассмотрим на примере простого приложения, как можно написать тест на утечки памяти. Сделаем тестовый проект, добавим в него пакет Ascon.NetMemoryProfiler. Install-Package Ascon.NetMemoryProfiler Напишем основу для теста: Создадим новое WPF приложение, и добавим в него несколько окон и view-model, в которые намеренно внедрим разные варианты утечек памяти: Пожалуй, самый распространенный вид утечки памяти. Объект-владелец события после подписки начинает хранить строгую ссылку на подписчика, тем самым не давая сборщику мусора убрать подписчика на все время жизни объекта-владельца события. Пример: В данном случае время жизни Dispatcher.CurrentDispatcher совпадаем с временем жизни приложения, и EventHandlerLeakViewModel не будет освобождена даже после закрытия ассоциированного с ней окна.
Проверим. Запускаем приложение, открываем окно, закрываем его, запускаем тест, предварительно указав в нем имя процесса и имя типа для поиска. Получаем результат: Исправить утечку можно, вовремя отписавшись от события (например, при закрытии окна), или воспользовавшись слабыми событиями (weak events). Довольно неочевидный способ получить утечку памяти в WPF приложении. Если целевой объект связывания не DependencyObject и не поддерживает интерфейс INotifyPropertyChanged, то данный объект будет жить в памяти вечно. Пример: Запустим тест. Получим такой результат: Чтобы устранить такую утечку, необходимо поддержать интерфейс INotifyPropertyChanged у класса BindingLeakViewModel, либо определить связывание как одноразовое (OneTime). При связывании с коллекцией, не поддерживающей интерфейс INotifyCollectionChanged, коллекция никогда не будет собрана GC. Пример: Поправим тест, чтобы он искал экземпляры типа MyCollectionItem, и запустим его. Устранить утечку можно, использовав ObservableCollection вместо List.
 Регрессионные тесты на утечки в .NET приложении писать можно, и даже совсем не сложно, особенно если у вас уже есть автоматизированные тесты, работающие с реальным приложением. Ссылка на репозиторий и nuget пакет.  Скачивайте, используйте в ваших .NET проектах для контроля утечек памяти. Мы будем рады пожеланиям и предложениям.