воскресенье, 4 января 2009 г.

DllMain и жизнь до родов

Это перевод DllMain and life before birth. Автор: Олег Львович.

Предисловие

Загрузчик ОС (OS loader) всегда интересовал меня - вероятно потому, что он работает за сценой. И обычно никто и не чешется понять, что же он делает в точности, до тех пор, пока не начинают происходить странные и забавные вещи. И они происходят. И тогда нам приходится читать документацию, и нас заставляют запоминать, что загрузка модуля - это нечто большее, чем просто маппинг его в адресное пространство процесса. Фактически, у нас есть замечательная статья Matt Pietrek, которая обсуждает все эти вещи. Я настоятельно рекомендую всем, кто имеет дело с нативным кодом, прочитать эту статью - она может сильно просветить вас - как это было для меня. Когда вы знаете, как работают эти вещи, то маловероятно, что вы забудете сменить базу своим модулям (re-base), учесть ранее связывание и т.п.

Когда в очередной раз появляется ещё один кусок информации или же отличная статья по теме, то я снова и снова озадачиваюсь темой загрузчика. В этот раз это был длинный пост в блоге Chris Brumme. Как заметили многие, этот пост пересыщен технической информацией, но что же ещё вы ожидали от блога Криса? :) В любом случае, чтобы лучше осознать тему и заполнить белые пятна, я решил написать об этом сам.

DllMain и загрузчик ОС

Как мы уже знаем, вещи не всегда бывают такими простыми, как они кажутся. И являются ли они вообще когда-нибудь простыми? DllMain, которой обычно уделяют один-два абзаца в книгах, описываемая как относительно безобидная функция инициализации, теперь может выглядеть ужасным чудовищем, не подчиняющимся никаким правилам и выдающим тонну непредсказуемых побочных эффектов. Но давайте вернёмся к источнику: MSDN.

Пока всё начинается вполне невинно. Статья определяет DllMain как опциональную точку входа в DLL, вызываемую системой, когда DLL подключается к процессу или потоку; подчёркивает несколько чудные, но разумные правила, которые разруливают вызовы (к примеру, вызовы могут не иметь парных, скажем, для потока, если это главный поток процесса или поток уже был запущен до вызова LoadLibrary), обсуждает аварийное завершение, а потом...

...ух-ты...

Даже не дрогнув, она перескакивает на описание того, что вы можете делать там. Это вообще ни в какие ворота не лезет - когда это вас вообще ограничивали в допустимых действиях? - но как только вы продолжаете читать, всё становится только хуже. В итоге оказывается, что вы вообще почти ничего не можете там делать. В явном виде запрещены вызовы LoadLibrary/LoadLibraryEx. Вызовы в kernel32 - это нормально. Но в User32 - уже нет. И не используйте свой менеджер памяти (если только он не прилинкован статически) - используйте вместо него HeapAlloc. Ах да, и, конечно же, не вызывайте ничего, что может использовать такие же штуки: это было бы нехорошо. И напоследок - не читайте реестр. Have a nice day!

Тот факт, что это написано не большим, жирным, а, может быть, даже и красным шрифтом удручает: действительно, ведь следовало бы - потому что большинство людей вообще не замечают эту часть. Так, если вы сейчас всё это всё же прочитали, то у вас остался вопрос: а почему?

Дело в том, что DllMain вызывается в действительно уникальный момент времени. К этому времени загрузчик ОС нашёл и спроецировал файл с диска, но (по обстоятельствам) в некотором смысле ваш модуль может быть ещё не "полностью рождён". Тут дело тонкое.

В двух словах, когда вызывается DllMain, загрузчик ОС находится в весьма подвешенном состоянии. Во-первых, он держит блокировку на свои внутренние структуры для защиты их от повреждения, а во-вторых, некоторые из ваших зависимостей могут быть ещё не разрешены (т.е. не загружены все DLL, которые требуются вашему модулю). Перед тем, как загрузить модуль, загрузчик ОС просматривает его статические зависимости. Если они также требуют дополнительные зависимости, то он просматривает и их тоже. В результате загрузчик получает последовательность, в которой он будет вызывать DllMain-ы этих модулей, для которых их нужно вызывать. Загрузчик довольно сообразителен и чаще всего, если вы не следуете правилам, описанным в MSDN, то вам это сойдёт с рук - но не всегда (*).

Суть в том, что порядок загрузки вам неизвестен, но что более важно, что порядок основывается только на информации из статического импорта. Если в вашей DllMain происходит динамическая загрузка во время DLL_PROCESS_ATTACH, и вы делаете вызов в чужой модуль, то все ставки уже сделаны. Нет никаких гарантий, что DllMain этого модуля будет вызвана до того, как вы получите через GetProcAddress функцию в этом модуле и вызовете её. Результаты будут полностью непредсказуемыми - например, глобальные переменные остаются не инициализированными. Наиболее вероятно, что вы получите AV.

Другой сценарий: вы создаёте новый поток в DLL_THREAD_ATTACH и ожидаете, пока он закончит инициализацию с помощью какой-либо техники инициализации. Такие действия держат ваш поток в DllMain пока загрузчик ОС держит свою блокировку. В сумме это может привести к deadlock-у.

В целом: если что-то - что угодно - пойдёт не так в DllMain хотя бы одного модуля, то весь процесс обречён.

Проблема в том, что определение этого "не так" в этом случае весьма и весьма расплывчато. Например, разработчики, использующие MC++, знают, что вам не следует даже мечтать о своей DllMain в библиотеке. А если вы это всё же сделаете, то вы можете очень-очень пожалеть об этом. Я думаю, что парни, работающие над CLR, хотели исправить это в релизе "Whidbey".

Chris Brumme перечисляет вещи, которые вы никогда и ни при каких условиях не должны делать в своих DllMain (**):
  • Динамические загрузки. Это включает в себя явные вызовы LoadLibrary/UnloadLibrary или вызовы чего угодно, что может к ним приводить.
  • Блокировки любого вида. Если вы попытаетесь получить блокировку, которую сейчас держит поток, которому вдруг понадобилась блокировка загрузчика ОС (которую в свою очередь держите вы), то вы получаете deadlock.
  • Вызовы в другие модули. Как уже было сказано, модуль, функцию которого вы пытаетесь вызвать, может быть не инициализирован или, наоборот, уже финализирован.
  • Запуск других потоков и синхронизация с ними. Как уже обсуждалось: поток может потребовать блокировку загрузчика ОС, которую держите вы.

Итак, что же всё это нам говорит?

DllMain - это оружие, из которого вы легко можете застрелиться

Скольких вы знаете людей, которые хоть раз делали такие грубейшие ошибки, как вызов CoInitialize в DllMain? Я знаю случаи, когда это делали в ответ на DLL_THREAD_ATTACH, что означает, что мы не только рискуем попасть на deadlock, но и что COM будет инициализирован в каждом потоке. Что ещё хуже, он может быть инициализирован с неверной потоковой моделью. А потом эти люди будут удивляться: как это у них получились STA-потоки в потоковых пулах. Или что-то более тонкое, как, например, вызовы системной функции, которая запускает рабочий поток в процессе своей работы? Как часто вы сами делали так?

Другой проблемой здесь является то, что все эти ужасы могут проявлять себя в очень ограниченных условиях. В большинстве случаев всё будет работать отлично, но условия гонки, лёгкие модификации DLL и порядка загрузки или другие факторы могут всё поменять. Что означает, что вы не узнаете о проблемах до начала отгрузок продукта. Ну, может это и приемлемо для пользовательских приложений (я имею ввиду, что такие вещи - это, конечно, всегда плохо, но по крайней мере в этом случае вред будет не слишком велик), но это точно плохо для серверов - особенно если мы говорим об уровне предприятия. Я не думаю, что это может стать угрозой безопасности (ну хотя бы той, с которой можно бороться), но случайные вылеты - это просто не хорошо.

Итак, давайте вернёмся к тому, что мы всё же можем делать в DllMain. Согласно MSDN, "функция точки входа должна выполнять только простые задачи инициализации и завершения работы".

Эти задачи могут включать в себя вызовы в Kernel32 (исключая LoadLibrary/LoadLibraryEx). Если вы подумаете, что это значит для вас, то обнаружите, что это весьма сильное ограничение. Далее, функции CRT (***), включая выделения памяти, не безопасны, если только вы не подключаете библиотеку статически. Это означает, что такие, казалось бы, безобидные вещи как "g_pMyGlobalObject = new CMyGlobalObject()" (аналог "MyGlobalObject := TMyGlobalObject.Create;" в Delphi - прим. пер.) теоретически могут вызывать сколь угодно отвратительные побочные эффекты, поскольку они используют malloc (аналог GetMem в Delphi - прим. пер.), которая динамически линкуется из msvcr*.dll (****).

Это оставляет нам примитивные типы, инициализацию объектов синхронизации... пожалуй и всё. И определённо - определённо - никакого управляемого кода (managed code).

К чему я клоню? Есть не так много вещей, которые можно делать легально в DllMain. Зато невероятно легко сделать что-то запретное - вам постоянно нужно быть уверенными, что вот эта вот функция, которую вы только что вызвали, никогда и ни при каких условиях не может выполнить что-либо запретное. Это делает невероятно сложным использование кода, написанного в другом месте.
А компилятор вам ничего не скажет. И код чаще всего будет работать, как будто так и должно быть... но иногда он будет вылетать.

Какие варианты нам остаются?
  • Просто скажите DllMain нет. Избегайте этого чёрта, как только возможно. Создавайте библиотеки с ключом /noentry. Пересмотрите свои способы работы с глобальными переменными. Делайте отложенную TLS-инициализацию (а для Delphi: просто используйте пакеты - прим. пер.).
  • Будьте очень осторожны. Иногда вам придётся использовать DllMain. Делайте полный анализ кода. Посмотрите, что делаете вы, и что делает ОС. Убедитесь, что все понимают, что DllMain - это особое место. Прочитайте и запомните поучительные истории-ужастики о людях, которые этого не знали.
    Вы можете сделать ещё одну вещь для минимизации ущерба: отключить вызовы в ваш DllMain для потоков - вы можете сделать это с помощью DisableThreadLibraryCalls. Чаще всего это хорошая идея во всех случаях, когда вам не нужно выполнять по-поточную инициализацию, потому что тогда загрузчику ОС уже не нужно вызывать ваш модуль каждый раз при старте нового потока.
  • Опасайтесь. А ещё лучше - бойтесь. Ну, просто оставьте всё, как оно есть. Ничто не вылетает прямо сейчас и у вас есть другие дела, чтобы ими заняться. Хороший план.

Серебряная облицовка: DllMain и диагностика утечек ресурсов

Есть один вид информации, который вы получаете через DllMain и не можете получить никакими другими путями. Если вы посмотрите на сигнатуру DllMain, вы увидите, что последний аргумент, несмотря на своё "зарезервированное" название lpReserved, на самом деле имеет смысл:
  • Если параметр dwReason равен DLL_PROCESS_ATTACH, то lpvReserved есть nil, если библиотека загружается динамически (т.е. через LoadLibrary(Ex)), и отличен от nil, если она прилинкована статически (т.е. через external 'имя-библиотеки').
  • Если параметр dwReason равен DLL_PROCESS_DETACH, то lpvReserved есть nil, если DllMain вызывается из-за FreeLibrary, и не равен nil, если DllMain вызывается при выгрузке библиотеки вместе с процессом.
Как вы видите, lpvReserved кое-что вам всё же сообщает. Хотя я не вижу, зачем вам может понадобиться информация о том, как вас загрузили - да, тут могут быть случаи, но я их просто не вижу - зато знать, как вас выгружают, может быть интересным.

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

Однако, есть также случаи, когда вы ожидаете, что библиотека будет выгружена конкретным способом, и вы можете использовать DllMain для проверки того, что библиотека действительно используется так, как планировалось. К примеру, если:
  • ваша DLL является COM сервером (и никак не используется по-другому), и
  • COM-хост является "хорошим", и
  • все ваши COM-объекты были корректно освобождены,
то тогда вы можете ожидать, что вы получите lpvReserved = nil - т.е. вас выгружают через FreeLibrary.

Вот что при этом происходит. Каждый COM-процесс с правильным поведением должен вызывать CoUninitialize в каждом потоке до его выхода. Внутри CoUninitialize вызывает DllCanUnloadNow на вашем модуле, которая возвращает TRUE, если все внешние ссылки были освобождены. В этом случае COM вызывает FreeLibrary, которая - если только нет других ссылок от LoadLibrary - выгружает вашу DLL. При этом вы получаете lpvReserved = nil. Если какие-то из этих условий не выполняются, то тогда ваша DLL остаётся в процессе до самого его выхода, и тогда вы получаете lpvReserved <> nil (я хотел бы поблагодарить Michael Entin - кому действительно пора завести свой блог - за помощь в складывании всех кусков вместе).

Поэтому, если - и это большое если - ваше приложение ведёт себя корректно, и никто не напортачил с загрузкой вашей DLL с LoadLibrary и забыл выгрузить её, то тогда lpvReserved <> nil означает, что какие-то из ваших COM-объектов не были освобождены. Ваш код ничего не может с этим сделать - ну разве что assert-нуться - и вам придётся самому исследовать эту ситуацию.

Этот подход не ограничен только утечками в COM - теоретически вы должны ожидать, что когда ваш модуль покидает этот мир, то он никого с собой не тащит. Вы можете проверить списки глобальных ресурсов и посмотреть: все ли они были освобождены. Но будьте очень-очень осторожны - не сделайте ничего, что поставит под угрозу загрузчик ОС: вспомните эти четыре пункта выше.

Читать далее.


Примечения переводчика:

(*) Фактически, DLL, написанные в Delphi, этим требованиям не удовлетворяют. Модуль System, к примеру, читает реестр в _FpuMaskInit (которая, к счастью, вызывается только в японской версии Windows). Модуль SysUtils показывает сообщение об ошибке (исключении в секциях initialization/finalization) с помощью MessageBox из User32.dll и т.д. А большинство не стандартных модулей вообще не озабочены особенностями DllMain и творят, что хотят (даже те, которые входят в поставку Delphi). В частности, очень часто можно такую видеть реализацию динамической загрузки DLL, при которой связывание (т.е. загрузка и вызовы GetProcAddress) располагают прямо в секции initialization. Пакеты Delphi таких проблем не имеют.

(**) В описании DllMain на MSDN также есть ссылка на документ "Best Practices for Creating DLLs" (в меню справа есть ссылка на закачку).

(***) Насколько я понимаю (или, скорее, не понимаю в C), CRT - это аналог RTL в Delphi, библиотеки run-time поддержки языка.

(****) Слава богу, этот ужас про объекты для Delphi не применим. Поскольку, если RTL подключается не статически, то это значит, что она находится в пакете - а значит, у нас нет проблем с DllMain. Т.е. для Delphi сами по себе функции RTL безопасно вызывать всегда (они либо статически подключены, либо находятся в пакете). Остаются только вызовы, которые могут приводить к LoadLibrary и т.п.

14 комментариев:

  1. Только что вляпался в эту засаду: виснет при выгрузке. Мне кажется, стоило бы дополнить этот перевод статьёй непосредственно про Дельфи - как с этим бороться и как обходить. Поскольку DLLMain и дельфийский DllProc - неочевидно, что это одно и то же. А уж до того, что begin-end в проекте DLL и тем более init секции в подключаемых модулях - тоже DLLMain, додуматься вообще очень сложно.
    Да, и ещё не очень понятно, как сделать нормальное освобождение ресурсов при выгрузке либы. И почему вообще возникает это зависание - т.к. везде идет речь только о моменте инициализации.

    ОтветитьУдалить
  2. Ну, я немного упомянул про Delphi в примечаниях. А второй пост из серии содержит пример на Delphi.

    (этот пост - первый в серии из 4-х постов)

    А общий рецепт уже сказан тут: держитесь подальше от DllMain. Просто избегайте её. Не помещайте код в initialization/finalization модулей - выносите его в отдельные процедуры инициализации, которые будет экспортировать DLL. Достаточно просто, если код находится под вашим контролем.

    Если это, по какой-то причине, невозможно, то есть простое "одностроковое" решение: использовать пакеты вместо DLL.

    Если и это не подходит - то увы, универсального решения нет. Придётся очень долго мучаться.

    ОтветитьУдалить
    Ответы
    1. Кстати, совет "использовать пакеты вместо DLL" работает тоже не во всех случаях. Только если пакеты статически используются главным EXE-модулем.
      Но бывают в жизни извращения, например, у нас: "плагины" оформляются в виде DLL и могут быть написаны на любом (нативном) языке. Но чаще всего пишутся на той же версии Delphi и (статически) используют пакеты от той же версии Delphi, что и главный EXE-модуль. И если они используют пакет IBXPress, который главный EXE-модуль статически не линкует, то соответствующая IBXPressXXX.BPL динамически загружается и выгружается вместе с DLL-плагином, и все прелести вылезают наружу.

      Удалить
  3. >А общий рецепт уже сказан тут: держитесь подальше от DllMain. Просто избегайте её. Не помещайте код в initialization/finalization модулей - выносите его в отдельные процедуры инициализации, которые будет экспортировать DLL. Достаточно просто, если код находится под вашим контролем.

    Насчет initialization/finalization - мда, а вещь-то весьма распространенная, в т.ч. и в RTL.
    Однако и отдавать наружу все "бразды правления" внутренними ресурсами тоже нехорошо. Для инициализации я вижу такое решение: создать отдельную процедуру Init, которая будет выставлять глобальный флаг. В каждой же экспортируемой функции проверять этот флаг и вызывать Init при необходимости.
    Но это ещё ладно. А вот как быть с освобождением ресурсов? Почему по большей части именно там случаются страшные вещи? Вроде бы все либы уже загружены, и системе неважно, как и что там будет удалено. Или же это следствие некорректной загрузки, которая сработала, но при выгрузке дает о себе знать?

    ОтветитьУдалить
  4. Ну, тут надо бы на конкретный случай смотреть.

    А если в общих словах, то выгрузка - это загрузка наоборот. Т.е. к ней применимы те же слова.

    В частности, если DLL применяет технику отложенной инициализации (инициализация по первому вызову функции), а финализацию бездумно откладывает на свою выгрузку - сценарий с зависанием или иными проблемами при выгрузке будет наиболее вероятен.

    Если конкретно - то надо же по коду смотреть. Что там и куда. Так просто не скажешь же.

    Ну и вообще: техника с выделенными Init/Done - вполне стандартный приём: CoInitialize, InitCommonControlsEx , OleInitialize, etc.

    ОтветитьУдалить
  5. > Модуль System, к примеру, читает реестр в _FpuMaskInit (которая, к счастью, вызывается только в японской версии Windows).

    К счастью, поскольку это используется для исправления бага в Win95, в Delphi XE этого вызова уже нет.

    ОтветитьУдалить
  6. Кажется, определил, отчего идет зависание. ICS (сетевая библиотека) использует динамическую подгрузку функций winsock по мере необходимости, и при закрытии сокета вызывает shutdown, что в глубине кода приводит к вызову GetProcAddress, именно она и подвисает. Тем не менее я всё еще не понимаю, почему такая проблема возникла: специально в отладчике смотрел, ни одна DLL еще не выгружена на этот момент (включая winsock), а GetProcAddress прописана в kernel32.

    ОтветитьУдалить
  7. Про DLL_PROCESS_DETACH и lpvReserved не знал. Спасибо!

    Сейчас прикручу assert :)

    ОтветитьУдалить
  8. Кто бы ещё рассказал про DllMain разработчикам Embarcadero?
    Кусочек из initialization модуля IBX.IBSQLMonitor (Delphi XE6):

    if Assigned(FReaderThread) then
    begin
    if not Assigned(FWriterThread) then
    FWriterThread := TWriterThread.Create;
    FWriterThread.WriteSQLData(' ', tfMisc);
    end;
    CloseThreads;

    ОтветитьУдалить
  9. > то маловероятно, что вы забудете сменить базу своим модулям (re-base)

    Blast from the p... future? re-base considered useless, потому что по-пацански теперь,10 лет спустя, ForceASLR :-D

    ОтветитьУдалить
  10. Этот комментарий был удален автором.

    ОтветитьУдалить

Можно использовать некоторые HTML-теги, например:

<b>Жирный</b>
<i>Курсив</i>
<a href="http://www.example.com/">Ссылка</a>

Вам необязательно регистрироваться для комментирования - для этого просто выберите из списка "Анонимный" (для анонимного комментария) или "Имя/URL" (для указания вашего имени и ссылки на сайт). Все прочие варианты потребуют от вас входа в вашу учётку.

Пожалуйста, по возможности используйте "Имя/URL" вместо "Анонимный". URL можно просто не указывать.

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

Примечание. Отправлять комментарии могут только участники этого блога.