Об утечке памяти в .Net

20 марта 2013 г.

С появлением платформ Java и .Net, казалось бы, можно забыть о проблеме утечки памяти, которая была актуальна на C++. Сборщик мусора автоматически отслеживает неиспользуемые объекты (на которых больше нет ссылок) и удаляет их, освобождая память. Однако перед сборщиком стоит важная задача: правильно определить, можно удалять объект или нет.

Больше говорят о проблеме удаления сборщиком мусора «нужных» объектов. Другими словами, о «ложной тревоге» - объект реально еще нужен, а сборщик мусора решил, что его можно удалить. В программе такая ситуация приведет к ошибке, поскольку объект будет удален из памяти раньше времени. Но есть и обратная сторона: проблема «пропуска цели» - неудаление объекта, который реально уже не используется. Это приведет к старой доброй утечке памяти. В ходе одного из наших собственных проектов мы столкнулись как раз с подобной проблемой.

В проекте имеется клиент и сервис (Windows service). В сервисе, работающем 24 часа в сутки, и стала проявляться проблема – примерно через сутки работы он занимал уже более 200 Мб оперативной памяти. Мы стали разбираться в проблеме.

Казалось бы, что может быть проще: сделать дамп памяти, открыть его в отладчике и посмотреть, какие объекты занимают больше всего памяти, а дальше уже разбираться, почему их так много и почему их не удаляет сборщик мусора. Тем более от Microsoft столько рекламы отладочных средств в Visual Studio – казалось, что для нее нет ничего невозможного :)

Однако в реальности все оказалось сложнее. Запустить службу в отладочном режиме среда разработки отказалась, равно как и присоединяться к уже запущенному процессу. Но мы нашли отладочный инструмент той же Visual Studio, который запускается из командной строки и подключается к сервису. К сожалению, данный инструмент мог лишь выполнить профилировку по использованию процессорного времени (быстродействию), а нам нужна была профилировка по памяти. Поэтому этот вариант нас тоже не устроил.

Следующим шагом мы перепробовали все существующие профайлеры памяти сторонних фирм. Их оказалось совсем немного. Функционал не позволял решить нашу проблему: максимум того, что мы узнали, был факт, что 70% памяти занято объектами типа Int32 и String. Но кто именно держит эти числа и строки? Понятно, что во главе стоят какие-то более сложные объекты, но какие именно? В идеале нам хотелось, чтобы профайлер строил дерево объектов и в процентном соотношении обозначал долю каждого из типов. Но никто такого не делает.

После этого мы решили вернуться к отладчику Visual Studio. Для этого пришлось временно сервис переделать в консольное приложение, чтобы его можно было запустить в режиме отладки. Это оказалось не так сложно, как мы думали – час работы и можно стартовать. Запустили отладку из VS. Ее пришлось оставить на целый день, чтобы довести приложение «до кондиции» по объему потребляемой памяти. В первый раз нас ждала неудача: оказывается, временная отладочная информация, собираемая профайлером VS, весит порядка 40 Гб за день работы, а у нас столько свободного места на тот момент не было. Компьютер завис. Пришлось повторять эксперимент. После остановки отладки еще около часа занимает обработка собранных данных (40 Гб – все-таки немало).

Наконец, мы увидели заветный отчет Visual Studio. Первый экран оказался неутешительным: мы опять увидели информацию о 70% памяти, занятой строками и числами. Но были и другие интересные сведения. В частности, в отчете говорилось, что порядка 80% памяти занимает вызов метода DbAdapter.Fill(). Становилось хотя бы ясным в каком направлении «копать». К счастью, этот метод вызывался всего в одном месте – в классе-обертке над SQL Server. Добавили удаление через Dispose() всех таблиц и dataset-ов, которые были больше не нужны. Но, к сожалению, проблема так и не ушла.

Продолжили поиски. Довольно много потратили времени на поиски ответа на вопрос, почему метод Fill может потреблять столько памяти. Все усложнялось тем, что этот метод – встроенный в .Net Framework и внутрь него не залезть. Пошли в обратную сторону: отладчик говорил, что почти все затратные вызовы этого метода происходят в конструкторе одного из наших классов (назовем его Device). Может быть где-то оставалась ссылка на объекты Device и сборщик не мог их уничтожить? Мы добавили обнуление ссылок после использования объекта везде, где только можно. Но проблема не исчезала.

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

По крайней мере, мы точно установили, что проблема в классе Device и, скорее всего – в конструкторе этого класса. Первым делом мы временно отключили вызов DbAdapter.Fill(). Каково же было наше удивление, когда память по-прежнему утекала! Значит, профайлер Visual Studio водил нас за нос и проблема была вовсе не в этом.

Методом постепенного комментирования строк кода мы, наконец, нашли виновника, при отключении которого память переставала пропадать. Это оказался объект-отладчик, который создавался внутри объекта Device. В свое время мы не стали использовать log4net и изобрели свой велосипед :) Наличие внутри Device объекта-отладчика (назовем его Debug) не дает сборщику мусора уничтожать объекты Device.

Полезли внутрь класса-отладчика. Теми же методами выяснили, что виной всему – таймер, создаваемый внутри. Этот таймер периодически опустошал буфер с информацией в файл.

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

Оказалось, что это известная проблема .Net. Воспользовались решением со слабыми ссылками на события – проблема наконец-таки исчезла.

Самое удивительное – анализатор Visual Studio показывал совершенно не ту информацию. В объектах Device действительно было много строк и чисел, поэтому они и занимали всю память, но вот указание на DbAdapter.Fill() было явно ошибочным, из-за которого много времени было потрачено впустую.

Кстати, в ходе экспериментов выяснили интересную особенность: сборщик мусора не уничтожает таймер, если тот запущен. Даже в том случае, если нет ни одного подписчика на его события. Поэтому таймер обязательно нужно останавливать, чтобы GC мог до него добраться.

P.S. Задумались о написании собственного профайлера, который бы строил дерево объектов в памяти для наглядности :)


Комментарии


Marat    (02/11/2017 18:03)

Хах. Красавцы. Такая же проблема, только с WCF :(


Написать комментарий

Ваше имя:


Комментарий:

Email:

Необязательно. Вы можете указать email, если хотите получать на него ответы в этой теме. Адрес опубликован не будет.

Защита от роботов: какой сегодня год?