更新履歴(1件・最終更新 2026年09月06日)
この記事に加えた変更の記録です。アーカイブした更新前のバージョンは、DOI付きの固定URLから読めます。
- DllMainとローダーロックの解説を、制約の理由、初期化を移す設計、DLLアンロードとプロセス終了の違い、ハング調査の順に再構成した。元の推奨方針と注意点、FAQ、参考資料を維持し、静的初期化やC++/CLIの間接経路、遅延初期化と終了処理の前提を小見出しと図で追いやすくした。 更新前のバージョンを見る (DOI: 10.5281/zenodo.22170903)
- 初版公開
この記事を引用する(DOI: 10.5281/zenodo.22170902)
この記事はZenodoにアーカイブされています。常に最新版へ解決されるDOIと、いま表示している版に固定されたDOIの両方を下に示します。
小村 豪(2026)「DllMainとローダーロック ── 「DLLの初期化で何もするな」と言われる本当の理由」合同会社小村ソフト. https://doi.org/10.5281/zenodo.22170902 https://comcomponent.com/blog/dllmain-loader-lock/
- DOI(最新版)
- 10.5281/zenodo.22170902
- DOI(この版)
- 10.5281/zenodo.22453567
「自作DLLを読み込むと、LoadLibrary から戻ってこない」「特定のPCやサービス起動時にだけハングする」。こうした不具合で、まず確認したい場所のひとつが DLLの初期化コード、DllMain とそこから呼ばれる処理です。
DllMain は、アプリの通常の初期化関数とは違います。OSがローダーロックを保持したまま呼ぶ関数なので、できる処理が強く制限されています。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がプロセスやスレッドに出入りするタイミングで、OSのローダーから呼ばれるエントリポイントです。通知は次の4種類です。2
| 通知 | タイミング |
|---|---|
| DLL_PROCESS_ATTACH | DLLがプロセスにロードされたとき |
| DLL_THREAD_ATTACH | プロセス内で新しいスレッドが開始されたとき |
| DLL_THREAD_DETACH | スレッドが正常終了するとき |
| DLL_PROCESS_DETACH | DLLのアンロード時、またはプロセス終了時 |
スレッド開始時の通知は、そのスレッドを作ったDLLだけでなく、プロセスにロードされているDLLにも届きます。DllMain は「自分のDLLをロードしたときに1回だけ動くコード」ではありません。スレッドが頻繁に作られるプロセスでは、通知のたびに実行されます。2
通知が不要なら、DLL_PROCESS_ATTACH の中で DisableThreadLibraryCalls を呼ぶ選択肢があります。ただし静的CRTとリンクしたDLLでは使えず、静的TLSにも条件があるため、5.3で分けて判断します。4
2.2 ロックを持ったまま呼ばれることが、すべての制約の出発点
OSのローダーは、DLLのロード・アンロード・通知の整合性を守るため、プロセスに1つのローダーロックで処理を直列化します。重要なのは、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. なぜ止まるのか ── 禁止事項を4つの経路で理解する
3.1 別のDLLをロードする処理を呼ぶ
DllMain からの LoadLibrary / FreeLibrary 呼び出しは避けます。LoadLibrary はロード順序に循環依存を作り、初期化コードがまだ実行されていないDLLが使われる事態を招きます。終了側でも、処理済み・解放済みのDLLを使う危険があります。2
自分で LoadLibrary と書いていなければ安全、というわけではありません。User・Shell・COMの関数には、内部でほかのシステムコンポーネントをロードするものがあり、初期化前・解放後のコンポーネントに触れてアクセス違反になることがあります。2
安全に呼べる範囲の基本は、DllMain の時点で必ずロード済みの Kernel32.dllのうち、ほかのDLLをロードしない関数です。たとえばクリティカルセクションやミューテックスなどの同期オブジェクトの作成、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 自分のロックとローダーロックの取得順序が逆転する
スレッドの開始・終了だけでなく、GetModuleHandle のようなAPIも、内部でローダーロックを必要とします。次の2つの経路が重なると、ロックの取得順序が逆転します。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 スレッドを作るだけでも、開始待ちと寿命の問題が残る
DllMain の中での CreateThread も推奨されません。新しいスレッドは、DLL_THREAD_ATTACH 通知の処理が終わるまで、スレッド関数の実行を始められません。現在のDllMainがローダーロックを保持しているため、そのDllMain内で開始や完了を待てばデッドロックします。5
待たなければすべて解決するわけでもありません。DllMainが戻った後、作ったスレッドがまだ走り出していないうちにDLLがアンロードされると、スレッドの開始番地が解放済みコードを指し、クラッシュする危険が残ります。5
flowchart TB
accTitle: DllMain内のスレッド作成に残る2つの問題
accDescr: DllMain内で作ったスレッドは開始通知のためにローダーロックを待つのでDllMain側がその開始や完了を待つとデッドロックし、待たずに戻っても実行開始前にDLLがアンロードされればコードの寿命が切れてクラッシュする
create["DllMain内でスレッドを作成"] --> pending["新スレッドは開始通知を待つ"]
pending --> q{"DllMain内で開始・完了待ち?"}
q -->|"はい"| dead["ロックを持ったまま待ち合う"]
q -->|"いいえ"| returns["DllMainから戻る"]
returns --> race["実行開始前にDLLをアンロード"]
race --> crash["開始番地のコードがなくなる"]
図4: DllMain内で待たないことと、作ったスレッドが使うDLLの寿命を守ることは、別々に必要になる。
4. DllMainが空でも危険な2か所
4.1 グローバル・静的オブジェクトの動的初期化
CRT(C/C++ランタイム)とリンクしたDLLでは、グローバル・静的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 C++/CLIの呼び出し先でMSILが実行される
ネイティブDLLをC++/CLIでラップする構成は、ネイティブDLLをC++/CLIでラップする実務で扱っています。この構成では、ローダーロック下でMSIL(マネージドコード)を実行する経路に注意が必要です。MSILの実行によってCLRの初期化や別アセンブリのロードが必要になると、デッドロックしえます。3
コンパイラは、DllMainが直接MSILを実行しようとするコードに警告 C4747 を出します。しかし、別モジュールの関数を経由した間接的な実行は検出できません。警告がないことだけを根拠に、安全とは判断できないのです。静的オブジェクトの動的初期化子も確認対象です。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や関数内staticで排他"]
図7: 静的初期化と遅延初期化を先に検討し、DllMainには早期検出が必要な最小限の処理だけを残す。
5.2 遅延初期化は「最初にどこから呼ぶか」まで設計する
初回利用時の排他には、INIT_ONCE によるワンタイム初期化や、C++の関数内static(マジックスタティック)を使えます。複雑なグローバルオブジェクトは、初回アクセス時に生成するポインターや関数内staticへ移す、という整理です。
ただし、遅延させただけではローダーロックの外へ出たことにはなりません。最初のアクセスが「DLLのロード完了後に呼ばれる通常のAPI」から起きて、初めてこの制約を外せます。その場合は、Windows APIのほぼ全体を使える通常の初期化処理として設計できます。1
逆に、DllMainや静的初期化子の中からその初回アクセスを行えば、初期化処理は結局ローダーロック下で実行されます。初期化関数へ切り出すだけでなく、誰が・いつ最初に呼ぶのかを確認してください。
flowchart TB
accTitle: 遅延初期化がローダーロックの外へ出る条件
accDescr: 遅延初期化の初回アクセスがロード完了後の通常APIから起きればローダーロックの外で初期化できるが、DllMainや静的初期化子から起きれば同じ制約下で実行される
first{"初回アクセスはどこから?"}
first -->|"ロード完了後の通常API"| outside["ローダーロックの外で初期化"]
first -->|"DllMain・静的初期化子"| inside["同じ制約下で初期化"]
outside --> once["INIT_ONCE・関数内staticで排他"]
inside --> move["初回アクセスの時点も移す"]
図8: INIT_ONCEや関数内staticは初期化の排他を担うが、ローダーロックの外で呼ばれることまで保証するものではない。
5.3 DisableThreadLibraryCallsは、3つの条件で判断する
スレッド通知に依存しないDLLでは、DLL_PROCESS_ATTACH で DisableThreadLibraryCalls を呼び、DLL_THREAD_ATTACH / DLL_THREAD_DETACH の通知を止められます。スレッドを頻繁に作るプロセスでは、通知のオーバーヘッドを減らせます。4
適用前に、静的CRT、静的TLS、通知を使う処理の有無を確認します。静的CRTとリンクしたDLLでは、CRT自身がスレッド通知を必要とするため呼んではいけません。thread_local や __declspec(thread) による静的TLSが有効な場合は、呼び出し自体が失敗して FALSE が返ります。4
flowchart TB
accTitle: DisableThreadLibraryCallsを呼ぶかの判断
accDescr: 静的CRTとリンクしているDLLでは呼んではならず、静的TLSが有効な場合は呼び出し自体が失敗するため呼ばず、どちらでもなくスレッド通知を使わないDLLならDLL_PROCESS_ATTACHで戻り値を確認しつつ呼んで通知コストを削減できる
q1{"静的CRTとリンク?"} -->|"はい"| no2["呼んではならない"]
q1 -->|"いいえ"| q2{"静的TLSを使用?"}
q2 -->|"はい"| eff["呼んでも失敗する(FALSE)"]
q2 -->|"いいえ"| q3{"スレッド通知が必要?"}
q3 -->|"いいえ"| yes["ATTACHで呼ぶ(戻り値を確認)"]
q3 -->|"はい"| keep["呼ばずに通知を処理"]
図9: 静的CRTでは使わず、静的TLSでは失敗することを区別し、通知が不要な場合も戻り値を確認する。
動的リンクのCRTを使い、これらの条件を満たす一般的なDLLで検討する最適化です。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)インポートなどが確認対象です。特に、遅延ロードされたインポート関数の初回呼び出しが内部でLoadLibraryになる点は見落とされがちです。関数名がDllMainの外にあっても、呼び出し元がDllMainなら、その制約は残ります。
8. まとめ ── 関数名ではなく「いつ、どのロックの下で動くか」を見る
DllMainの制約は、プロセス共通のローダーロックを持ったまま呼ばれるという一点から理解できます。自分のDllMainだけでなく、CRTの静的初期化・終了処理、C++/CLIの間接呼び出しも同じ範囲に入ります。
見直しは、まず初期化を静的にできないか、残りをロード完了後へ遅延できないかから始めます。遅延先の初回アクセスまで確認し、通知が不要なら静的CRT・静的TLSの条件を見て DisableThreadLibraryCalls を検討します。
終了側では、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設計の出発点になります。
関連記事
- Windows DLL名前解決の仕組み - 検索順序とSxS
- ネイティブDLLをC++/CLIでラップする実務
- マルチスレッドの実務ベストプラクティス C++編
- スプリアスウェイクアップ ── 条件変数が「通知なしに目覚める」理由とWindowsでの正しい待ち方
- WinDbg + SOSでクラッシュダンプを読む ── 収集した後の実務解析入門
- COM STA/MTA の基礎知識 - スレッドモデルとハングを避ける考え方
関連する相談領域
合同会社小村ソフトでは、起動時・DLLロード時のハングやデッドロックの原因調査(ダンプ解析)、DllMain・静的初期化まわりの設計レビュー、C++/CLIラッパーやプラグインDLLの安全な初期化設計への改修を扱っています。「特定環境でだけ起動が固まる」といった再現困難な段階からでもご相談いただけます。
参考リンク
-
Microsoft Learn, Dynamic-Link Library Best Practices. DllMainがローダーロック保持中に呼ばれるため呼べる関数に重大な制約があること、理想のDllMainは空のスタブであり初期化はできる限り遅延すべきこと、コンパイル時の静的初期化の推奨、早期検出が必要な失敗だけを最小限行うこと、Application VerifierによるDllMain内の典型的な誤りの検出について。 ↩ ↩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で対処すること、DllMainが直接MSILを実行しようとすると警告C4747が出るが別モジュール経由の間接実行は検出できないこと、静的オブジェクトの動的初期化子も同じ問題を起こしうることについて。 ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). DLL_THREAD_ATTACH / DLL_THREAD_DETACH通知を無効化してスレッド生成・破棄時のオーバーヘッドを減らせること、静的CRTとリンクしたDLLから呼んではならないこと、静的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を呼ぶためローダーロックはロック階層の最上位に置くべきこと、GetModuleFileNameのように間接的にローダーロックを取るAPIと私有ロックの取得順序を守ること、ロック順序逆転によるデッドロックの実例について。 ↩ ↩2
関連する記事
同じタグを共有する最新の記事です。さらに近い話題で知識を深められます。
Win32スレッドプールAPI ── CreateThreadpoolWorkで「スレッドを作らない」並行処理
ネイティブコードでCreateThreadを乱立させていませんか。Vistaで刷新されたWin32スレッドプールAPIのwork・timer・wait・ioの4オブジェクト、クリーンアップグループ、コールバックでの禁止事項までを一次情報にもとづいて解説します。
「応答なし」の正体 ── Windowsがアプリを「固まった」と判定する仕組みと、固まらない設計
Windowsの「応答なし」は、ウィンドウが5秒間メッセージを取り出さないとOSが判定し、幽霊ウィンドウに差し替える仕組みです。判定の内部動作から、固まる定番原因、UIスレッドから重い処理を追い出す設計、ハングの調査手順まで解説します。
スプリアスウェイクアップ ── 条件変数が「通知なしに目覚める」理由とWindowsでの正しい待ち方
条件変数のwaitは通知が来ていなくても目覚めることがあります(スプリアスウェイクアップ)。仕様がそれを許す理由をWindowsの実装から解き明かし、whileと述語で書く正しい待ち方をWin32・C++・C#のコードで示します。
Time Travel Debugging ── 長期稼働で再現しない不具合を「録画」して巻き戻す
月に一度しか出ない不具合は、クラッシュダンプでは結果しか写りません。WinDbgのTime Travel Debugging(TTD)で実行を録画して巻き戻す方法を、TTD.exeの録画設計、リングバッファ、TTD.Callsクエリ、ダンプとの使い分けまで解説します。
引数はなぜ壊れるか ── Windowsのコマンドライン引数の規則
Windowsでは引数の配列は存在せず、CreateProcessに渡るのは1本の文字列で、分割は受け取り側が行います。CommandLineToArgvW・CRT・.NETの分割規則と、.NETのArgumentList・C++での正しい組み立て方を解説します。
関連トピック
このテーマと近いトピックページです。記事を起点に、関連するサービスや他の記事へ進めます。
Windows技術トピック
Windows 開発、不具合調査、既存資産活用の技術トピックをまとめた入口です。
不具合調査 / 長期稼働テーマ
再現しにくい不具合、通信停止、長期稼働障害、失敗パス検証を整理するトピックです。
このテーマがつながるサービス
この記事は次のサービスページにつながります。近い入口からご覧ください。
Windowsアプリ開発
業務アプリ、装置連携、通信ツールなどの Windows ソフト開発を支援します。
不具合調査・原因解析
再現しにくい障害、長期稼働後の不具合、通信停止などの調査を支援します。
よくある質問
この記事のテーマについて、相談時によくある質問をまとめています。
- DllMainでは本当に何もできないのですか?
- 「何もするな」は誇張ではなく公式の設計方針で、理想のDllMainは空に近いスタブだとMicrosoft自身が述べています。安全なのは、DllMainの時点で必ずロード済みであるKernel32.dllの、他のDLLをロードしない範囲の関数だけです。たとえばクリティカルセクションやミューテックスの作成、TLSの利用はできます。逆にLoadLibrary/FreeLibrary、他スレッドとの同期、User32・Shell・COMなどの関数呼び出しは、デッドロックやアクセス違反の原因になるため禁止です。判断に迷う初期化は「DllMainでやらず、最初に使われるときまで遅延する」のが正解です。
- C++のグローバル変数(静的オブジェクト)のコンストラクタもDllMainの制約を受けますか?
- 受けます。DLLがCRT(C++ランタイム)とリンクされている場合、グローバル・静的オブジェクトのコンストラクタとデストラクタはCRTが提供するエントリポイント経由で、実質的にDllMainの一部として実行されます。つまりコンストラクタの中でLoadLibraryを呼ぶ、他スレッドを起動して完了を待つ、COMを初期化するなどはすべてDllMainでやるのと同じ危険があります。複雑な初期化を持つグローバルオブジェクトは、ポインターにしておいて初回アクセス時に生成する、関数内staticにするなど、実行タイミングをDllMainの外へずらしてください。
- DisableThreadLibraryCallsは呼んだほうがよいですか?
- 条件付きで有効です。DLL_THREAD_ATTACH/DETACH通知が不要なDLLなら、DLL_PROCESS_ATTACHでDisableThreadLibraryCallsを呼ぶことでスレッド生成・破棄のたびの通知を止められ、スレッドを頻繁に作るプロセスでのオーバーヘッドを減らせます。ただし2つ例外があります。静的CRTとリンクしているDLLでは呼んではいけません(静的CRTがスレッド通知を必要とします)。また、thread_localや__declspec(thread)による静的TLSが有効な場合は呼び出し自体が失敗しFALSEが返るため、戻り値の確認を習慣にしてください。動的リンクのCRTを使う一般的なDLLで、スレッド通知に依存する処理がないことを確認して使ってください。
- C++/CLI(マネージド混在)のDLLで起動時にハングするのはなぜですか?
- ローダーロック保持中にMSIL(マネージドコード)を実行しようとしていることが典型原因です。C++/CLIの混在アセンブリでは、DllMainやそこから呼ばれる関数、グローバル変数の動的初期化子がMSILにコンパイルされていると、ローダーロック下でCLRの初期化や別アセンブリのロードが必要になり、デッドロックしえます。コンパイラはDllMainが直接MSILを実行しようとすると警告C4747を出しますが、別モジュール経由の間接的な実行は検出できません。対策はDllMainとその呼び出し木を#pragma unmanagedでネイティブコンパイルすること、そもそもDllMainを持たないことです。
- DLL_PROCESS_DETACHでリソースの後始末をしてもよいですか?
- 「プロセス終了時」と「FreeLibraryによるアンロード時」で答えが変わります。プロセス終了時のDLL_PROCESS_DETACHでは、他のスレッドはすでに強制終了されており、アドレス空間が一貫している保証もないため、メモリ解放のような後始末はむしろ危険で、公式も「理想のハンドラーは空」としています。永続化が必要なデータはアプリ本来の終了処理で書き出しておき、ここでは基本的に何もせず返すのが安全です。一方、FreeLibraryによるアンロード時はプロセスは生き続けるので、スレッドの停止・ハンドルのクローズなどの完全な後始末が必要です。ただしDllMain内でスレッドの終了を待つとデッドロックするため、公式が示す「合図して整合状態まで待ち、最後はDllMainの外で処理する」プロトコルに従う必要があります。