Строго говоря именно это оригинальный текст статьи, а в блоге уже перевод. Здесь статья публикуется чуть позже и только потому получает бирку перевод.

В 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.

Остался последний вопрос: Как найти откуда вызываются эти методы в контексте моего кода? И здесь мне помог один хитрый трюк:

  1. Запустить приложение на отладку в VS.
  2. Найти его в Api Monitor и выбрать.
  3. Выбрать нужную функцию Api и поставить точку останова.
  4. Терпеливо нажимать “Далее”, пока она не вызовется с интересующими параметрами. (Как же не хватала conditional breakpoints из vs
  5. Когда дойдете до нужного вызова, перейти в VS и нажать break all.
  6. Отладчик VS будет остановлен в месте, где создается утекающий объект и останется лишь найти, почему он не удаляется.


(Код написан исключительно для примера)

Резюме:


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

Вот он, для тех кому было лень читать или кто уже забыл с чего все начиналось, пока читал:

  1. Поискать утечки памяти объектов-оберток GDI
  2. Если они есть, устранить и повторить первый шаг.
  3. Если их нет, то поискать вызовы функций апи напрямую.
  4. Если их немного, то поискать сценарий, при котором объект может не удаляться.
  5. Если их много или не получается отследить, то нужно скачать Api Monitor и настроить на логирование вызовов GDI функций.
  6. Запустить приложение на отладку в VS
  7. Воспроизвести утечку (это проинициализирует программу, что бы кешируемые объекты, не мазолили глаза в логе).
  8. Подключится Api Monitor’ом.
  9. Воспроизвести утечку.
  10. Скопировать лог в текстовый файл, импортировать в любую базу, что есть под рукой (скрипты в статье для mysql, но без труда адаптируются под любую РСУБД)
  11. Сопоставить Create и Delete методы (SQL-скрипт есть выше в этой статье), найти те, на которые нет вызов Delete
  12. Установить в Api Monitor точку останова на вызов нужного метода.
  13. Нажимать continue до тех пор, пока метод не вызовется с нужными параметрами. Плакать из-за отсутствия conditional breakpoints.
  14. Когда метод вызовется с нужными параметрами, нажать Break All в VS.
  15. Найти, почему этот объект не удаляется.

Очень надеюсь, что эта статья сэкономит кому-то много время и будет полезной.
Поделиться с друзьями
-->

Комментарии (21)


  1. Yeah
    03.01.2017 18:13
    -5

    Плакать из-за отсутствия conditional breakpoints.

    До 2015 — right click on breakpoint, после 2015 — hover on breakpoint and click gear icon. Не?


    1. podkolzzzin
      03.01.2017 18:33
      +5

      Я имею ввиду, что их нету в api monitor


    1. Sersoftin
      03.01.2017 18:36
      +8

      А вы читайте внимательнее, а потом советы давайте. Автор не поо VS.


  1. dmitryredkin
    03.01.2017 18:30
    +1

    Прочитал по диагонали, так что не знаю, была ли возможность логировать стек, но в таких ситуациях стнадартный алгоритм такой:
    1. Хукаем API функции создания объектов, логируем стек и хендл.
    2. Хукаем удаление объектов и собираем утекшие хендлы.
    3. Смотрим стек первого утекшего хендла, правим код и повторяем сначала.


    1. dmitryredkin
      03.01.2017 21:17
      +5

      Трехминутное гугление выдало и библиотеку для работы со StackFrame, и библиотеку для хуков WinApi.
      Реально все просто, непонятно зачем было огород городить.


      1. podkolzzzin
        04.01.2017 11:25
        +1

        У меня была мысль написать программу, которая бы подменяла адреса нативных функций на мои, не был уверен, как именно это сделать, но мне подвернулась эта тула и необходимость писать что-то свое отпала.
        И еще одна проблема: не уверен, что получилось бы вытянуть корректный и полный стек в смешанной(управляемой и неуправляемой) среде.

        Еще часто просто стека может оказаться мало, потому может оказаться полезным получить возможность отладки именно с момента создания утекающего объекта.

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


  1. haoNoQ
    03.01.2017 20:34
    +2

    Как-то ковырялся с чем-то таким по другому поводу (под линуксом). Там лажа была что всякие валгринды не понимали что есть утечка, потому что всё потом освобождалось. Тоже перехватывал api захвата-освобождения и разбирал многогигабайтные csv-трассы malloc/calloc/realloc/free, хоть и питоном. При этом вся память перед смертью приложения освобождалась, а просто при ничего не делании (скажем "перейти во вкладку, перейти обратно в исходное положение" (1)) неограниченно росла, поэтому всякие валгринды не осиливали.


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


    А потом я просто тыкал сценарий (1) и глазами смотрел на трассу утечек и находил повторяющиеся аллокации. Получить по ним бэктрейс — уже кто как умеет, мне повезло что там аллок с красивым числом байтов был и я кондишнл-брейк просто поставил. А так либо инструмент сразу даст все бэктрейсы, либо ставить больше брейков, на разные библиотеки, и тоже поиск логарифмически сложный выходит.


    А поделитесь секретом — насколько локализован был баг на небольшой площади кода? В смысле ну статический анализ смог бы найти?


    1. dmitryredkin
      03.01.2017 21:22
      +1

      ДЛя таких случаев делаются слепки кучи в те моменты, когда все созданные объекты должны быть удалены (например, перед созданием и после закрытия вкладки). Затем разница между этими слепками и составляет утечку.


      1. haoNoQ
        03.01.2017 21:49

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

        Наверное даже можно сделать инструмент, который будет её хорошо решать, чтобы ему надо было только кормить запущенное приложение и моменты.

        P.S. Извиняюсь за самоповторы в комменте выше, старческий маразм/оверредактиринг.


        1. podkolzzzin
          04.01.2017 11:34
          +1

          Кстати, была идея написать подобную тулу, но быстро испугался объема работы.
          Хотя с радостью увидел бы такую фичу в каком-нибудь dotMemory и ему подобных.


    1. podkolzzzin
      04.01.2017 11:33

      Я правильно понял, у Вас была утечка памяти? Почему пришлось идти именно таким путем, а не использовать профилировщик?

      А поделитесь секретом — насколько локализован был баг на небольшой площади кода? В смысле ну статический анализ смог бы найти?

      А вот здесь интересно вышло. Утечек было 3:
      1. Самая интенсивная. Была донельзя банальна: не вызывался Dispose объекту, который не отписывал от событий дюжину других объектов, которые оставались висеть в памяти и держать объекты, которые держали GDI-wrapping-objects.
      2. Наименее интенсивная. Была в нашем коде. И думаю могла пойматься бы статическим анализатором, но боюсь кроме дельных срабатываний анализатор выдал бы еще очень много ложных срабатываний, где мог бы просто потеряться.
      3. Средней интенсивности. Оказалась в коде third-party библиотеки, доступа к коду которой не оказалось. И здесь было бы не на что натравить анализатор.


  1. loginsin
    03.01.2017 21:08
    +5

    Работа непосредственно с winapi долгие годы побудила к выработке особого подхода:
    * Пишешь CreateXXX, тут же пишешь DeleteXXX. Между ними код работы с объектом;
    * Пишешь new, тут же пишешь delete/delete[]. Между ними код работы с памятью Перешел на векторы;
    * Если объект «долгоиграющий» (неважно, HANDLE или HGDIOBJ), обязательно имей методы в классе вида AllocResource/FreeResource, последний из которых обязан быть в деструкторе, а в конструкторе не забудь написать hObject( NULL ). В AllocResource тоже всегда вызывай FreeResource, который внутри имеет код вида «if ( hObject != NULL ) FreeObject( hObject );». Причем оформление стоит делать сразу: написать оба метода и поместить последний в деструктор, потом уже делать основную работу.
    * Где можно не выделять новый объект — не выделяй. Имеешь много пиктограмм для вывода в меню, списки, дерево, свой собственный элемент управления? Собери в один длинный битмап, и используй BitBlt/StretchBlt/TransparentBlt. Этот способ в одном проекте увеличил скорость работы приложения в тысячи раз, а использование GDI объектов уменьшил в разы. Если лень, используй родные виндовые ImageList.

    Можно много еще много чего написать, но уже эти три подхода уменьшили головную боль при работе с winapi в разы.

    Для отладки можно использовать метод вида #undef OrigFunc, #define OrigFunc MyAuditFunc (точно подходит для парных функций OrigFuncA/OrigFuncW, для одиночных тоже подходит, но надо поколдовать с хедерами). С new / delete([]) вообще все просто: переопределяем на свои, где записываем выделенные объекты в vector/map (по вкусу), удаляем оттуда при delete.

    Ну это c++. В шарпе, думаю, возможен подобный подход, и думать над тем, чтобы лезть на более низкий уровень, не нужно.


    1. dmitryredkin
      03.01.2017 21:24
      +3

      Это хоршо для нового кода, у автора 9 млн строк чужого :)


      1. loginsin
        03.01.2017 21:32

        Так я о том и пишу: переопределить основные функции на свои функции аудита, а затем уже анализировать их вывод. Конечно, всегда будут особые случаи, но это уже частности.


    1. DrZlodberg
      04.01.2017 00:03
      +1

      Вектор удобно, но для большого количества объектов может памяти кушать много. Особенно если и сама программа её жрёт как не в себя. Была похожая задача — так же дефайнами прикрыл все подозрительные функции и писал в лог вместе с файлом/строкой вызова. Логи были огромные, но короткий perl-скрипт их разгребал влёт оставляя на выходе всех подозрительных.


      1. podkolzzzin
        04.01.2017 11:48

        Пора идти за книгой по perl. Часто внутренний/внешний голос говорит, что задача хорошо решается перлом.


    1. podkolzzzin
      04.01.2017 11:46

      Да в шарпе есть очень четкий набор правил, как использовать unmanaged ресурсы.
      1. Никогда не используйте unmanaged ресурсы.
      2. Если никогда наступило, то оберните ресурс в класс, задачей которого будет следить за этим ресурсом. В классе должен присутствовать Dispose и финализатор/деструктор, оба освобождающие ресурс.
      Обойтись одним деструктором, увы, не получится, так как в c# уж очень недерменирована его работа.

      По этому принципу написаны все объекты из System.Drawing.

      Про трюк с #define. В таком виде в шарпе его не провернуть. Но мне кажется он не будет корректно работать и на плюсах или си, поправьте, если ошибаюсь.
      #define на плюсах это директива препроцессора, тоесть «выполняется» на этапе компиляции. А, что если вызов интересующего Вас метода происходит из подключенной dll библиотеки? Мне кажется трюк с #define не сможет поймать такой трюк, хотя мог где-то и ошибиться в размышлениях.
      Есть трюк который точно сработает, он был моим последним вариантом, на случай, если вообще ничего не будет помогать:
      Подменить в памяти вызовы нативной функции на свои. Когда-то читал, что так можно делать, но полного представления что-именно нужно для этого написать нету, потому рад что не пришлось спуститься на последний круг ада. Хотя если бы пришлось, то изменилось бы не так много: собрать лог, найти что не удаляется, остановить отладку в момент создания текущего объекта. Разве что лог пришлось бы собирать самому.


  1. Norfolc
    03.01.2017 21:33
    +2

    Вместо ApiMonitor можно попробовать запустить отладку в Mixed Mode и поставить брекпоинты (с трассировкой) на нативные функции. И тут уже могут работать условные брекпоинты.


    1. podkolzzzin
      04.01.2017 12:07

      Спасибо за комментарий. Сейчас проверил, breakpoint сработал отлично, но установить condition никак не получается.
      В доке написано, что аргумент называется crColor.
      А без аргументов становится очень тяжело трассировать что-то внятное… Возможно есть способ достать стек, но мне кажется аргументы более точно идетифицируют утекающий объект.


      1. Norfolc
        04.01.2017 15:34
        +4

        Чтобы сделать условный брекпоинт с использованием имени параметра нужно загрузить символы для библиотеки. Microsoft Symbol Servers содержит символы для gdi32.dll, но, к сожалению, не содержит имен параметров. Поэтому нужно опуститься на уровень ассемблера, так как все параметры, в конечном счёте, передаются либо через стек, либо через регистры.

        Если запускать программу в 32-битном режиме, то первый параметр будет находиться по адресу [ebp+8], так как winapi использует stdcall. см. x86 Function-call Conventions
        Соответсвенно значение по этому адресу можно получить так: (*(int*)(ebp+8))


        Если программа 64-битная, то первые 4 целочисленные параметры функции находятся в регистрах RCX, RDX, R8, R9. см. Microsoft x64 calling convention.
        Если мы хотим проверять только младшие 4 байта параметра, то вместо регистра rcx нужно использовать ecx: ecx == 0x12345678.


        1. podkolzzzin
          05.01.2017 10:58

          Ничего себе. На этот круг ада благо еще не приходилось спускаться. Спасибо за ссылки и интересный трюк.
          Буду иметь ввиду в моменты глубокого отчаяния)