DllMain и блокировка загрузчика — почему говорят «в инициализации DLL ничего не делайте»

· Обновлено: · · Windows, DLL, Разработка под Windows, C++, Устранение неполадок, Многопоточность, Win32 API

История изменений (первая версия, опубликована 22 Aug 2026)
Первая публикация
Цитирование статьи(DOI (зарегистрированный архив): 10.5281/zenodo.22176717)

Приведённые ниже DOI относятся к ранее зарегистрированным архивным версиям, которые могут отличаться от текущего текста. Для ссылки на текущий текст используйте URL этой страницы.

Го Комура (2026). DllMain и блокировка загрузчика — почему говорят «в инициализации DLL ничего не делайте». KomuraSoft LLC. https://comcomponent.com/ru/blog/dllmain-loader-lock/

DOI (зарегистрированный архив)
10.5281/zenodo.22176717
DOI (последняя зарегистрированная версия)
10.5281/zenodo.22176718

«Когда мы загружаем свою DLL, LoadLibrary не возвращает управление.» «Зависает только на определённых ПК или только при старте службы.» При таких сбоях одно из первых мест, которое стоит проверить, — код инициализации DLL: DllMain и всё, что он вызывает.

DllMain — не обычная функция инициализации приложения. Это функция, которую ОС вызывает, удерживая блокировку загрузчика, поэтому то, что в ней можно делать, жёстко ограничено. Позиция Microsoft, что «идеальный DllMain — пустая заглушка», следует из того, что это ограничение затрагивает не только вашу DLL, но и другие DLL и потоки в процессе.1

Статья рассчитана на разработчиков, которые пишут под Windows DLL, плагины и обёртки на C++/CLI. Тема разбирается в таком порядке: почему останавливается → куда перенести инициализацию → как завершать → как расследовать зависание.

1. Сначала вывод — держите DllMain маленьким и меняйте момент выполнения

Искать безопасный способ что-то написать внутри DllMain менее полезно, чем сократить работу, которая там выполняется. Что оставлять, решайте в таком порядке.1

Решение Базовая политика Где читать дальше
Когда инициализировать То, что можно решить на этапе компиляции, делайте статически; остальное откладывайте до первого использования после завершения загрузки Глава 5
Что вызывать из DllMain Избегайте LoadLibrary / FreeLibrary, синхронизации с другими потоками и использования User, Shell, COM и им подобных. Проверяйте и косвенные вызовы Глава 3
Что делать при завершении Различайте случай, когда выгружается только DLL, и случай, когда завершается весь процесс Глава 6

Легко упустить, что те же ограничения действуют на конструкторы и деструкторы глобальных и статических объектов C++. В C++/CLI нужно ещё убрать все пути, по которым под блокировкой загрузчика выполняется MSIL. Пустое тело DllMain само по себе ещё ничего не доказывает.23

Если вы сейчас расследуете зависание, начните с главы 7; если пересматриваете проект, читайте главы 2–6 по порядку — так каждое запрещение стыкуется со способом его обойти.

2. Механизм — DllMain вызывается внутри блокировки загрузчика

2.1 Вызывается не только при загрузке, но и при старте и завершении потоков

DllMain — точка входа, которую загрузчик ОС вызывает, когда DLL входит в процесс или поток либо покидает их. Уведомлений четыре.2

Уведомление Момент
DLL_PROCESS_ATTACH Когда DLL загружается в процесс
DLL_THREAD_ATTACH Когда в процессе запускается новый поток
DLL_THREAD_DETACH Когда поток завершается нормально
DLL_PROCESS_DETACH Когда DLL выгружается или когда процесс завершается

Уведомление о старте потока приходит не только той DLL, которая создала поток, а каждой DLL, уже загруженной в процесс. DllMain — не «код, который один раз отработал при загрузке моей DLL». В процессе, который часто создаёт потоки, он выполняется на каждое уведомление.2

Если уведомления не нужны, один из вариантов — вызвать DisableThreadLibraryCalls внутри DLL_PROCESS_ATTACH. В DLL, слинкованной со статическим CRT, её использовать нельзя; у статического TLS есть своё условие — это решение отдельно разобрано в разделе 5.3.4

2.2 Вызов при удерживаемой блокировке — исходная точка всех ограничений

Чтобы загрузка, выгрузка и уведомления DLL оставались согласованными, загрузчик ОС сериализует их одной блокировкой загрузчика на процесс. Важно, что он захватывает эту блокировку до вызова DllMain и продолжает удерживать её, пока DllMain выполняется.1

В это время любой другой поток того же процесса, который пытается загрузить DLL или пройти уведомление о старте или завершении потока, ждёт освобождения блокировки. Это не место, куда можно вставить долгое ожидание только потому, что так удобно вашей DLL.

Почему работа в DllMain влияет на уведомления DLL во всём процессеЗагрузчик захватывает общепроцессную блокировку загрузчика до вызова DllMain, а загрузки DLL и уведомления потоков на других потоках ждут её освобождения, поэтому работа в DllMain затрагивает и другие DLL, и другие потокиЗагрузчик захватывает общую блокировкуВыполняется DllMainDllMain возвращает управлениеБлокировка загрузчика освобождаетсяЗагрузка DLL или уведомление на другом потокеЖдёт освобождения той же блокировкиОжидавшая работа может продолжиться

Рис. 1: Пока выполняется DllMain, общая блокировка удерживается, поэтому ожидание там останавливает продвижение других DLL и потоков.

Запреты ниже становятся понятны, если спросить: «нужна ли этой работе напрямую или косвенно блокировка загрузчика или инициализация другой DLL?»

3. Почему останавливается — четыре пути к запретам

3.1 Вызов того, что загружает другую DLL

Избегайте вызовов LoadLibrary / FreeLibrary из DllMain. LoadLibrary создаёт циклические зависимости порядка загрузки и приводит к тому, что начинает использоваться DLL, чей код инициализации ещё не выполнился. На стороне завершения точно так же есть риск обратиться к DLL, которая уже обработана или освобождена.2

То, что вы сами не написали LoadLibrary, ещё не делает код безопасным. Часть функций User, Shell и COM внутри загружает другие системные компоненты и может задеть ещё не инициализированный или уже освобождённый компонент — и получить нарушение доступа.2

База того, что можно безопасно вызывать, — функции Kernel32.dll, которые не загружают другие DLL, потому что Kernel32.dll к моменту DllMain уже гарантированно загружен. Например, можно создавать объекты синхронизации вроде критических секций и мьютексов и пользоваться TLS. Официальная документация, однако, прямо говорит, что полного списка безопасных функций не существует. Не рассуждайте «это Kernel32, значит можно всё» или «объект синхронизации создать можно, значит можно ждать другие потоки».2

3.2 Ожидание внутри DllMain завершения другого потока

Классическая взаимная блокировка возникает при выгрузке DLL: DllMain просит рабочий поток остановиться и затем ждёт его завершения.

Даже когда рабочий закончил свою работу, ему нужно пройти уведомление DLL_THREAD_DETACH при выходе из потока. Этому уведомлению нужна блокировка загрузчика, которую удерживает ожидающий DllMain. В результате DllMain ждёт рабочего, а рабочий ждёт возврата из DllMain.5

Почему ожидание потока в DllMain даёт взаимную блокировкуDllMain, удерживая блокировку загрузчика, ждёт завершения рабочего потока, но завершающийся рабочий поток ждёт освобождения блокировки загрузчика для уведомления DLL_THREAD_DETACH, поэтому они ждут друг друга и взаимно блокируютсяРабочий потокDllMainЗагрузчик (удерживает блокировку)Рабочий потокDllMainЗагрузчик (удерживает блокировку)Уведомлению о выходе нужна блокировка загрузчикаDllMain ждёт, удерживая блокировку, W ждёт блокировкуУведомить DLL_PROCESS_DETACHЗапросить выход и ждать завершенияЗакончить работу и идти к выходу из потока

Рис. 2: Даже после того, как рабочий закончил свою работу, он не может пройти уведомление о выходе из потока, поэтому ожидание выхода внутри DllMain никогда не заканчивается.

Это не случай «остановится, если не повезёт»: взаимное ожидание держится структурно. Очистку, нужную при выгрузке, разбирает глава 6 отдельно от работы при завершении процесса.

3.3 Своя блокировка и блокировка загрузчика захватываются в обратном порядке

Дело не только в старте и завершении потоков: API вроде GetModuleHandle тоже внутри нуждаются в блокировке загрузчика. Когда пересекаются два следующих пути, порядок захвата блокировок инвертируется.6

  • Со стороны DllMain код, который уже удерживает блокировку загрузчика, пытается взять частную блокировку G.
  • Со стороны рабочего код, который уже удерживает частную блокировку G, вызывает API и пытается взять блокировку загрузчика.
Инверсия порядка между блокировкой загрузчика и частной блокировкойDllMain идёт за частной блокировкой, удерживая блокировку загрузчика, а рабочий поток идёт за блокировкой загрузчика, например из-за GetModuleHandle, удерживая частную блокировку, поэтому порядок захвата инвертируется и они взаимно блокируютсяDllMain: удерживает блокировку загрузчикаИдёт за частной блокировкой GРабочий: удерживает частную блокировку GИдёт за блокировкой загрузчикаВзаимная блокировка из-за инвертированного порядка захватаВнутри требуется GetModuleHandle и другим

Рис. 3: Когда одна сторона берёт блокировку загрузчика, а затем G, а другая берёт G, а затем блокировку загрузчика, каждая ждёт, пока другая отпустит.

Официальная рекомендация — считать блокировку загрузчика вершиной иерархии блокировок приложения, то есть блокировкой, которую захватывают первой. К моменту, когда вы уже внутри DllMain, эта блокировка уже удерживается. Смотрите не только имя вызываемой функции, но и какие блокировки вы удерживаете в момент вызова.6

3.4 Даже простое создание потока оставляет проблемы ожидания старта и срока жизни

CreateThread внутри DllMain тоже не рекомендуется. Новый поток не начнёт выполнять свою функцию, пока не обработано уведомление DLL_THREAD_ATTACH. Поскольку текущий DllMain удерживает блокировку загрузчика, ожидание внутри этого DllMain старта или завершения потока даёт взаимную блокировку.5

Не ждать — тоже не решение всех проблем. Если DLL выгружается после возврата из DllMain, но до того, как созданный поток начал выполняться, стартовый адрес потока указывает на уже освобождённый код, и падение остаётся возможным.5

Две проблемы, которые остаются, когда поток создают в DllMainПоток, созданный в DllMain, ждёт блокировку загрузчика для уведомления о старте, поэтому если DllMain ждёт его старта или завершения, они взаимно блокируются, а даже если DllMain вернётся без ожидания, срок жизни кода заканчивается и происходит падение, если DLL выгружается до того, как поток начнёт выполнятьсяДаНетПоток создан внутри DllMainНовый поток ждёт уведомление о стартеЖдать старта или завершения внутри DllMain?Взаимное ожидание при удерживаемой блокировкеВозврат из DllMainDLL выгружена до того, как поток начал выполнятьсяКода по стартовому адресу уже нет

Рис. 4: Не ждать внутри DllMain и защитить срок жизни DLL, которой пользуется созданный поток, — два отдельных требования.

4. Два места, которые опасны даже при пустом DllMain

4.1 Динамическая инициализация глобальных и статических объектов

В DLL, слинкованной с CRT (библиотекой времени выполнения C/C++), конструкторы и деструкторы глобальных и статических объектов C++ выполняются через точку входа CRT. Они фактически являются частью DllMain и подчиняются тем же ограничениям.2

Вынести в конструктор загрузку файла конфигурации, запуск логгера, инициализацию COM или запуск потока — значит только спрятать место вызова; момент выполнения по-прежнему внутри блокировки загрузчика. Если в этой сложной работе есть загрузка другой DLL или синхронизация с потоками, это ровно так же опасно, как написать это в теле DllMain.

Как инициализация глобального объекта становится минойЗагрузка DLL захватывает блокировку загрузчика, и конструкторы глобальных объектов выполняются через CRT, поэтому LoadLibrary, синхронизация с потоком или инициализация COM внутри них — это выполнение того, что DllMain запрещаетЗагрузка DLL (блокировка загрузчика захвачена)Точка входа CRTКонструктор глобального объектаРабота, эквивалентная LoadLibraryЗапустить поток и ждать его завершенияИспользование COM или User32Всё это запреты DllMain

Рис. 5: Просматривайте не только тело DllMain, но и инициализацию и завершение статических объектов, вызываемые из CRT.

Компиляционно-временную константную инициализацию, например всё, что можно сделать constexpr, отделяйте от сложной инициализации во время выполнения. Динамическую инициализацию с вызовами функций откладывайте и устраивайте так, чтобы первый доступ тоже происходил вне DllMain.

4.2 MSIL, выполняемый в вызываемом коде C++/CLI

Обёртка нативной DLL на C++/CLI разобрана в статье Вызов нативной DLL из C#: обёртка на C++/CLI или P/Invoke. В этой конфигурации следите за путями, по которым под блокировкой загрузчика выполняется MSIL (управляемый код). Если для выполнения MSIL нужна инициализация CLR или загрузка другой сборки, возможна взаимная блокировка.3

Компилятор выдаёт предупреждение C4747 для кода, в котором DllMain напрямую пытается выполнить MSIL. Но косвенное выполнение через функцию в другом модуле он обнаружить не может. Отсутствие предупреждения само по себе не основание считать код безопасным. Динамические инициализаторы статических объектов тоже входят в область действия.3

Можно ли обнаружить выполнение MSIL под блокировкой загрузчикаКод, в котором DllMain выполняет MSIL напрямую, компилятор обнаруживает предупреждением C4747, а косвенное выполнение через функцию в другом модуле — нет, поэтому его нужно предотвращать ревью дерева вызовов и нативной компиляцией на всём путиВызов из DllMainВыполняет MSIL напрямуюВыполняет через другой модульОбнаруживается предупреждением C4747Компилятор не обнаруживаетПредотвращать ревью и #pragma unmanaged

Рис. 6: Помимо прямого пути, который ловит C4747, просматривайте вызовы, идущие через другой модуль.

Средство — скомпилировать DllMain и каждую достижимую из него функцию как нативный код через #pragma unmanaged либо вообще не иметь DllMain. Даже во втором случае не упускайте косвенные пути вроде статических инициализаторов.3

5. Проектирование инициализации — сделать статическим, отложить, оставить только минимум

5.1 Прежде чем оставлять что-то в DllMain, спросите, нельзя ли сменить момент выполнения

Официальная база — завершить на этапе компиляции всё, что можно, и остальное откладывать как можно дальше. Остаётся, как исключение и в минимуме, только работа, которую нужно рано обнаружить как отказ загрузки.1

Например, может быть требование сделать саму загрузку DLL неуспешной, потому что зависимый файл конфигурации повреждён. Даже тогда сузьте это до «попытаться выполнить нужную работу и сразу отказать», а не сначала прогнать прочую инициализацию и отказать потом. Это не исключение, которое разрешает сложную инициализацию в момент загрузки.1

Принципы проектирования инициализации DLLСначала рассмотреть, нельзя ли сделать инициализацию статической на этапе компиляции; если нельзя, по умолчанию откладывать до первого использования и оставлять в DllMain только минимум, который нужно рано обнаружить как отказ загрузкиДаНетНетДаМожно решить на этапе компиляции?Сделать статической инициализациейНужно обнаружить отказ при загрузке?Отложить до первого использования (по умолчанию)Делать в DllMain только минимумИсключение через INIT_ONCE или function-local static

Рис. 7: Сначала рассмотрите статическую и отложенную инициализацию и оставляйте в DllMain только минимум, которому нужно раннее обнаружение.

5.2 Отложенную инициализацию нужно проектировать до «кто вызывает её первым»

Для взаимного исключения при первом использовании можно взять одноразовую инициализацию через INIT_ONCE или function-local static в C++ (magic static). Идея — сложные глобальные объекты перенести в указатель, создаваемый при первом доступе, или в function-local static.

Однако одно только откладывание ещё не выводит вас из блокировки загрузчика. Ограничение снимается только когда первый доступ приходит из «обычного API, вызванного после завершения загрузки DLL». Тогда это можно проектировать как обычный код инициализации, которому доступен почти весь Windows API.1

Наоборот, если этот первый доступ происходит из DllMain или статического инициализатора, инициализация всё равно выполняется под блокировкой загрузчика. Мало вынести функцию инициализации: подтвердите кто вызывает её первым и когда.

Когда отложенная инициализация выходит из блокировки загрузчикаЕсли первый доступ отложенной инициализации приходит из обычного API после завершения загрузки, инициализировать можно вне блокировки загрузчика, а если из DllMain или статического инициализатора — под теми же ограничениямиОбычный API после завершения загрузкиDllMain или статический инициализаторОткуда приходит первый доступ?Инициализировать вне блокировки загрузчикаИнициализировать под теми же ограничениямиИсключение через INIT_ONCE или function-local staticПеренести и момент первого доступа

Рис. 8: INIT_ONCE и function-local static обеспечивают взаимное исключение инициализации; они не гарантируют, что её вызовут вне блокировки загрузчика.

5.3 DisableThreadLibraryCalls решайте по трём условиям

В DLL, которая не зависит от уведомлений о потоках, вызов DisableThreadLibraryCalls в DLL_PROCESS_ATTACH останавливает уведомления DLL_THREAD_ATTACH / DLL_THREAD_DETACH. В процессе, который часто создаёт потоки, это снижает накладные расходы уведомлений.4

Перед применением проверьте статический CRT, статический TLS и есть ли что-то, что пользуется уведомлениями. DLL, слинкованная со статическим CRT, вызывать её не должна: самому CRT нужны уведомления о потоках. Когда действует статический TLS через thread_local или __declspec(thread), сам вызов завершается ошибкой и возвращает FALSE.4

Решение, вызывать ли DisableThreadLibraryCallsDLL, слинкованная со статическим CRT, не должна её вызывать, а при действующем статическом TLS сам вызов завершается ошибкой, поэтому её не вызывают; если ни то ни другое и уведомления о потоках не нужны, её можно вызвать в DLL_PROCESS_ATTACH с проверкой возвращаемого значения, чтобы снизить стоимость уведомленийДаНетДаНетНетДаСлинкована со статическим CRT?Вызывать нельзяИспользует статический TLS?Вызов всё равно завершится ошибкой (FALSE)Нужны уведомления о потоках?Вызвать в ATTACH (проверить возвращаемое значение)Не вызывать; обрабатывать уведомления

Рис. 9: Отличайте статический CRT, где её нельзя использовать, от статического TLS, где она завершается ошибкой, и даже когда уведомления не нужны, проверяйте возвращаемое значение.

Это оптимизация, которую стоит рассматривать для обычной DLL с динамически слинкованным CRT, удовлетворяющей этим условиям. DisableThreadLibraryCalls существует, чтобы уменьшить уведомления; это не средство делать сложную инициализацию в DllMain.

6. Завершение — отделяйте выгрузку DLL от завершения процесса

6.1 Тот же DLL_PROCESS_DETACH, но то, что остаётся после, различается

DLL_PROCESS_DETACH доставляется и когда выгружается только DLL, и когда завершается весь процесс. Имя уведомления одно, а предпосылки очистки разные.5

При выгрузке через FreeLibrary процесс после этого продолжает работать. Поэтому потоки нужно остановить, а открытые дескрипторы, выделенные ресурсы, состояние, которое нужно сохранить, и так далее — корректно почистить. Как показано в разделе 3.2, однако, ждать ради этого внутри DllMain естественного завершения потока — взаимная блокировка.5

Ещё лучше избегать проекта, в котором DLL, которую можно выгрузить, вообще владеет потоками; перенести владение потоками на сторону EXE — самый безопасный вариант. Для существующего проекта, в котором потоками владеет DLL, рассматривайте следующий протокол остановки вместе с его ограничениями, никогда одно без другого.

6.2 Официально описанный протокол остановки, когда DLL владеет рабочим

В рекомендациях Microsoft для выгрузки описана процедура остановки, которая ждёт не «естественного завершения потока», а «сигнала, что достигнуто согласованное состояние».5

  1. Сторона DllMain сигнализирует рабочему остановиться через событие.
  2. Рабочий сворачивает текущую работу до согласованного состояния, сигнализирует о завершении и входит в бесконечное ожидание.
  3. Сторона DllMain подтверждает согласованное состояние и завершает поток через TerminateThread.

Выглядит грубо, но это задокументировано при ограничении, что ожидание естественного выхода сталкивает уведомление о выходе с блокировкой загрузчика. Предпосылка — что работа по достижению согласованного состояния тоже подчиняется тем же ограничениям, что и DllMain. Если эта работа входит в загрузку другой DLL или в ожидание блокировки загрузчика, взаимной блокировки со стороной, которая ждёт сигнала, не избежать.5

При выгрузке ждать не естественного выхода, а завершения согласованияDllMain сигнализирует рабочему остановиться, рабочий достигает согласованного состояния, соблюдая те же ограничения, что и DllMain, сигнализирует обратно и входит в бесконечное ожидание, а DllMain подтверждает согласованное состояние и затем завершает потокРабочий потокDllMain (при выгрузке)Рабочий потокDllMain (при выгрузке)Ждут сигнала согласованности, а не естественного выходаСигнал остановиться через событиеДойти до согласованного состояния под теми же ограничениямиСигнал, что согласованность достигнутаВойти в бесконечное ожиданиеПодтвердить согласованность, затем TerminateThread

Рис. 10: Даже при этом протоколе работа, которая устанавливает согласованное состояние, не должна ждать блокировку загрузчика.

Это не значит, что любой выполняющийся поток можно просто оборвать. Сначала рассмотрите, нельзя ли владеть потоком вне DLL.

6.3 При завершении процесса идеал — вернуть управление, ничего не делая

К моменту, когда при завершении процесса доставляется DLL_PROCESS_DETACH, другие потоки уже завершились или принудительно прерваны, и на согласованность адресного пространства полагаться нельзя. Состоянию зависимых DLL и сред выполнения тоже нельзя доверять, поэтому такая очистка, как освобождение памяти, становится опасной, а не полезной. Официальная рекомендация тоже говорит, что идеальный обработчик в этом случае пуст.5

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

Предпосылки очистки различаются между выгрузкой DLL и завершением процессаКогда выгружается только DLL, процесс продолжает работать, поэтому ресурсы нужно почистить, но не ждать естественного выхода внутри DllMain; при завершении процесса не полагаться на согласованность других потоков и ресурсов, по сути вернуть управление ничего не делая, а состояние для сохранения заранее записать в коде завершения приложенияВыгрузка только DLLЗавершение процессаПричина DLL_PROCESS_DETACH?Процесс после этого продолжает работатьКорректно почистить оставшиеся ресурсыНе ждать естественного выхода внутри DllMainСостояние других потоков и ресурсов неопределённоПо сути вернуть управление ничего не делаяСохранить заранее в коде завершения приложения

Рис. 11: Решайте не только «нужна ли очистка», но и продолжает ли процесс работать и можно ли доверять ресурсам, которыми пользуетесь для очистки.

7. Расследование зависания — найти сторону, которая ждёт блокировку загрузчика, и сторону, которая её удерживает

7.1 В дампе подтвердить пару взаимно ожидающих потоков

Снимите дамп в момент зависания и посмотрите стек каждого потока. Типичный отпечаток — пара: поток, ждущий блокировку внутри функции загрузчика в ntdll.dll, имя которой начинается с Ldr, и поток, ждущий чего-то ещё внутри DllMain или статического инициализатора (dynamic initializer).

Поток, остановившийся посреди вызова LoadLibrary, — ещё один типичный участник. Когда связывается отношение между стороной, которая ждёт блокировку, и стороной, которая её удерживает, ожидая чего-то ещё, почти наверняка можно заключить, что это взаимная блокировка вокруг блокировки загрузчика.

Подтверждение взаимного ожидания блокировки загрузчика по дампу зависанияВ стеке каждого потока искать ожидание блокировки в функциях Ldr и ожидание внутри DllMain или статического инициализатора, подтвердить, что они ждут друг друга, и если типичная пара не видна, расследовать и другие виды зависанияДаНе видноСнять дамп во время зависанияПосмотреть стек каждого потокаОжидание блокировки в функциях LdrОжидание внутри DllMain или статического инициализатораСвязывается ли отношение взаимного ожидания?Взаимная блокировка вокруг блокировки загрузчикаРасследовать и другие виды зависания

Рис. 12: Не решайте по одному стеку; ищите пару стороны, которая ждёт блокировку загрузчика, и стороны, которая её удерживает, ожидая.

Условия вроде «иногда при старте», «только на конкретной машине» или «только при запуске как служба» тоже подсказки. Взаимное ожидание складывается, когда загрузка DLL совпадает со стартом или завершением потока, поэтому различия среды и момента выполнения меняют, как это проявляется.

7.2 Предотвращать через Application Verifier и ревью вызываемого кода

Запуск тестов с включённым Application Verifier обнаруживает типичные ошибки DllMain во время выполнения.1 В C++/CLI не игнорируйте предупреждение C4747 и проверяйте косвенные пути, которые предупреждение не ловит, с точки зрения раздела 4.2.

В ревью ищите среди работы, достижимой из DllMain, функции, которые косвенно вызывают LoadLibrary. К проверке относятся инициализация COM, часть возможностей CRT и импорты с отложенной загрузкой (delay-load). Особенно легко упустить, что первый вызов delay-load импортированной функции внутри становится LoadLibrary. Даже если имя функции живёт вне DllMain, пока вызывающий — DllMain, ограничение остаётся.

8. Итог — смотрите не на имена функций, а на «когда и под какой блокировкой это выполняется»

Ограничения DllMain можно понять из одной точки: его вызывают, удерживая общепроцессную блокировку загрузчика. В ту же область входят не только ваш собственный DllMain, но и статическая инициализация и завершение CRT и косвенные вызовы C++/CLI.

Пересмотр начинайте с вопроса, нельзя ли сделать инициализацию статической и нельзя ли остальное отложить до завершения загрузки. Проверяйте вплоть до первого доступа отложенной работы и, если уведомления не нужны, рассматривайте DisableThreadLibraryCalls после проверки условий статического CRT и статического TLS.

На стороне завершения отделяйте выгрузку только DLL от завершения всего процесса. Если DLL владеет рабочим, следуйте протоколу сигнал — проверка согласованности — завершение и по возможности переносите владение потоками на сторону EXE. DllMain при завершении процесса приближайте к пустому и сохранение данных кладите в штатное завершение приложения.

Порядок пересмотра DllMain и того, что он вызываетОсмотреть область, включая не только DllMain, но и статическую инициализацию и косвенные вызовы, перенести инициализацию в статическую или после загрузки, спроектировать остановку и очистку по причине завершения, затем подтвердить Application Verifier и дампамиDllMain, статическая инициализация и вызываемыеПеренести инициализацию в статическую или после загрузкиПроверить и первый доступ отложенной работыСпроектировать очистку по причине завершенияПодтвердить Verifier, предупреждениями и дампами

Рис. 13: Если отслеживать не только что делает инициализация, но и когда она выполняется и каков её срок жизни при завершении, запреты можно трактовать как единую политику проектирования.

Вопрос, который стоит задать на пограничном случае: «эту работу допустимо выполнять, удерживая блокировку загрузчика?» Не запихивать сомнительную инициализацию в DllMain, а менять момент её выполнения — исходная точка безопасного проектирования DLL.

Похожие статьи

Смежные области консультирования

KomuraSoft LLC занимается расследованием причин зависаний и взаимных блокировок при старте или загрузке DLL (анализ дампов), ревью проекта вокруг DllMain и статической инициализации и переработкой обёрток C++/CLI и плагинных DLL к безопасной схеме инициализации. К нам можно обратиться даже на трудно воспроизводимой стадии «зависает при старте только в определённой среде».

Справочные ссылки

  1. Microsoft Learn, Dynamic-Link Library Best Practices. О том, что DllMain вызывается при удерживаемой блокировке загрузчика, поэтому функции, которые можно вызывать, жёстко ограничены; о том, что идеальный DllMain — пустая заглушка, а инициализацию следует откладывать как можно дальше; о рекомендации компиляционно-временной статической инициализации; о том, чтобы делать только минимум для отказов, которые нужно обнаружить рано; и об обнаружении типичных ошибок DllMain через Application Verifier. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  2. Microsoft Learn, DllMain entry point. О том, что в точке входа следует выполнять только простую инициализацию и завершение; почему нельзя вызывать LoadLibrary / FreeLibrary (циклический порядок загрузки и использование DLL до инициализации или после завершения); о том, что Kernel32.dll гарантированно загружен, поэтому его можно вызывать в диапазоне, который не загружает другие DLL; о том, что полного списка безопасных функций не существует; о том, что функции User, Shell и COM вызывают нарушения доступа; о том, что уведомления DLL сериализованы, поэтому обмен с другими потоками или процессами приводит к взаимным блокировкам; и о том, что те же ограничения действуют на конструкторы и деструкторы статических объектов при линковке с CRT. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7

  3. Microsoft Learn, Initialization of Mixed Assemblies. О том, что нельзя выполнять MSIL под блокировкой загрузчика; о том, что DllMain и его дерево вызовов нельзя компилировать в MSIL и это закрывают через #pragma unmanaged; о том, что предупреждение C4747 выдаётся, когда DllMain пытается выполнить MSIL напрямую, а косвенное выполнение через другой модуль не обнаруживается; и о том, что динамические инициализаторы статических объектов могут вызвать ту же проблему. ↩ ↩2 ↩3 ↩4

  4. Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). Об отключении уведомлений DLL_THREAD_ATTACH / DLL_THREAD_DETACH, чтобы снизить накладные расходы при создании и уничтожении потоков; о том, что её нельзя вызывать из DLL, слинкованной со статическим CRT; и о том, что оптимизация не выполняется, когда действует статический TLS (thread_local или __declspec(thread)). ↩ ↩2 ↩3

  5. Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. О структуре, которая взаимно блокируется, когда DllMain ждёт завершения потока (уведомлению DLL_THREAD_DETACH при выходе из потока нужна блокировка загрузчика); о протоколе остановки потоков при выгрузке (сигнал событием, подтверждение согласованного состояния, затем завершение); о DLL_PROCESS_DETACH при завершении процесса, где другие потоки уже принудительно завершены, согласованность адресного пространства не гарантирована, а идеальный обработчик пуст; и о том, что создание потока в DllMain оставляет уведомления в очереди при незавершённой инициализации и порождает проблемы. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7 ↩8

  6. Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. Об определении иерархии блокировок и всегда захватывать в одном порядке; о том, что загрузчик захватывает блокировку загрузчика до вызова DllMain, поэтому блокировка загрузчика должна стоять на вершине иерархии; о соблюдении порядка захвата между API, которые берут блокировку загрузчика косвенно, например GetModuleHandle, и частными блокировками; и о конкретном примере взаимной блокировки из-за инверсии порядка захвата. ↩ ↩2

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

Эти страницы показывают тему статьи в более широком контексте услуг и решений.

Статья напрямую связана со следующими услугами.

Частые вопросы

Вопросы, которые часто возникают при консультациях по теме статьи.

Неужели в DllMain действительно нельзя делать ничего?
«Ничего не делать» — не преувеличение, а официальная политика проектирования: сама Microsoft пишет, что идеальный DllMain — почти пустая заглушка. Безопасно вызывать только функции Kernel32.dll, который к моменту DllMain уже гарантированно загружен, и только те из них, которые сами не загружают другие DLL. Например, можно создавать критические секции и мьютексы и пользоваться TLS. Напротив, LoadLibrary/FreeLibrary, синхронизация с другими потоками и вызовы User32, Shell, COM и им подобных запрещены: они приводят к взаимным блокировкам и нарушениям доступа. Если сомневаетесь, нужна ли инициализация именно здесь, не делайте её в DllMain — отложите до первого использования.
Попадают ли конструкторы глобальных переменных C++ (статических объектов) под те же ограничения DllMain?
Да. Если DLL слинкована с CRT (библиотекой времени выполнения C++), конструкторы и деструкторы глобальных и статических объектов выполняются через точку входа CRT и по сути являются частью DllMain. Значит, вызов LoadLibrary из конструктора, запуск другого потока с ожиданием его завершения, инициализация COM — всё это та же опасность, что и те же действия внутри DllMain. Глобальный объект со сложной инициализацией лучше хранить как указатель и создавать при первом доступе либо сделать локальной static-переменной внутри функции, чтобы работа шла уже вне DllMain.
Стоит ли вызывать DisableThreadLibraryCalls?
Имеет смысл при определённых условиях. Если DLL не нужны уведомления DLL_THREAD_ATTACH/DETACH, вызов DisableThreadLibraryCalls в DLL_PROCESS_ATTACH отключает уведомления на каждое создание и завершение потока и снижает накладные расходы в процессе, который часто создаёт потоки. Есть два исключения. Не вызывайте её из DLL, слинкованной со статическим CRT (статическому CRT нужны уведомления о потоках). Если используется статический TLS через thread_local или __declspec(thread), сам вызов завершается ошибкой и возвращает FALSE, поэтому проверяйте возвращаемое значение. На обычной DLL с динамически слинкованным CRT вызывайте её, убедившись, что от уведомлений о потоках ничего не зависит.
Почему DLL на C++/CLI (смешанная управляемая) зависает при старте?
Типичная причина — попытка выполнить MSIL (управляемый код), пока удерживается блокировка загрузчика. В смешанной сборке C++/CLI, если DllMain, функции, которые он вызывает, или динамические инициализаторы глобальных объектов скомпилированы в MSIL, под блокировкой загрузчика может потребоваться инициализация CLR или загрузка другой сборки — и это может привести к взаимной блокировке. Компилятор выдаёт предупреждение C4747, когда сам DllMain пытается выполнить MSIL напрямую, но не обнаруживает косвенное выполнение через другой модуль. Как избежать: скомпилировать DllMain и его дерево вызовов как нативный код через #pragma unmanaged — или вообще не иметь DllMain.
Можно ли освобождать ресурсы в DLL_PROCESS_DETACH?
Ответ зависит от того, это завершение процесса или выгрузка через FreeLibrary. При DLL_PROCESS_DETACH на выходе процесса другие потоки уже принудительно завершены, и нет гарантии, что адресное пространство ещё согласовано, поэтому такая «уборка», как освобождение памяти, на самом деле опасна; официально «идеальный обработчик пуст». Данные, которые нужно сохранить, записывайте в штатном завершении приложения, а здесь по сути ничего не делайте и возвращайте управление. При выгрузке через FreeLibrary процесс продолжает работать, поэтому нужна полная очистка: остановить потоки, закрыть дескрипторы и так далее. Ожидание завершения потока внутри DllMain, однако, даёт взаимную блокировку, поэтому нужно следовать официальному протоколу: подать сигнал, дождаться согласованного состояния и закончить работу уже вне DllMain.

Об авторе

Страница с профилем автора статьи.

Го Комура

Представитель KomuraSoft LLC

Специализируется на разработке программного обеспечения для Windows, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.

Публичные ссылки

Вернуться в блог