[Перевод] Поиск утечки GDI объектов: Как загнать мастодонта
В 2016 году, когда большинство программ выполняются в песочницах, из которых даже самый некомпетентный разработчик не сможет навредить системе, странно сталкиваться с проблемой, о которой дальше пойдет речь. Если честно, я надеялся, что она ушла в далекое прошлое вместе с Win32Api, но недавно я с ней столкнулся. До этого я лишь слышал жуткие байки старых более опытных разработчиков, что такое может быть.
Проблема
Утечка или использование слишком большого числа GDI объектов.
Симптомы:
- В Task Manager на вкладке Details колонка GDI objects показывает угрожающие 10000(Если этой колонки нету, ее можно добавить, кликнув на заголовке таблицы правой кнопкой и выбрав пункт Select Columns)
- При разработке на C# или другом языке выполняемом CLR полетит исключение, не блещущее конкретикой:
Message: A generic error occurred in GDI+.
Source: System.Drawing
TargetSite: IntPtr GetHbitmap (System.Drawing.Color)
Type: System.Runtime.InteropServices.ExternalException
Также при определенных настройках или версии системы исключения может и не быть, но Ваше приложение не сможет нарисовать ни единого объекта. - При разработке на С/С++ все методы GDI вроде Create%SOME_GDI_OBJECT% стали возвращать NULL
Почему?
В системах семейства Windows может быть одновременно создано не более 65535 объектов GDI. Число, на самом деле, невероятно большое и ни при каком нормальном сценарии и близко не должно достигаться. На процесс установлено ограничение в 10000, которое хоть и можно изменить (в реестре изменить значение HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Windows\GDIProcessHandleQuota в пределах от 256 до 65535), но Microsoft настоятельно не рекомендует увеличивать это ограничение. Если это сделать, то у одного процесса будет возможность положить систему настолько, что та даже не сможет нарисовать сообщение об ошибке. В этом случае система сможет ожить только после перезагрузки.
Как исправлять?
Если Вы живете в аккуратном управляемом CLR«ом мире, то вероятность 9 из 10, что у Вас в приложении обычная утечка памяти. Проблема хоть и неприятная, зато довольно обыденная и есть по меньшей мере дюжина отличных инструментов для ее поиска. Подробно останавливаться на этом не буду. Вам лишь будет нужно использовать любой профилировщик, чтобы посмотреть, не увеличтиресомеивается ли число объектов-оберток над GDI ресурсами, это: Brush, Bitmap, Pen, Region, Graphics. Если это действительно так, то Вам повезло, можете закрывать вкладку со статьей.
Если не нашлась утечка объектов-оберток, то значит у Вас в коде есть прямое использование функций GDI и сценарий, при котором они не удаляются.
Что Вам будут советовать другие?
Официальное руководство от Microsoft или другие статьи по этому поводу, которые Вы найдете в интернете, будут советовать примерно следующее:
Найти все Create%SOME_GDI_OBJECT% и узнать, есть ли соответствующий ему DeleteObject(или ReleaseDC для HDC-объектов), а если и есть, то, возможно, существует сценарий, при котором он не вызовется.
Есть еще чуть улучшенная версия этого метода, она содержит дополнительный первый шаг:
Скачать утилиту GDIView. Она умеет показывать конкретное количество GDI объектов по типу и единственное, что настораживает, так это то, что сумма всех не соответствует значению в последней колонке. На это можно попробовать не обращать внимание, если она поможет хоть как-то сузить зону поиска.
Проект, над которым я работаю, имеет кодовую базу в более 9 миллионов строк и еще примерно столько же в third-party библиотеках, сотни вызовов функций GDI, размазанных по десяткам файлов. Я потратил много сил и кофе, прежде чем понял, что вручную просто невозможно это проанализировать ничего не упустив.
Что предложу я?
Если этот способ Вам покажется слишком длинным и требующим лишних телодвижений, значит, Вы еще не прошли все стадии отчаяния с предыдущим. Можете еще несколько раз попробовать прошлые шаги, но если не поможет, то не сбрасывайте со счетов этот вариант.
В поисках утечки я задался вопросом: «А где создаются те объекты, что утекают?» Было абсолютно невозможно поставить точки останова во всех местах, где вызываются функции API. К тому же не было полной уверенности, что это не происходит в .net framework или одной из third-party библиотек, которые мы используем. Несколько минут гугления привели меня к утилите Api Monitor, которая позволяла логировать и отлаживать вызовы любых системных функций. Я без труда нашел список всех функций, порождающих GDI объекты, честно нашел их и выбрал в Api Monitor«е, после чего установил точки останова.
После чего запустил процесс на отладку в Visual Studio, а здесь выбрал его в дереве процессов. Первая точка останова сработала мгновенно:
Вызовов было слишком много. Я быстро понял, что захлебнусь в этом потоке и нужно придумать что-то еще. Я снял точки останова с функций и решил посмотреть лог. Это были тысячи и тысячи вызовов. Стало очевидно, что их не проанализировать вручную.
Задача: Найти те вызовы функций GDI, которым не соответствует удаление. В логи присутствует все необходимое: список вызовов функций в хронологическом порядке, их возвращаемые значения и параметры. Получается, что мне нужно взять возвращаемое значение функции Create%SOME_GDI_OBJECT% и найти вызов DeleteObject с этим значением в качестве аргумента. Я выделил все записи в Api Monitor, вставил в текстовый файл и получил что-то вроде CSV с разделителем TAB. Запустил VS, где думал написать программу, чтобы попарсить это, но, прежде чем она загрузилась, мне пришла в голову идея получше: экспортировать данные в базу и написать запрос, чтобы выгрести то, что меня интересует. Это был правильный выбор, потому что позволил очень быстро задавать вопросы и получать на них ответы.
Есть множество инструментов, чтобы импортировать данные из CSV в базу, потому не буду на этом останавливаться (mysql, mssql, sqlite).
У меня получилась вот такая таблица:
-- mysql code
CREATE TABLE apicalls (
id int(11) DEFAULT NULL,
`Time of Day` datetime DEFAULT NULL,
Thread int(11) DEFAULT NULL,
Module varchar(50) DEFAULT NULL,
API varchar(200) DEFAULT NULL,
`Return Value` varchar(50) DEFAULT NULL,
Error varchar(100) DEFAULT NULL,
Duration varchar(50) DEFAULT NULL
)
Написал функцию mysql, чтобы получать дескриптор удаляемого объекта из вызова апи:
CREATE FUNCTION getHandle(api varchar(1000))
RETURNS varchar(100) CHARSET utf8
BEGIN
DECLARE start int(11);
DECLARE result varchar(100);
SET start := INSTR(api,','); -- for ReleaseDC where HDC is second parameter. ex: 'ReleaseDC ( 0x0000000000010010, 0xffffffffd0010edf )'
IF start = 0 THEN
SET start := INSTR(api, '(');
END IF;
SET result := SUBSTRING_INDEX(SUBSTR(api, start + 1), ')', 1);
RETURN TRIM(result);
END
И наконец запрос, который найдет все текущие объекты:
SELECT creates.id, creates.handle chandle, creates.API, dels.API deletedApi
FROM (SELECT a.id, a.`Return Value` handle, a.API FROM apicalls a WHERE a.API LIKE 'Create%') creates
LEFT JOIN (SELECT
d.id,
d.API,
getHandle(d.API) handle
FROM apicalls d
WHERE API LIKE 'DeleteObject%'
OR API LIKE 'ReleaseDC%' LIMIT 0, 100) dels
ON dels.handle = creates.handle
WHERE creates.API LIKE 'Create%';
(Строго говоря, он просто найдет все вызовы Delete на все вызовы Create)
На рисунке сразу видны вызовы, на которые так и не нашлось ни одного Delete.
Остался последний вопрос: Как найти откуда вызываются эти методы в контексте моего кода? И здесь мне помог один хитрый трюк:
- Запустить приложение на отладку в VS.
- Найти его в Api Monitor и выбрать.
- Выбрать нужную функцию Api и поставить точку останова.
- Терпеливо нажимать «Далее», пока она не вызовется с интересующими параметрами. (Как же не хватала conditional breakpoints из vs
- Когда дойдете до нужного вызова, перейти в VS и нажать break all.
- Отладчик VS будет остановлен в месте, где создается утекающий объект и останется лишь найти, почему он не удаляется.
(Код написан исключительно для примера)
Резюме:
Алгоритм длинный и сложный, в нем задействовано много инструментов, но мне он дал результат значительно быстрее, чем тупой поиск ошибок по огромной кодовой базе.
Вот он, для тех кому было лень читать или кто уже забыл с чего все начиналось, пока читал:
- Поискать утечки памяти объектов-оберток GDI
- Если они есть, устранить и повторить первый шаг.
- Если их нет, то поискать вызовы функций апи напрямую.
- Если их немного, то поискать сценарий, при котором объект может не удаляться.
- Если их много или не получается отследить, то нужно скачать Api Monitor и настроить на логирование вызовов GDI функций.
- Запустить приложение на отладку в VS
- Воспроизвести утечку (это проинициализирует программу, что бы кешируемые объекты, не мазолили глаза в логе).
- Подключится Api Monitor«ом.
- Воспроизвести утечку.
- Скопировать лог в текстовый файл, импортировать в любую базу, что есть под рукой (скрипты в статье для mysql, но без труда адаптируются под любую РСУБД)
- Сопоставить Create и Delete методы (SQL-скрипт есть выше в этой статье), найти те, на которые нет вызов Delete
- Установить в Api Monitor точку останова на вызов нужного метода.
- Нажимать continue до тех пор, пока метод не вызовется с нужными параметрами. Плакать из-за отсутствия conditional breakpoints.
- Когда метод вызовется с нужными параметрами, нажать Break All в VS.
- Найти, почему этот объект не удаляется.
Очень надеюсь, что эта статья сэкономит кому-то много время и будет полезной.