DllMain und die Ladersperre — Der wahre Grund, warum man Ihnen sagt, „in der DLL-Initialisierung nichts zu tun“
· Go Komura · Windows, DLL, Windows-Entwicklung, C++, Fehleruntersuchung, Multithreading, Win32-API
„Die App hängt beim Start, aber nur in einer bestimmten Umgebung.“ „Wenn wir unsere eigene DLL laden, kehrt LoadLibrary manchmal nie zurück.“ „Es deadlockt nur zum Zeitpunkt eines Dienststarts.“ — Verfolgen Sie Untersuchungen wie diese weit genug, und Sie kommen häufiger als nicht am selben Ort an. Dem Initialisierungscode der DLL — das heißt DllMain.
Die Dokumentation von Microsoft warnt über DllMain in einem ungewöhnlich scharfen Ton. Rufen Sie LoadLibrary nicht auf. Synchronisieren Sie nicht mit anderen Threads. Rufen Sie keine User-, Shell- oder COM-Funktionen auf. Die ideale DllMain ist ein leerer Stub — warum ist die Sprache so scharf? Der Grund konzentriert sich in einem einzigen internen Mechanismus, der Ladersperre (Loader Lock). An Entwickler gerichtet, die DLLs, Plug-ins und C++/CLI-Wrapper unter Windows schreiben, erklärt dieser Artikel anhand von Primärquellen, wie die Ladersperre funktioniert, die Struktur, die einen Deadlock hält, und den sicheren Entwurf sowie das Untersuchungsverfahren.
1. Zuerst das Fazit
DllMainwird aufgerufen, während die Ladersperre gehalten wird, eine gemeinsame Sperre, von der es genau eine pro Prozess gibt. Also schafft das Aufrufen von Arbeit ausDllMain, die versucht, die Ladersperre zu nehmen (direkt oder indirekt), die Möglichkeit eines Deadlocks oder eines Absturzes durch das Anfassen einer DLL, die noch nicht initialisiert ist.1- Das Aufrufen von
LoadLibrary/FreeLibraryist verboten. Es erzeugt eine zirkuläre Abhängigkeit der Ladereihenfolge und kann dazu führen, dass Initialisierungscode gegen eine DLL läuft, deren eigene Initialisierung noch nicht gelaufen ist.2 - Das Synchronisieren mit anderen Threads ist ebenfalls verboten. DLL-Benachrichtigungen sind serialisiert, also lässt das Warten innerhalb von
DllMainauf den Start oder das Ende eines Threads diesen Thread selbst stehen, der auf die Ladersperre wartet, und Sie deadlocken.23 - Was Sie sicher aufrufen können, ist in der Praxis nur eine Teilmenge von Kernel32.dll. Und die offizielle Dokumentation sagt deutlich, dass „eine vollständige Liste sicherer Funktionen nicht existiert“. User-, Shell- und COM-Funktionen laden andere Komponenten und verursachen Zugriffsverletzungen.2
- In einer mit der CRT gebundenen DLL gelten dieselben Einschränkungen für Konstruktoren und Destruktoren von Globalen. Sie laufen als de-facto-Teil von
DllMain.2 - Der richtige Entwurf ist „aufschieben“. Tun Sie die Initialisierung, die Sie können, zur Kompilierzeit (statisch); schieben Sie das, was Sie nicht können, bis zur ersten Nutzung auf. Das ist die offizielle Best Practice.1
- Gemischte C++/CLI-DLLs sind besonders gefährlich. Um zu vermeiden, MSIL unter der Ladersperre auszuführen, müssen
DllMainund sein Aufrufbaum nativ kompiliert werden.4
2. Wann und wie DllMain aufgerufen wird
DllMain ist der Einstiegspunkt, den der OS-Lader aufruft, wenn eine DLL einen Prozess oder einen Thread betritt oder verlässt. Es gibt vier Benachrichtigungen.
| Benachrichtigung | Zeitpunkt |
|---|---|
| DLL_PROCESS_ATTACH | Wenn die DLL in den Prozess geladen wird |
| DLL_THREAD_ATTACH | Wenn ein neuer Thread im Prozess gestartet wird |
| DLL_THREAD_DETACH | Wenn ein Thread normal endet |
| DLL_PROCESS_DETACH | Wenn die DLL entladen wird oder der Prozess endet |
Zwei Tatsachen sind leicht zu übersehen. Erstens: Jedes Mal, wenn ein einzelner Thread angelegt wird, wird DllMain jeder bereits geladenen DLL mit DLL_THREAD_ATTACH aufgerufen. Mit anderen Worten ist DllMain nicht „etwas, das einmal läuft, wenn meine DLL geladen wird“; es ist Code, der für die Thread-Aktivität des Prozesses weiter aufgerufen wird. Wenn Sie das nicht brauchen, können Sie es stoppen, indem Sie DisableThreadLibraryCalls innerhalb von DLL_PROCESS_ATTACH aufrufen (rufen Sie es nicht aus einer mit der statischen CRT gebundenen DLL auf).5
Zweitens: In einer mit der CRT (der C/C++-Laufzeit) gebundenen DLL laufen Konstruktoren und Destruktoren von globalen und statischen C++-Objekten über den Einstiegspunkt der CRT als Teil von DllMain.2 Selbst wenn Sie denken „unsere DllMain ist leer, also sind wir sicher“, ist ein globales Objekt mit aufwendiger Initialisierung dasselbe, als liefe diese Arbeit in DllMain.
flowchart TB
accTitle: Die vier Zeitpunkte, zu denen DllMain aufgerufen wird
accDescr: DLL_PROCESS_ATTACH läuft beim DLL-Laden; DLL_THREAD_ATTACH und DETACH laufen auf jeder bereits geladenen DLL bei jedem Thread-Start und -Ende im Prozess; DLL_PROCESS_DETACH läuft beim Entladen oder Prozessende; und Konstruktoren statischer Objekte laufen hier ebenfalls über die CRT
load["DLL-Laden"] --> pa["DLL_PROCESS_ATTACH"]
pa --> ta["DLL_THREAD_ATTACH(bei jedem Thread-Start)"]
ta --> td["DLL_THREAD_DETACH(bei jedem Thread-Ende)"]
td --> pd["DLL_PROCESS_DETACH(beim Entladen oder Ende)"]
pa -.-> crt["Konstruktion statischer Objekte läuft hier ebenfalls"]
Abbildung 1: DllMain wird nicht nur zur Ladezeit, sondern bei jedem Thread-Start und -Ende aufgerufen, und die Initialisierung statischer Objekte läuft ebenfalls als Teil davon.
3. Die Ladersperre — Eine Sperre, die jede Benachrichtigung serialisiert
Warum sind die Einschränkungen allein bei DllMain so streng? Die Antwort liegt in der Struktur des Laders.
Um eine Reihe von Operationen — DLL-Laden, Entladen und die verschiedenen Benachrichtigungen — konsistent zu halten, serialisiert der OS-Lader die Arbeit mit einer einzigen Ladersperre pro Prozess. Und der wichtige Punkt ist, dass DllMain aufgerufen wird, während diese Ladersperre gehalten wird.1 Solange Sie in DllMain sind, wartet jedes andere DLL-Laden in diesem Prozess und jede Thread-Start-Benachrichtigung auf die Freigabe dieser Sperre.
Aus dieser Struktur folgen die Gründe für die Verbote nacheinander.
- Sie dürfen
LoadLibrarynicht aufrufen, weil das Wiedereintreten in die Ladersperre oder eine zirkuläre Abhängigkeit der Ladereihenfolge erzeugt. Es kann auch dazu führen, eine Funktion auf einer DLL aufzurufen, deren Initialisierung noch nicht fertig ist.2 - Das Synchronisieren mit anderen Threads ist gefährlich, weil der Thread, auf den Sie warten, Momente hat, in denen er die Ladersperre braucht (Benachrichtigungen bei Start und Ende, Aufrufe von APIs der
GetModuleHandle-Familie und so weiter). Sie halten die Ladersperre und warten auf die Gegenseite; die Gegenseite wartet auf die Ladersperre — die klassische Umkehrung der Sperrreihenfolge.6 - User-, Shell- und COM-Funktionen sind gefährlich, weil sie intern andere Systemkomponenten laden. Sie fassen eine Komponente an, bevor sie initialisiert ist oder nachdem sie abgebaut wurde, und Sie bekommen eine Zugriffsverletzung.2
sequenceDiagram
accTitle: Warum das Warten auf einen Thread innerhalb von DllMain deadlockt
accDescr: DllMain, die die Ladersperre hält, wartet auf das Ende eines Worker-Threads, aber der Worker, der zu enden versucht, wartet auf die Freigabe der Ladersperre, um DLL_THREAD_DETACH zu empfangen, sodass sie aufeinander warten und deadlocken
participant L as Lader(hält Sperre)
participant D as DllMain
participant W as Worker
L->>D: DLL_PROCESS_DETACH
D->>W: Ende anfordern und warten
W->>W: Arbeit beenden, dann enden
Note over W: Die Endbenachrichtigung braucht die Sperre
Note over D,W: DllMain hält die Sperre, W wartet
Abbildung 2: „DllMain wartet auf das Ende eines Threads“ ist ein struktureller Deadlock, weil das Thread-Ende selbst die Ladersperre braucht.
Der Punkt ist, dass dies nicht die Art von Sache ist, die „passiert, wenn Sie Pech haben“; es ist strukturell garantiert, dass es hält. Die Dokumentation sagt Ihnen, die Ladersperre als die Spitze der Sperrhierarchie zu behandeln, die die App definiert (die zuerst genommene). Innerhalb von DllMain halten Sie diese oberste Sperre bereits, also ist jede Handlung, von dort aus auf etwas anderes zu warten, gefährlich — das ist eine nützliche Art, es zu merken.6
flowchart TB
accTitle: Umkehrung der Sperrreihenfolge zwischen Ladersperre und privater Sperre
accDescr: DllMain, die die Ladersperre hält, geht, um eine private Sperre zu nehmen, während ein Worker, der diese private Sperre hält, geht, um die Ladersperre für GetModuleHandle oder Ähnliches zu nehmen, sodass die Erwerbsreihenfolge umkehrt und sie deadlocken
d["DllMain: hält die Ladersperre"] --> dg["Geht, um private Sperre G zu nehmen"]
w["Worker: hält private Sperre G"] --> wl["Geht, um die Ladersperre zu nehmen"]
dg -.-> dead["Deadlock durch umgekehrte Erwerbsreihenfolge"]
wl -.-> dead
wl -.-> api["GetModuleHandle und Ähnliches brauchen sie intern"]
Abbildung 3: Sogar eine harmlose API wie GetModuleHandle braucht intern die Ladersperre, sodass eine Reihenfolgeumkehrung mit einer privaten Sperre halten kann.
Außerdem ist das Aufrufen von CreateThread aus DllMain selbst nicht empfohlen. Der angelegte Thread braucht die Ladersperre, um die DLL_THREAD_ATTACH-Benachrichtigung zu verarbeiten, also kann er nicht zu laufen beginnen, bis die gerade ausführende DllMain zurückkehrt und die Sperre freigibt. Daher ist das Warten innerhalb von DllMain auf den Start oder das Ende dieses Threads ein sofortiger Deadlock. Es gibt auch ein Lebensdauerproblem — wenn nach der Rückkehr von DllMain die DLL entladen wird, während ein Thread, der noch nicht zu laufen begonnen hat, noch zurückbleibt, zeigt die Startadresse des Threads noch auf bereits freigegebenen Code und Sie stürzen ab.3
4. Zwei Minen, auf die C++-Entwickler leicht treten
Mine 1: Dynamische Initialisierung globaler Objekte. Wie Kapitel 2 sagte, laufen Konstruktoren statischer Objekte unter den DllMain-Einschränkungen. Eine Konfigurationsdatei lesen, eine Protokollierungseinrichtung aufstellen, COM initialisieren, einen Thread starten — in dem Moment, in dem Sie ein Global in eine DLL setzen, dessen Konstruktor diese Art von Arbeit tut, führen Sie „Dinge aus, die Sie in DllMain nicht tun dürfen“. Konstante Initialisierung, die zur Kompilierzeit festliegt (alles, was Sie constexpr machen können), ist sicher; Initialisierung, die einen Funktionsaufruf umfasst, sollte aufgeschoben werden.
flowchart TB
accTitle: Der Pfad, auf dem das Initialisieren eines globalen Objekts zur Mine wird
accDescr: Die Ladersperre wird beim DLL-Laden genommen, und Konstruktoren globaler Objekte laufen über den CRT-Einstiegspunkt, sodass LoadLibrary, Thread-Synchronisierung und COM-Initialisierung innerhalb dieser Konstruktoren Ausführungen von DllMain-Verboten sind
load["DLL-Laden(Ladersperre erworben)"] --> crt["CRT-Einstiegspunkt"]
crt --> ctor["Konstruktor eines globalen Objekts"]
ctor --> ng1["LoadLibrary-äquivalente Arbeit"]
ctor --> ng2["Einen Thread starten und auf sein Ende warten"]
ctor --> ng3["COM oder User32 nutzen"]
ng1 -.-> risk["All das fällt unter DllMain-Verbote"]
ng2 -.-> risk
ng3 -.-> risk
Abbildung 4: Selbst „DllMain ist leer, also sind wir sicher“ belebt dieselbe Gefahr in dem Moment wieder, in dem Sie ein Global mit aufwendiger Initialisierung haben.
Mine 2: C++/CLI (gemischte Assemblies). In einer Konfiguration, die eine native DLL mit C++/CLI umhüllt (die Form, die im Wrapper-Artikel behandelt ist), besteht die Gefahr, MSIL (verwalteten Code) unter der Ladersperre auszuführen. Das Ausführen von MSIL kann die CLR-Initialisierung oder das Laden einer anderen Assembly auslösen. Der Compiler gibt die Warnung C4747 auf Code aus, in dem DllMain MSIL direkt ausführt, aber er kann indirekte Ausführung über eine Funktion in einem anderen Modul nicht erkennen. Kompilieren Sie DllMain und die von ihr aufgerufenen Funktionen mit #pragma unmanaged nativ, oder nutzen Sie eine Konfiguration, die gar keine DllMain hat.4
flowchart TB
accTitle: Ob MSIL-Ausführung unter der Ladersperre erkannt werden kann
accDescr: Code, in dem DllMain MSIL direkt ausführt, kann der Compiler mit der Warnung C4747 erkennen, aber indirekte Ausführung über eine Funktion in einem anderen Modul nicht, sodass Sie sie durch Prüfung des Aufrufbaums und Bestehen auf native Kompilierung verhindern müssen
d2["Aufrufe aus DllMain"] --> dir["MSIL direkt ausführen"]
d2 --> ind["Über ein anderes Modul ausführen"]
dir --> c47["Erkennbar mit Warnung C4747"]
ind --> nc["Der Compiler kann es nicht erkennen"]
nc -.-> rv["Mit Prüfung und #pragma unmanaged verhindern"]
Abbildung 5: C4747 schützt Sie nur gegen direkte Ausführung. Indirekte Pfade können nur durch Prüfung gefangen werden.
5. Der richtige Entwurf — „Aufschieben“ zur Standardpolitik machen
Die offizielle Best-Practice-Empfehlung ist klar.1
- Beenden Sie die Initialisierung, die Sie können, zur Kompilierzeit (statisch). Fragen Sie zuerst, ob eine dynamische Initialisierung durch eine statische ersetzt werden kann.
- Schieben Sie den Rest bis zur ersten Nutzung auf. Solange die erste Nutzung von einer gewöhnlichen API kommt, die aufgerufen wird, nachdem die DLL fertig geladen ist, läuft die Initialisierung außerhalb der Ladersperre und Sie können sicher fast die ganze Windows-API nutzen. Für den Ausschluss beim ersten Zugriff können Sie
INIT_ONCE(Einmalinitialisierung) oder C++ magic statics (funktionslokale statics) nutzen. Aufschieben ist allerdings kein Allheilmittel — wenn dieser erste Zugriff selbst ausDllMainoder einem statischen Initialisierer kommt, läuft der Initialisierer immer noch unter der Ladersperre und Sie sind wieder unter denselben Einschränkungen. - Machen Sie eine Ausnahme nur für Fehlschläge, die Sie früh erkennen müssen. Sie können die Anforderung haben, dass eine kaputte Konfigurationsdatei das Laden selbst fehlschlagen lassen soll. Auch dann halten Sie es auf das Minimum von „versuchen und sofort fehlschlagen“.
- Erwägen Sie
DisableThreadLibraryCallsin DLL_PROCESS_ATTACH. Wenn die DLL Thread-Benachrichtigungen nicht nutzt, können Sie die Benachrichtigungskosten selbst entfernen (außer bei Nutzung der statischen CRT oder statischem TLS).5 - Prüfen Sie mit Application Verifier. Viele der gefährlichen Aufrufe innerhalb von
DllMainsind solche, die Application Verifier zur Laufzeit erkennt.1
flowchart TB
accTitle: Entwurfsleitlinie für die DLL-Initialisierung
accDescr: Erwägen Sie zuerst, ob Initialisierung kompilierzeit-statische Initialisierung sein kann; wenn nicht, ist die Vorgabe, bis zur ersten Nutzung aufzuschieben, und in DllMain nur das Minimum zu lassen, das früh als Ladefehler erkannt werden muss
q1{"Kann es zur Kompilierzeit entschieden werden?"} -->|"ja"| s["Machen Sie es zur statischen Initialisierung"]
q1 -->|"nein"| q2{"Muss der Fehlschlag zur Ladezeit erkannt werden?"}
q2 -->|"nein"| lazy["Bis zur ersten Nutzung aufschieben(die Vorgabe)"]
q2 -->|"ja"| min["Nur das Minimum in DllMain tun"]
lazy -.-> once["Mit INIT_ONCE oder einer funktionslokalen static ausschließen"]
Abbildung 6: Die Entscheidungsreihenfolge ist „kann es statisch sein → kann es aufgeschoben werden“, und was Sie in DllMain lassen, ist nur das Minimum, das früh erkannt werden muss.
Ob Sie DisableThreadLibraryCalls anwenden, können Sie mit der folgenden Verzweigung mechanisch entscheiden.
flowchart TB
accTitle: Ob DisableThreadLibraryCalls aufgerufen werden soll
accDescr: Rufen Sie es nicht aus einer mit der statischen CRT gebundenen DLL auf; wenn statisches TLS in Kraft ist, schlägt der Aufruf selbst fehl, also rufen Sie es nicht auf; gilt keines von beiden und die DLL nutzt keine Thread-Benachrichtigungen, rufen Sie es in DLL_PROCESS_ATTACH auf und prüfen den Rückgabewert, um die Benachrichtigungskosten zu senken
q1{"Mit der statischen CRT gebunden?"} -->|"ja"| no2["Darf nicht aufgerufen werden"]
q1 -->|"nein"| q2{"Statisches TLS in Nutzung?"}
q2 -->|"ja"| eff["Der Aufruf schlägt sowieso fehl(FALSE)"]
q2 -->|"nein"| q3{"Werden Thread-Benachrichtigungen gebraucht?"}
q3 -->|"nein"| yes["In ATTACH aufrufen(Rückgabewert prüfen)"]
q3 -->|"ja"| keep["Nicht aufrufen; die Benachrichtigungen behandeln"]
Abbildung 7: Die drei Bedingungen statische CRT, statisches TLS und ob Benachrichtigungen gebraucht werden, entscheiden eindeutig, ob Sie es aufrufen sollten.
Für das Stoppen von Threads beim Entladen gibt die offizielle Dokumentation ein konkretes Protokoll. Statt in DLL_PROCESS_DETACH (bei einem Entladen über FreeLibrary) auf das Ende von Worker-Threads zu „warten“, ist die Form (1) Ende mit einem Ereignis signalisieren, (2) die Thread-Seite faltet ihre Arbeit auf einen konsistenten Zustand zusammen, signalisiert zurück und geht in ein unendliches Warten, (3) die DllMain-Seite bestätigt den konsistenten Zustand und faltet den Thread dann mit TerminateThread.3 Es sieht grob aus, aber es ist als die realistische Antwort innerhalb der Einschränkung „Sie dürfen innerhalb von DllMain nicht auf das natürliche Ende eines Threads warten“ dokumentiert.
sequenceDiagram
accTitle: Protokoll zum Stoppen eines Threads beim Entladen
accDescr: DllMain signalisiert dem Worker-Thread mit einem Ereignis das Ende; der Worker faltet seine Arbeit auf einen konsistenten Zustand zusammen, signalisiert zurück und geht in ein unendliches Warten; DllMain bestätigt den konsistenten Zustand und beendet den Thread dann
participant D as DllMain(DETACH-Behandlung)
participant W as Worker-Thread
D->>W: Ende mit einem Ereignis signalisieren
W->>W: Arbeit auf einen konsistenten Zustand falten
W->>D: Konsistenz fertig signalisieren und ewig warten
D->>W: Mit TerminateThread beenden
Note over D,W: Kein Warten auf natürliches Ende, also kein Deadlock
Abbildung 8: Statt „auf ein natürliches Ende warten“ vermeidet „auf ein Konsistenzsignal warten und dann abschneiden“ eine Kollision mit der Ladersperre.
Als Sache erster Prinzipien ist der sicherste Entwurf, das Besitzen von Threads in einer DLL zu vermeiden, die entladen werden kann, und den Thread-Besitz auf der EXE-Seite zu halten.
DLL_PROCESS_DETACH am Prozessende ist das Gegenteil: nichts tun und zurückkehren ist das Ideal. Zu diesem Zeitpunkt ist jeder andere Thread bereits zwangsweise beendet, und Sie können sich auch nicht auf den Zustand abhängiger DLLs oder der Laufzeit verlassen. Aufwendige Arbeit hier verursacht nur Deadlocks und Abstürze. Daten, die erhalten bleiben müssen, sollten im eigenen Beendigungspfad der App herausgeschrieben werden; verlassen Sie sich nicht auf diese Benachrichtigung.3
6. Wie Sie untersuchen, wenn Sie hineinlaufen
Hänger der Ladersperre haben einen erkennbaren Fingerabdruck.
Schauen Sie sich die Stacks in einem Hänger-Dump an. Nehmen Sie einen Dump des eingefrorenen Moments und prüfen Sie den Stack jedes Threads. Wenn Sie ein Paar finden aus einem Thread, der auf eine Sperre innerhalb von Laderfunktionen von ntdll.dll wartet (die Familie, deren Namen mit Ldr beginnen), und einem Thread, der innerhalb von DllMain oder einem statischen Initialisierer (dynamic initializer) auf etwas anderes wartet, sind Sie fast sicher. Ein Thread, der mitten in einem LoadLibrary-Aufruf steht, ist ein weiterer typischer Charakter.
flowchart TB
accTitle: Der Fingerabdruck eines Ladersperren-Hängers
accDescr: Wenn Sie in einem Hänger-Dump sowohl einen Thread finden, der auf eine Sperre innerhalb von ntdll-Laderfunktionen wartet, als auch einen Thread, der innerhalb von DllMain oder einem statischen Initialisierer auf etwas anderes wartet, können Sie es mit nahezu Sicherheit als Ladersperren-Deadlock behandeln
dump["Hänger-Dump"] --> t1["Thread wartet auf eine Sperre in Ldr-Funktionen"]
dump --> t2["Thread wartet innerhalb von DllMain oder einem statischen Initialisierer"]
t1 --> pair{"Beide vorhanden?"}
t2 --> pair
pair -->|"ja"| conf["Fast sicher ein Ladersperren-Deadlock"]
pair -->|"nein"| other["Als Hänger anderer Art untersuchen"]
Abbildung 9: Ladersperren-Hänger haben den erkennbaren Fingerabdruck „Warten in Ldr + Warten innerhalb von DllMain“.
Verdächtigen Sie den „zeitabhängigen“ Charakter. Ein Ladersperren-Deadlock hält nur in dem Moment, in dem ein DLL-Laden mit einem Thread-Start oder -Ende zusammenfällt. Reproduktionsbedingungen wie „gelegentlich beim Start“, „nur auf einer bestimmten Maschine“ und „nur wenn als Dienst ausgeführt“ sind Zeichen dieser Art von Problem.
Führen Sie vorbeugende Prüfung aus. Aktivieren Sie Application Verifier und führen Sie Ihre Tests aus, und Sie können gefährliche Aufrufe innerhalb von DllMain zur Laufzeit erkennen.1 Für C++/CLI ignorieren Sie die Warnung C4747 nicht; in Prüfungen von Funktionen, die von DllMain erreichbar sind, fügen Sie den Blickwinkel „Funktionen, die LoadLibrary indirekt aufrufen“ (COM-Initialisierung, einige CRT-Funktionen, verzögert geladene Importe und so weiter) zur Prüfliste hinzu, und Sie fangen Unfälle, bevor Sie sie ausliefern. Der erste Aufruf eines verzögert geladenen Imports, der intern zu LoadLibrary wird, ist ein leicht zu übersehender Punkt.
7. Zusammenfassung
DllMainwird aufgerufen, während die Ladersperre gehalten wird (eine pro Prozess, die Sperre, die jede DLL-Benachrichtigung serialisiert). Jede Einschränkung folgt daraus.- Der Kern der Verbote ist „rufen Sie
LoadLibrary/FreeLibrarynicht auf“, „synchronisieren Sie nicht mit anderen Threads“ und „rufen Sie keine Funktionen auf, die von einer anderen DLL als Kernel32 abhängen“. Konstruktoren und Destruktoren statischer Objekte, die über die CRT laufen, fallen unter dieselben Einschränkungen. - Die grundlegende Entwurfspolitik ist Aufschieben. Machen Sie die Initialisierung statisch, die Sie statisch machen können; schieben Sie den Rest bis zur ersten Nutzung auf. Nutzen Sie
DisableThreadLibraryCallsund Application Verifier. - Das Stoppen von Threads beim Entladen folgt dem offiziellen Protokoll (signalisieren → Konsistenz bestätigen → beenden). DLL_PROCESS_DETACH am Prozessende ist idealerweise leer.
- In C++/CLI ist das Ausführen von MSIL unter der Ladersperre eine Mine für sich. Bestehen Sie auf eine native Kompilierung des
DllMain-Aufrufbaums.
Die DllMain-Einschränkungen sehen zunächst wie eine unvernünftige Verbotsliste aus. Aber sobald Sie den einen Punkt halten, dass „sie aufgerufen wird, während die Ladersperre, die oberste Sperre, gehalten wird“, ist jedes Verbot eine Umformulierung desselben Prinzips. Merken Sie es als Prinzip, und wenn Sie einen Randfall treffen, der nicht in der Dokumentation steht, sollten Sie immer noch die richtige Frage stellen können: „Ist das Arbeit, die ich tun darf, während ich die Sperre halte?“
Weiterführende Artikel
- Wie die Windows-DLL-Namensauflösung funktioniert – Suchreihenfolge und SxS
- Native DLLs aus C# aufrufen: C++/CLI-Wrapper vs. P/Invoke
- Praktische Multithreading-Best-Practices: C++-Edition — Unfälle mit RAII und jthread strukturell beseitigen
- Scheinwecken — Warum Bedingungsvariablen „ohne Benachrichtigung“ aufwachen und wie Sie unter Windows richtig warten
- Absturz-Dumps mit WinDbg + SOS lesen — Ein praxistauglicher Leitfaden zur Analyse nach der Sammlung
- Grundlagenwissen zu COM STA/MTA – Threadmodelle und wie man Hänger vermeidet
Zugehörige Beratungsfelder
KomuraSoft LLC übernimmt Ursachenuntersuchung von Hängern und Deadlocks zur Start- oder DLL-Ladezeit (Dump-Analyse), Entwurfsreviews rund um DllMain und statische Initialisierung sowie die Sanierung von C++/CLI-Wrappern und Plug-in-DLLs hin zu einem sicheren Initialisierungsentwurf. Sie können uns auch in der schwer reproduzierbaren Stufe „es hängt nur in einer bestimmten Umgebung beim Start“ konsultieren.
- Fehleruntersuchung und Ursachenanalyse
- Technische Beratung und Design-Review
- Windows-Anwendungsentwicklung
- Kontakt
Quellen
-
Microsoft Learn, Dynamic-Link Library Best Practices. Dazu, dass DllMain aufgerufen wird, während die Ladersperre gehalten wird, sodass die Funktionen, die Sie aufrufen können, stark eingeschränkt sind; dazu, dass die ideale DllMain ein leerer Stub ist und Initialisierung so weit wie möglich aufgeschoben wird; zur Empfehlung kompilierzeit-statischer Initialisierung; dazu, nur das Minimum für Fehlschläge zu tun, die früh erkannt werden müssen; sowie zum Erkennen typischer DllMain-Fehler mit Application Verifier. ↩ ↩2 ↩3 ↩4 ↩5 ↩6
-
Microsoft Learn, DllMain entry point. Dazu, im Einstiegspunkt nur einfache Initialisierung und Beendigung auszuführen; warum Sie LoadLibrary / FreeLibrary nicht aufrufen dürfen (zirkuläre Ladereihenfolge und Nutzung einer DLL vor der Initialisierung oder nach der Beendigung); dazu, dass Kernel32.dll garantiert bereits geladen ist, sodass Sie sie in dem Bereich aufrufen können, der keine anderen DLLs lädt; dazu, dass es keine erschöpfende Liste sicherer Funktionen gibt; dazu, dass User-, Shell- und COM-Funktionen Zugriffsverletzungen verursachen; dazu, dass DLL-Benachrichtigungen serialisiert sind, sodass Kommunikation mit anderen Threads oder Prozessen Deadlocks verursacht; sowie dazu, dass dieselben Einschränkungen für Konstruktoren und Destruktoren statischer Objekte gelten, wenn die CRT gebunden ist. ↩ ↩2 ↩3 ↩4 ↩5 ↩6 ↩7
-
Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. Zur Struktur, die deadlockt, wenn Sie innerhalb von DllMain auf das Ende eines Threads warten (die DLL_THREAD_DETACH-Benachrichtigung des Thread-Endes braucht die Ladersperre); zum Protokoll zum Stoppen eines Threads beim Entladen (mit einem Ereignis signalisieren, einen konsistenten Zustand bestätigen, dann beenden); dazu, dass DLL_PROCESS_DETACH am Prozessende die anderen Threads bereits zwangsweise beendet hat und keine Garantie der Adressraumkonsistenz besteht, sodass der ideale Handler leer ist; sowie dazu, dass das Anlegen eines Threads in DllMain Benachrichtigungen mit unvollständiger Initialisierung in der Warteschlange lässt und Probleme verursacht. ↩ ↩2 ↩3 ↩4
-
Microsoft Learn, Initialization of Mixed Assemblies. Dazu, MSIL nicht unter der Ladersperre auszuführen; DllMain und seinen Aufrufbaum nicht nach MSIL zu kompilieren und das über #pragma unmanaged zu behandeln; dazu, dass die Warnung C4747 ausgegeben wird, wenn DllMain versucht, MSIL direkt auszuführen, indirekte Ausführung über ein anderes Modul aber nicht erkennbar ist; sowie dazu, dass dynamische Initialisierer statischer Objekte dasselbe Problem verursachen können. ↩ ↩2
-
Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). Zum Deaktivieren der Benachrichtigungen DLL_THREAD_ATTACH / DLL_THREAD_DETACH, um den Aufwand bei Thread-Anlage und -Zerstörung zu senken; dazu, es nicht aus einer mit der statischen CRT gebundenen DLL aufzurufen; sowie dazu, dass die Optimierung nicht ausgeführt wird, wenn statisches TLS (thread_local oder __declspec(thread)) in Kraft ist. ↩ ↩2
-
Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. Zum Definieren einer Sperrhierarchie und immer im selben Reihenfolge erwerben; dazu, dass der Lader die Ladersperre erwirbt, bevor er DllMain aufruft, sodass die Ladersperre an der Spitze der Sperrhierarchie sitzen sollte; zum Beachten der Erwerbsreihenfolge zwischen APIs, die die Ladersperre indirekt nehmen, wie GetModuleFileName, und privaten Sperren; sowie zu einem konkreten Beispiel eines Deadlocks durch Umkehrung der Sperrreihenfolge. ↩ ↩2
Verwandte Artikel
Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.
Die Win32-Thread-Pool-API — Nebenläufigkeit ohne eigene Threads, über CreateThreadpoolWork
Verstreuen Sie CreateThread-Aufrufe über Ihren nativen Code? Dieser Artikel erklärt die in Vista neu entworfene Win32-Thread-Pool-API — d...
Was „Keine Rückmeldung“ wirklich ist — Wie Windows entscheidet, dass eine App hängt, und wie Sie Apps entwerfen, die das nicht tun
„Keine Rückmeldung“ unter Windows ist ein Mechanismus, in dem das Betriebssystem urteilt, dass ein Fenster 5 Sekunden lang keine Nachrich...
Scheinwecken — Warum Bedingungsvariablen „ohne Benachrichtigung“ aufwachen und wie Sie unter Windows richtig warten
Das Warten einer Bedingungsvariable kann zurückkehren, auch wenn keine Benachrichtigung angekommen ist (ein Scheinwecken). Dieser Artikel...
Named Pipes in der Praxis — Windows' Standard-IPC von Entwurf bis Sicherheit
Ein Praxisleitfaden zu Named Pipes, der Standard-Prozesskommunikation unter Windows. Der Artikel ordnet anhand von Primärquellen die Wahl...
Apps, die nach dem Fortsetzen aus dem Schlaf kaputtgehen — Wie Windows-Energieereignisse funktionieren und wie Sie Geschäftsanwendungen bauen, die sie überstehen
Sie haben den Laptop aufgeklappt, und die Verbindungen der Geschäftsanwendung waren tot — die Ursache ist ein Entwurf, der Schlaf nie ein...
Verwandte Themen
Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.
Technische Windows-Themen
Portal zu Windows-Entwicklung, Fehleranalyse und der Nutzung bestehender Assets.
Fehleranalyse und Langzeitprobleme
Sporadische Fehler, Kommunikationsdiagnose, Langzeitabstürze und Tests von Fehlerpfaden.
Leistungen zu diesem Thema
Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.
Windows-App-Entwicklung
Geschäftsanwendungen, Geräteintegration und Kommunikationstools von den Anforderungen bis zur Umsetzung.
Häufige Fragen
Fragen, die in Beratungen zu diesem Artikelthema häufig gestellt werden.
- Kann man in DllMain wirklich gar nichts tun?
- „Nichts tun“ ist keine Übertreibung; es ist die offizielle Entwurfsposition, und Microsoft selbst sagt, dass die ideale DllMain ein nahezu leerer Stub ist. Sicher ist eine Teilmenge der Funktionen von Kernel32.dll — Kernel32 ist garantiert geladen, wenn DllMain läuft — in dem Bereich, der keine anderen DLLs lädt. Das Anlegen einer Critical Section oder eines Mutex und die Nutzung von TLS sind Beispiele für das, was Sie tun können. Umgekehrt sind LoadLibrary/FreeLibrary, das Synchronisieren mit anderen Threads und das Aufrufen von Funktionen in User32, Shell, COM und Ähnlichem verboten, weil sie Deadlocks und Zugriffsverletzungen verursachen. Initialisierung, bei der Sie unsicher sind, sollten Sie nicht in DllMain tun; schieben Sie sie bis zur ersten Nutzung auf.
- Fallen Konstruktoren von C++-Globalen (statischen Objekten) auch unter die DllMain-Einschränkungen?
- Ja. Wenn die DLL mit der CRT (der C++-Laufzeit) gebunden ist, laufen Konstruktoren und Destruktoren von globalen und statischen Objekten über den Einstiegspunkt, den die CRT bereitstellt, als de-facto-Teil von DllMain. Das heißt, LoadLibrary aus einem Konstruktor aufzurufen, einen anderen Thread zu starten und auf sein Ende zu warten, COM zu initialisieren und so weiter tragen alle dieselbe Gefahr wie das Tun derselben Dinge in DllMain. Für ein globales Objekt mit nichttrivialer Initialisierung halten Sie einen Zeiger und konstruieren Sie es beim ersten Zugriff, oder nutzen Sie eine funktionslokale static, sodass die Arbeit außerhalb von DllMain läuft.
- Soll ich DisableThreadLibraryCalls aufrufen?
- Bedingt ja. Wenn die DLL keine DLL_THREAD_ATTACH/DETACH-Benachrichtigungen braucht, stoppt der Aufruf von DisableThreadLibraryCalls in DLL_PROCESS_ATTACH die Benachrichtigungen pro Thread-Anlage und pro Thread-Ende und senkt den Aufwand in einem Prozess, der häufig Threads anlegt. Es gibt zwei Ausnahmen. Rufen Sie es nicht aus einer DLL auf, die mit der statischen CRT gebunden ist (die statische CRT braucht die Thread-Benachrichtigungen). Und wenn statisches TLS über thread_local oder __declspec(thread) in Kraft ist, schlägt der Aufruf selbst fehl und gibt FALSE zurück, also machen Sie sich zur Gewohnheit, den Rückgabewert zu prüfen. Nutzen Sie es auf einer typischen DLL, die die dynamisch gebundene CRT nutzt, nachdem Sie bestätigt haben, dass nichts von den Thread-Benachrichtigungen abhängt.
- Warum hängt eine C++/CLI-(gemischt verwaltete) DLL beim Start?
- Die typische Ursache ist der Versuch, MSIL (verwalteten Code) auszuführen, während die Ladersperre gehalten wird. In einer gemischten C++/CLI-Assembly können, wenn DllMain, von ihr aufgerufene Funktionen oder dynamische Initialisierer von Globalen nach MSIL kompiliert sind, unter der Ladersperre die CLR-Initialisierung oder das Laden einer anderen Assembly nötig werden, und das kann deadlocken. Der Compiler gibt die Warnung C4747 aus, wenn DllMain selbst versucht, MSIL direkt auszuführen, aber er kann indirekte Ausführung über ein anderes Modul nicht erkennen. Die Gegenmaßnahme ist, DllMain und seinen Aufrufbaum mit #pragma unmanaged nativ zu kompilieren — oder gar keine DllMain zu haben.
- Darf ich Ressourcen in DLL_PROCESS_DETACH aufräumen?
- Die Antwort ändert sich zwischen „Prozessende“ und „Entladen über FreeLibrary“. Bei DLL_PROCESS_DETACH am Prozessende sind die anderen Threads bereits beendet, und es gibt keine Garantie, dass der Adressraum noch konsistent ist, sodass Aufräumen wie das Freigeben von Speicher tatsächlich gefährlich ist; die offizielle Leitlinie ist, dass „der ideale Handler leer ist“. Schreiben Sie Daten, die erhalten bleiben müssen, im eigenen Beendigungspfad der App heraus, und tun Sie hier im Wesentlichen nichts und kehren Sie zurück. Bei einem Entladen über FreeLibrary läuft der Prozess weiter, also brauchen Sie vollständiges Aufräumen — Threads stoppen, Handles schließen und so weiter. Das Warten auf das Ende eines Threads innerhalb von DllMain deadlockt allerdings, also müssen Sie das offizielle Protokoll befolgen: signalisieren, bis zu einem konsistenten Zustand warten und die Arbeit außerhalb von DllMain beenden.
Autorenprofil
Profilseite des Artikelautors.
Go Komura
Geschäftsführer von KomuraSoft LLC
Spezialisiert auf Windows-Softwareentwicklung, technische Beratung und Fehleranalyse, insbesondere bei bestehenden Systemen und schwer reproduzierbaren Störungen.