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.
flowchart TB
accTitle: Почему работа в DllMain влияет на уведомления DLL во всём процессе
accDescr: Загрузчик захватывает общепроцессную блокировку загрузчика до вызова DllMain, а загрузки DLL и уведомления потоков на других потоках ждут её освобождения, поэтому работа в DllMain затрагивает и другие DLL, и другие потоки
loader["Загрузчик захватывает общую блокировку"] --> dll["Выполняется DllMain"]
dll --> ret["DllMain возвращает управление"]
ret --> unlock["Блокировка загрузчика освобождается"]
other["Загрузка DLL или уведомление на другом потоке"] --> wait["Ждёт освобождения той же блокировки"]
unlock --> resume["Ожидавшая работа может продолжиться"]
wait --> resume
Рис. 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
sequenceDiagram
accTitle: Почему ожидание потока в DllMain даёт взаимную блокировку
accDescr: DllMain, удерживая блокировку загрузчика, ждёт завершения рабочего потока, но завершающийся рабочий поток ждёт освобождения блокировки загрузчика для уведомления DLL_THREAD_DETACH, поэтому они ждут друг друга и взаимно блокируются
participant L as Загрузчик (удерживает блокировку)
participant D as DllMain
participant W as Рабочий поток
L->>D: Уведомить DLL_PROCESS_DETACH
D->>W: Запросить выход и ждать завершения
W->>W: Закончить работу и идти к выходу из потока
Note over W: Уведомлению о выходе нужна блокировка загрузчика
Note over D,W: DllMain ждёт, удерживая блокировку, W ждёт блокировку
Рис. 2: Даже после того, как рабочий закончил свою работу, он не может пройти уведомление о выходе из потока, поэтому ожидание выхода внутри DllMain никогда не заканчивается.
Это не случай «остановится, если не повезёт»: взаимное ожидание держится структурно. Очистку, нужную при выгрузке, разбирает глава 6 отдельно от работы при завершении процесса.
3.3 Своя блокировка и блокировка загрузчика захватываются в обратном порядке
Дело не только в старте и завершении потоков: API вроде GetModuleHandle тоже внутри нуждаются в блокировке загрузчика. Когда пересекаются два следующих пути, порядок захвата блокировок инвертируется.6
- Со стороны DllMain код, который уже удерживает блокировку загрузчика, пытается взять частную блокировку G.
- Со стороны рабочего код, который уже удерживает частную блокировку G, вызывает API и пытается взять блокировку загрузчика.
flowchart TB
accTitle: Инверсия порядка между блокировкой загрузчика и частной блокировкой
accDescr: DllMain идёт за частной блокировкой, удерживая блокировку загрузчика, а рабочий поток идёт за блокировкой загрузчика, например из-за GetModuleHandle, удерживая частную блокировку, поэтому порядок захвата инвертируется и они взаимно блокируются
d["DllMain: удерживает блокировку загрузчика"] --> dg["Идёт за частной блокировкой G"]
w["Рабочий: удерживает частную блокировку G"] --> wl["Идёт за блокировкой загрузчика"]
dg -.-> dead["Взаимная блокировка из-за инвертированного порядка захвата"]
wl -.-> dead
wl -.-> api["Внутри требуется GetModuleHandle и другим"]
Рис. 3: Когда одна сторона берёт блокировку загрузчика, а затем G, а другая берёт G, а затем блокировку загрузчика, каждая ждёт, пока другая отпустит.
Официальная рекомендация — считать блокировку загрузчика вершиной иерархии блокировок приложения, то есть блокировкой, которую захватывают первой. К моменту, когда вы уже внутри DllMain, эта блокировка уже удерживается. Смотрите не только имя вызываемой функции, но и какие блокировки вы удерживаете в момент вызова.6
3.4 Даже простое создание потока оставляет проблемы ожидания старта и срока жизни
CreateThread внутри DllMain тоже не рекомендуется. Новый поток не начнёт выполнять свою функцию, пока не обработано уведомление DLL_THREAD_ATTACH. Поскольку текущий DllMain удерживает блокировку загрузчика, ожидание внутри этого DllMain старта или завершения потока даёт взаимную блокировку.5
Не ждать — тоже не решение всех проблем. Если DLL выгружается после возврата из DllMain, но до того, как созданный поток начал выполняться, стартовый адрес потока указывает на уже освобождённый код, и падение остаётся возможным.5
flowchart TB
accTitle: Две проблемы, которые остаются, когда поток создают в DllMain
accDescr: Поток, созданный в DllMain, ждёт блокировку загрузчика для уведомления о старте, поэтому если DllMain ждёт его старта или завершения, они взаимно блокируются, а даже если DllMain вернётся без ожидания, срок жизни кода заканчивается и происходит падение, если DLL выгружается до того, как поток начнёт выполняться
create["Поток создан внутри DllMain"] --> pending["Новый поток ждёт уведомление о старте"]
pending --> q{"Ждать старта или завершения внутри DllMain?"}
q -->|"Да"| dead["Взаимное ожидание при удерживаемой блокировке"]
q -->|"Нет"| returns["Возврат из DllMain"]
returns --> race["DLL выгружена до того, как поток начал выполняться"]
race --> crash["Кода по стартовому адресу уже нет"]
Рис. 4: Не ждать внутри DllMain и защитить срок жизни DLL, которой пользуется созданный поток, — два отдельных требования.
4. Два места, которые опасны даже при пустом DllMain
4.1 Динамическая инициализация глобальных и статических объектов
В DLL, слинкованной с CRT (библиотекой времени выполнения C/C++), конструкторы и деструкторы глобальных и статических объектов C++ выполняются через точку входа CRT. Они фактически являются частью DllMain и подчиняются тем же ограничениям.2
Вынести в конструктор загрузку файла конфигурации, запуск логгера, инициализацию COM или запуск потока — значит только спрятать место вызова; момент выполнения по-прежнему внутри блокировки загрузчика. Если в этой сложной работе есть загрузка другой DLL или синхронизация с потоками, это ровно так же опасно, как написать это в теле DllMain.
flowchart TB
accTitle: Как инициализация глобального объекта становится миной
accDescr: Загрузка DLL захватывает блокировку загрузчика, и конструкторы глобальных объектов выполняются через CRT, поэтому LoadLibrary, синхронизация с потоком или инициализация COM внутри них — это выполнение того, что DllMain запрещает
load["Загрузка DLL (блокировка загрузчика захвачена)"] --> crt["Точка входа CRT"]
crt --> ctor["Конструктор глобального объекта"]
ctor --> ng1["Работа, эквивалентная LoadLibrary"]
ctor --> ng2["Запустить поток и ждать его завершения"]
ctor --> ng3["Использование COM или User32"]
ng1 -.-> risk["Всё это запреты DllMain"]
ng2 -.-> risk
ng3 -.-> risk
Рис. 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
flowchart TB
accTitle: Можно ли обнаружить выполнение MSIL под блокировкой загрузчика
accDescr: Код, в котором DllMain выполняет MSIL напрямую, компилятор обнаруживает предупреждением C4747, а косвенное выполнение через функцию в другом модуле — нет, поэтому его нужно предотвращать ревью дерева вызовов и нативной компиляцией на всём пути
d2["Вызов из DllMain"] --> dir["Выполняет MSIL напрямую"]
d2 --> ind["Выполняет через другой модуль"]
dir --> c47["Обнаруживается предупреждением C4747"]
ind --> nc["Компилятор не обнаруживает"]
nc -.-> rv["Предотвращать ревью и #pragma unmanaged"]
Рис. 6: Помимо прямого пути, который ловит C4747, просматривайте вызовы, идущие через другой модуль.
Средство — скомпилировать DllMain и каждую достижимую из него функцию как нативный код через #pragma unmanaged либо вообще не иметь DllMain. Даже во втором случае не упускайте косвенные пути вроде статических инициализаторов.3
5. Проектирование инициализации — сделать статическим, отложить, оставить только минимум
5.1 Прежде чем оставлять что-то в DllMain, спросите, нельзя ли сменить момент выполнения
Официальная база — завершить на этапе компиляции всё, что можно, и остальное откладывать как можно дальше. Остаётся, как исключение и в минимуме, только работа, которую нужно рано обнаружить как отказ загрузки.1
Например, может быть требование сделать саму загрузку DLL неуспешной, потому что зависимый файл конфигурации повреждён. Даже тогда сузьте это до «попытаться выполнить нужную работу и сразу отказать», а не сначала прогнать прочую инициализацию и отказать потом. Это не исключение, которое разрешает сложную инициализацию в момент загрузки.1
flowchart TB
accTitle: Принципы проектирования инициализации DLL
accDescr: Сначала рассмотреть, нельзя ли сделать инициализацию статической на этапе компиляции; если нельзя, по умолчанию откладывать до первого использования и оставлять в DllMain только минимум, который нужно рано обнаружить как отказ загрузки
q1{"Можно решить на этапе компиляции?"} -->|"Да"| s["Сделать статической инициализацией"]
q1 -->|"Нет"| q2{"Нужно обнаружить отказ при загрузке?"}
q2 -->|"Нет"| lazy["Отложить до первого использования (по умолчанию)"]
q2 -->|"Да"| min["Делать в DllMain только минимум"]
lazy -.-> once["Исключение через 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 или статического инициализатора, инициализация всё равно выполняется под блокировкой загрузчика. Мало вынести функцию инициализации: подтвердите кто вызывает её первым и когда.
flowchart TB
accTitle: Когда отложенная инициализация выходит из блокировки загрузчика
accDescr: Если первый доступ отложенной инициализации приходит из обычного API после завершения загрузки, инициализировать можно вне блокировки загрузчика, а если из DllMain или статического инициализатора — под теми же ограничениями
first{"Откуда приходит первый доступ?"}
first -->|"Обычный API после завершения загрузки"| outside["Инициализировать вне блокировки загрузчика"]
first -->|"DllMain или статический инициализатор"| inside["Инициализировать под теми же ограничениями"]
outside --> once["Исключение через INIT_ONCE или function-local static"]
inside --> move["Перенести и момент первого доступа"]
Рис. 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
flowchart TB
accTitle: Решение, вызывать ли DisableThreadLibraryCalls
accDescr: DLL, слинкованная со статическим CRT, не должна её вызывать, а при действующем статическом TLS сам вызов завершается ошибкой, поэтому её не вызывают; если ни то ни другое и уведомления о потоках не нужны, её можно вызвать в DLL_PROCESS_ATTACH с проверкой возвращаемого значения, чтобы снизить стоимость уведомлений
q1{"Слинкована со статическим CRT?"} -->|"Да"| no2["Вызывать нельзя"]
q1 -->|"Нет"| q2{"Использует статический TLS?"}
q2 -->|"Да"| eff["Вызов всё равно завершится ошибкой (FALSE)"]
q2 -->|"Нет"| q3{"Нужны уведомления о потоках?"}
q3 -->|"Нет"| yes["Вызвать в ATTACH (проверить возвращаемое значение)"]
q3 -->|"Да"| keep["Не вызывать; обрабатывать уведомления"]
Рис. 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
- Сторона DllMain сигнализирует рабочему остановиться через событие.
- Рабочий сворачивает текущую работу до согласованного состояния, сигнализирует о завершении и входит в бесконечное ожидание.
- Сторона DllMain подтверждает согласованное состояние и завершает поток через
TerminateThread.
Выглядит грубо, но это задокументировано при ограничении, что ожидание естественного выхода сталкивает уведомление о выходе с блокировкой загрузчика. Предпосылка — что работа по достижению согласованного состояния тоже подчиняется тем же ограничениям, что и DllMain. Если эта работа входит в загрузку другой DLL или в ожидание блокировки загрузчика, взаимной блокировки со стороной, которая ждёт сигнала, не избежать.5
sequenceDiagram
accTitle: При выгрузке ждать не естественного выхода, а завершения согласования
accDescr: DllMain сигнализирует рабочему остановиться, рабочий достигает согласованного состояния, соблюдая те же ограничения, что и DllMain, сигнализирует обратно и входит в бесконечное ожидание, а DllMain подтверждает согласованное состояние и затем завершает поток
participant D as DllMain (при выгрузке)
participant W as Рабочий поток
D->>W: Сигнал остановиться через событие
W->>W: Дойти до согласованного состояния под теми же ограничениями
W->>D: Сигнал, что согласованность достигнута
W->>W: Войти в бесконечное ожидание
D->>W: Подтвердить согласованность, затем TerminateThread
Note over D,W: Ждут сигнала согласованности, а не естественного выхода
Рис. 10: Даже при этом протоколе работа, которая устанавливает согласованное состояние, не должна ждать блокировку загрузчика.
Это не значит, что любой выполняющийся поток можно просто оборвать. Сначала рассмотрите, нельзя ли владеть потоком вне DLL.
6.3 При завершении процесса идеал — вернуть управление, ничего не делая
К моменту, когда при завершении процесса доставляется DLL_PROCESS_DETACH, другие потоки уже завершились или принудительно прерваны, и на согласованность адресного пространства полагаться нельзя. Состоянию зависимых DLL и сред выполнения тоже нельзя доверять, поэтому такая очистка, как освобождение памяти, становится опасной, а не полезной. Официальная рекомендация тоже говорит, что идеальный обработчик в этом случае пуст.5
Данные, которые нужно сохранить, записывайте в штатном завершении приложения. Не используйте DLL_PROCESS_DETACH как последнее место, где ещё можно всё прибрать.
flowchart TB
accTitle: Предпосылки очистки различаются между выгрузкой DLL и завершением процесса
accDescr: Когда выгружается только DLL, процесс продолжает работать, поэтому ресурсы нужно почистить, но не ждать естественного выхода внутри DllMain; при завершении процесса не полагаться на согласованность других потоков и ресурсов, по сути вернуть управление ничего не делая, а состояние для сохранения заранее записать в коде завершения приложения
reason{"Причина DLL_PROCESS_DETACH?"}
reason -->|"Выгрузка только DLL"| alive["Процесс после этого продолжает работать"]
alive --> cleanup["Корректно почистить оставшиеся ресурсы"]
cleanup -.-> nojoin["Не ждать естественного выхода внутри DllMain"]
reason -->|"Завершение процесса"| processEnd["Состояние других потоков и ресурсов неопределённо"]
processEnd --> empty["По сути вернуть управление ничего не делая"]
empty -.-> save["Сохранить заранее в коде завершения приложения"]
Рис. 11: Решайте не только «нужна ли очистка», но и продолжает ли процесс работать и можно ли доверять ресурсам, которыми пользуетесь для очистки.
7. Расследование зависания — найти сторону, которая ждёт блокировку загрузчика, и сторону, которая её удерживает
7.1 В дампе подтвердить пару взаимно ожидающих потоков
Снимите дамп в момент зависания и посмотрите стек каждого потока. Типичный отпечаток — пара: поток, ждущий блокировку внутри функции загрузчика в ntdll.dll, имя которой начинается с Ldr, и поток, ждущий чего-то ещё внутри DllMain или статического инициализатора (dynamic initializer).
Поток, остановившийся посреди вызова LoadLibrary, — ещё один типичный участник. Когда связывается отношение между стороной, которая ждёт блокировку, и стороной, которая её удерживает, ожидая чего-то ещё, почти наверняка можно заключить, что это взаимная блокировка вокруг блокировки загрузчика.
flowchart TB
accTitle: Подтверждение взаимного ожидания блокировки загрузчика по дампу зависания
accDescr: В стеке каждого потока искать ожидание блокировки в функциях Ldr и ожидание внутри DllMain или статического инициализатора, подтвердить, что они ждут друг друга, и если типичная пара не видна, расследовать и другие виды зависания
dump["Снять дамп во время зависания"] --> stacks["Посмотреть стек каждого потока"]
stacks --> ldr["Ожидание блокировки в функциях Ldr"]
stacks --> init["Ожидание внутри DllMain или статического инициализатора"]
ldr --> pair{"Связывается ли отношение взаимного ожидания?"}
init --> pair
pair -->|"Да"| found["Взаимная блокировка вокруг блокировки загрузчика"]
pair -->|"Не видно"| more["Расследовать и другие виды зависания"]
Рис. 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 при завершении процесса приближайте к пустому и сохранение данных кладите в штатное завершение приложения.
flowchart TB
accTitle: Порядок пересмотра DllMain и того, что он вызывает
accDescr: Осмотреть область, включая не только DllMain, но и статическую инициализацию и косвенные вызовы, перенести инициализацию в статическую или после загрузки, спроектировать остановку и очистку по причине завершения, затем подтвердить Application Verifier и дампами
scope["DllMain, статическая инициализация и вызываемые"] --> timing["Перенести инициализацию в статическую или после загрузки"]
timing --> first["Проверить и первый доступ отложенной работы"]
first --> shutdown["Спроектировать очистку по причине завершения"]
shutdown --> verify["Подтвердить Verifier, предупреждениями и дампами"]
Рис. 13: Если отслеживать не только что делает инициализация, но и когда она выполняется и каков её срок жизни при завершении, запреты можно трактовать как единую политику проектирования.
Вопрос, который стоит задать на пограничном случае: «эту работу допустимо выполнять, удерживая блокировку загрузчика?» Не запихивать сомнительную инициализацию в DllMain, а менять момент её выполнения — исходная точка безопасного проектирования DLL.
Похожие статьи
- Как работает разрешение имён DLL в Windows — порядок поиска и SxS
- Вызов нативной DLL из C#: обёртка на C++/CLI или P/Invoke
- Практические приёмы многопоточности в C++: как RAII и jthread убирают аварии из конструкции
- Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows
- Как читать аварийные дампы в WinDbg + SOS — практика анализа после сбора
- STA и MTA в COM: модель потоков и как не получить зависание
Смежные области консультирования
KomuraSoft LLC занимается расследованием причин зависаний и взаимных блокировок при старте или загрузке DLL (анализ дампов), ревью проекта вокруг DllMain и статической инициализации и переработкой обёрток C++/CLI и плагинных DLL к безопасной схеме инициализации. К нам можно обратиться даже на трудно воспроизводимой стадии «зависает при старте только в определённой среде».
- Расследование сбоев и анализ причин
- Техническая консультация и ревью проекта
- Разработка Windows-приложений
- Связаться с нами
Справочные ссылки
-
Microsoft Learn, Dynamic-Link Library Best Practices. О том, что DllMain вызывается при удерживаемой блокировке загрузчика, поэтому функции, которые можно вызывать, жёстко ограничены; о том, что идеальный DllMain — пустая заглушка, а инициализацию следует откладывать как можно дальше; о рекомендации компиляционно-временной статической инициализации; о том, чтобы делать только минимум для отказов, которые нужно обнаружить рано; и об обнаружении типичных ошибок DllMain через Application Verifier. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, DllMain entry point. О том, что в точке входа следует выполнять только простую инициализацию и завершение; почему нельзя вызывать LoadLibrary / FreeLibrary (циклический порядок загрузки и использование DLL до инициализации или после завершения); о том, что Kernel32.dll гарантированно загружен, поэтому его можно вызывать в диапазоне, который не загружает другие DLL; о том, что полного списка безопасных функций не существует; о том, что функции User, Shell и COM вызывают нарушения доступа; о том, что уведомления DLL сериализованы, поэтому обмен с другими потоками или процессами приводит к взаимным блокировкам; и о том, что те же ограничения действуют на конструкторы и деструкторы статических объектов при линковке с CRT. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Initialization of Mixed Assemblies. О том, что нельзя выполнять MSIL под блокировкой загрузчика; о том, что DllMain и его дерево вызовов нельзя компилировать в MSIL и это закрывают через #pragma unmanaged; о том, что предупреждение C4747 выдаётся, когда DllMain пытается выполнить MSIL напрямую, а косвенное выполнение через другой модуль не обнаруживается; и о том, что динамические инициализаторы статических объектов могут вызвать ту же проблему. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). Об отключении уведомлений DLL_THREAD_ATTACH / DLL_THREAD_DETACH, чтобы снизить накладные расходы при создании и уничтожении потоков; о том, что её нельзя вызывать из DLL, слинкованной со статическим CRT; и о том, что оптимизация не выполняется, когда действует статический TLS (thread_local или __declspec(thread)). ↩ ↩2 ↩3
-
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
-
Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. Об определении иерархии блокировок и всегда захватывать в одном порядке; о том, что загрузчик захватывает блокировку загрузчика до вызова DllMain, поэтому блокировка загрузчика должна стоять на вершине иерархии; о соблюдении порядка захвата между API, которые берут блокировку загрузчика косвенно, например GetModuleHandle, и частными блокировками; и о конкретном примере взаимной блокировки из-за инверсии порядка захвата. ↩ ↩2
Похожие статьи
Недавние статьи с теми же тегами помогут подробнее изучить близкие темы.
Пул потоков Win32 — параллелизм через CreateThreadpoolWork без своих потоков
Не плодите ли вы CreateThread по всему нативному коду? Разбираем API пула потоков Win32, переработанный в Vista: четыре объекта work, tim...
«Не отвечает»: как Windows определяет зависание и как проектировать так, чтобы оно не возникало
Windows считает окно неотвечающим, если 5 секунд не извлекает сообщения, и подменяет его фантомным окном. Разбираем критерий, типичные пр...
Ложное пробуждение: почему wait на условной переменной возвращается без уведомления и как ждать в Windows
wait на условной переменной может вернуться и без уведомления (ложное пробуждение). Почему спецификация это допускает и как правильно жда...
Именованные каналы на практике — от проектирования до безопасности IPC в Windows
Практический разбор именованных каналов — стандартного IPC в Windows. По первоисточникам: выбор байтового режима и режима сообщений, серв...
Time Travel Debugging — записывать и перематывать ошибки, которые не воспроизводятся в долгоживущих приложениях
Ошибка раз в месяц оставляет в дампе только результат. Записывайте и перематывайте исполнение через WinDbg Time Travel Debugging (TTD): T...
Связанные темы
Эти страницы показывают тему статьи в более широком контексте услуг и решений.
Технические темы Windows
Раздел о разработке Windows, расследовании сбоев и использовании существующих активов.
Услуги по этой теме
Статья напрямую связана со следующими услугами.
Разработка приложений для Windows
Бизнес-приложения, интеграция оборудования и средства связи — от требований до разработки.
Частые вопросы
Вопросы, которые часто возникают при консультациях по теме статьи.
- Неужели в 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, техническом консалтинге и расследовании сбоев, особенно в проектах с унаследованными системами и трудно воспроизводимыми ошибками.