DllMain und die Ladersperre — Der wahre Grund, warum man sagt, in der DLL-Initialisierung nichts zu tun

· Aktualisiert am: · · Windows, DLL, Windows-Entwicklung, C++, Fehleruntersuchung, Multithreading, Win32-API

Änderungsverlauf (Erstfassung, veröffentlicht am 22. Aug 2026)
Erstveröffentlichung
Diesen Artikel zitieren(DOI (registriertes Archiv): 10.5281/zenodo.22176697)

Die folgenden DOIs verweisen auf bereits archivierte Versionen, die vom aktuellen Text abweichen können. Verwenden Sie die URL dieser Seite, um auf den aktuellen Text zu verweisen.

Go Komura (2026). DllMain und die Ladersperre — Der wahre Grund, warum man sagt, in der DLL-Initialisierung nichts zu tun. KomuraSoft LLC. https://comcomponent.com/de/blog/dllmain-loader-lock/

DOI (registriertes Archiv)
10.5281/zenodo.22176697
DOI (zuletzt registrierte Version)
10.5281/zenodo.22176698

„Wenn wir unsere eigene DLL laden, kehrt LoadLibrary nicht zurück.“ „Es hängt nur auf bestimmten PCs, oder nur beim Start des Dienstes.“ Bei solchen Störungen ist einer der ersten Orte, die Sie prüfen sollten, der Initialisierungscode der DLL: DllMain und alles, was von dort aufgerufen wird.

DllMain ist nicht wie eine gewöhnliche Initialisierungsfunktion einer App. Es ist eine Funktion, die das Betriebssystem aufruft, während es die Ladersperre hält, daher ist stark eingeschränkt, was sie tun darf. Microsofts Haltung, dass „die ideale DllMain ein leerer Stub ist“, kommt daher, dass diese Einschränkung nicht nur Ihre eigene DLL betrifft, sondern die anderen DLLs und Threads im Prozess.1

Dieser Artikel richtet sich an Entwickler, die unter Windows DLLs, Plug-ins und C++/CLI-Wrapper schreiben. Er arbeitet das Thema in dieser Reihenfolge durch: warum es stehen bleibt, dann wohin die Initialisierung gehört, dann wie man beendet, dann wie man einen Hänger untersucht.

1. Zuerst das Fazit — DllMain klein halten und den Ausführungszeitpunkt ändern

Statt nach einer sicheren Schreibweise innerhalb von DllMain zu suchen, ist der Grundansatz, die Arbeit zu reduzieren, die dort läuft. Was bleibt, entscheiden Sie in dieser Reihenfolge.1

Entscheidung Grundsatz Wo Sie mehr lesen
Wann initialisieren Was zur Kompilierzeit festliegt, statisch erledigen; den Rest bis zur ersten Nutzung nach abgeschlossenem Laden aufschieben Kapitel 5
Was Sie aus DllMain aufrufen LoadLibrary / FreeLibrary, Synchronisation mit anderen Threads und die Nutzung von User, Shell, COM und Ähnlichem vermeiden. Auch indirekte Aufrufe prüfen Kapitel 3
Was Sie beim Beenden tun Den Fall, in dem nur die DLL entladen wird, vom Fall trennen, in dem der ganze Prozess endet Kapitel 6

Leicht zu übersehen ist, dass dieselben Einschränkungen für die Konstruktoren und Destruktoren globaler und statischer C++-Objekte gelten. In C++/CLI beseitigen Sie außerdem jeden Pfad, der unter der Ladersperre MSIL ausführt. Ob der DllMain-Rumpf selbst leer ist, reicht zur Beurteilung nicht.23

Wenn Sie gerade einen Hänger untersuchen, beginnen Sie mit Kapitel 7; wenn Sie einen Entwurf prüfen, lesen Sie die Kapitel 2 bis 6 der Reihe nach, und Sie können jedes Verbot seiner Gegenmaßnahme zuordnen.

2. Der Mechanismus — DllMain wird innerhalb der Ladersperre aufgerufen

2.1 Es wird nicht nur beim Laden aufgerufen, sondern auch beim Start und Ende von Threads

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.2

Benachrichtigung Zeitpunkt
DLL_PROCESS_ATTACH Wenn die DLL in den Prozess geladen wird
DLL_THREAD_ATTACH Wenn im Prozess ein neuer Thread startet
DLL_THREAD_DETACH Wenn ein Thread normal endet
DLL_PROCESS_DETACH Wenn die DLL entladen wird oder der Prozess endet

Eine Thread-Start-Benachrichtigung geht nicht nur an die DLL, die den Thread angelegt hat, sondern an jede im Prozess geladene DLL. DllMain ist nicht „Code, der einmal läuft, wenn meine DLL geladen wird“. In einem Prozess, der häufig Threads anlegt, läuft sie bei jeder Benachrichtigung.2

Wenn Sie die Benachrichtigungen nicht brauchen, ist eine Option, innerhalb von DLL_PROCESS_ATTACH DisableThreadLibraryCalls aufzurufen. In einer mit der statischen CRT gebundenen DLL ist das jedoch nicht nutzbar, und statisches TLS stellt eine eigene Bedingung, daher behandelt Abschnitt 5.3 diese Entscheidung getrennt.4

2.2 Der Aufruf bei gehaltener Sperre ist der Ausgangspunkt aller Einschränkungen

Damit Laden, Entladen und Benachrichtigungen von DLLs konsistent bleiben, serialisiert der OS-Lader sie mit einer Ladersperre pro Prozess. Der wichtige Punkt ist, dass er diese Sperre erwirbt, bevor er DllMain aufruft, und sie hält, während DllMain läuft.1

In dieser Zeit wartet jeder andere Thread desselben Prozesses, der eine DLL laden oder eine Thread-Start- oder -Ende-Benachrichtigung fortsetzen will, auf die Freigabe der Sperre. Das ist kein Ort, an dem Sie nur weil es Ihrer eigenen DLL gelegen kommt, ein langes Warten einfügen dürfen.

Warum Arbeit in DllMain die DLL-Benachrichtigungen im ganzen Prozess betrifftDer Lader erwirbt die prozessweite Ladersperre, bevor er DllMain aufruft, und DLL-Laden sowie Thread-Benachrichtigungen auf anderen Threads warten auf ihre Freigabe, sodass Arbeit in DllMain auch andere DLLs und Threads betrifftLader erwirbt die gemeinsame SperreDllMain wird ausgeführtDllMain kehrt zurückLadersperre wird freigegebenDLL-Laden oder Benachrichtigung auf einem anderen ThreadWartet auf die Freigabe derselben SperreWartende Arbeit kann fortfahren

Abbildung 1: Während DllMain läuft, wird die gemeinsame Sperre gehalten, daher hält ein Warten dort den Fortschritt anderer DLLs und Threads auf.

Die folgenden Verbote werden verständlich, sobald Sie fragen: „Braucht diese Arbeit direkt oder indirekt die Ladersperre oder die Initialisierung einer anderen DLL?“

3. Warum es stehen bleibt — die Verbote über vier Pfade verstehen

3.1 Etwas aufrufen, das eine andere DLL lädt

Vermeiden Sie den Aufruf von LoadLibrary / FreeLibrary aus DllMain. LoadLibrary erzeugt zirkuläre Abhängigkeiten in der Ladereihenfolge und führt dazu, dass eine DLL genutzt wird, bevor ihr Initialisierungscode gelaufen ist. Auf der Beendigungsseite besteht ebenso die Gefahr, eine DLL zu nutzen, die bereits verarbeitet oder freigegeben wurde.2

Dass Sie selbst kein LoadLibrary geschrieben haben, macht Sie nicht sicher. Einige User-, Shell- und COM-Funktionen laden intern andere Systemkomponenten und können eine noch nicht initialisierte oder bereits freigegebene Komponente anfassen und eine Zugriffsverletzung verursachen.2

Die Basis dessen, was sicher aufgerufen werden kann, sind die Funktionen in Kernel32.dll, die keine anderen DLLs laden, weil Kernel32.dll zum Zeitpunkt von DllMain garantiert geladen ist. Sie können zum Beispiel Synchronisierungsobjekte wie Critical Sections und Mutexes anlegen und TLS nutzen. Die offizielle Dokumentation stellt jedoch ausdrücklich fest, dass keine erschöpfende Liste sicherer Funktionen existiert. Denken Sie nicht „es ist in Kernel32, also ist alles erlaubt“ oder „ich kann ein Synchronisierungsobjekt anlegen, also darf ich auf andere Threads warten“.2

3.2 Innerhalb von DllMain auf das Ende eines anderen Threads warten

Der klassische Deadlock entsteht beim Entladen der DLL: DllMain fordert einen Worker-Thread auf zu stoppen und wartet dann auf sein Ende.

Selbst nachdem der Worker seine eigene Arbeit beendet hat, muss er die DLL_THREAD_DETACH-Benachrichtigung beim Thread-Ende durchlaufen. Diese Benachrichtigung braucht die Ladersperre, die die wartende DllMain hält. Im Ergebnis wartet DllMain auf den Worker, und der Worker wartet darauf, dass DllMain zurückkehrt.5

Warum das Warten auf einen Thread in DllMain deadlocktDllMain hält die Ladersperre und wartet auf das Ende eines Worker-Threads, aber der endende Worker-Thread wartet für seine DLL_THREAD_DETACH-Benachrichtigung auf die Freigabe der Ladersperre, sodass beide aufeinander warten und deadlockenWorker-ThreadDllMainLader (hält die Sperre)Worker-ThreadDllMainLader (hält die Sperre)Die Ende-Benachrichtigung braucht die LadersperreDllMain wartet und hält die Sperre, W wartet auf die SperreDLL_PROCESS_DETACH benachrichtigenEnde anfordern und auf Abschluss wartenArbeit beenden und zum Thread-Ende gehen

Abbildung 2: Selbst nachdem der Worker seine Arbeit beendet hat, kommt er nicht durch die Thread-Ende-Benachrichtigung, daher löst sich das Ende-Warten innerhalb von DllMain nicht auf.

Das ist kein Fall von „es bleibt stehen, wenn Sie Pech haben“; das gegenseitige Warten hält strukturell. Das Aufräumen, das beim Entladen nötig ist, behandelt Kapitel 6 getrennt von der Arbeit am Prozessende.

3.3 Die Erwerbsreihenfolge der eigenen Sperre und der Ladersperre kehrt sich um

Es geht nicht nur um Thread-Start und -Ende; APIs wie GetModuleHandle brauchen intern ebenfalls die Ladersperre. Wenn die folgenden zwei Pfade überlappen, kehrt sich die Erwerbsreihenfolge der Sperren um.6

  • Auf der DllMain-Seite versucht Code, der bereits die Ladersperre hält, die private Sperre G zu nehmen.
  • Auf der Worker-Seite ruft Code, der bereits die private Sperre G hält, eine API auf und versucht, die Ladersperre zu nehmen.
Reihenfolgeumkehr zwischen Ladersperre und privater SperreDllMain geht bei gehaltener Ladersperre an eine private Sperre, und ein Worker-Thread geht bei gehaltener privater Sperre an die Ladersperre, etwa für GetModuleHandle, sodass die Erwerbsreihenfolge umgekehrt ist und sie deadlockenDllMain: hält die LadersperreGeht an die private Sperre GWorker: hält die private Sperre GGeht an die LadersperreDeadlock durch umgekehrte ErwerbsreihenfolgeIntern von GetModuleHandle und anderen verlangt

Abbildung 3: Nimmt die eine Seite Ladersperre und dann G, die andere G und dann die Ladersperre, wartet jede auf die Freigabe der anderen.

Die offizielle Leitlinie verlangt, die Ladersperre als die Spitze der Sperrhierarchie der App zu behandeln, also als die zuerst erworbene Sperre. In dem Moment, in dem Sie in DllMain sind, ist diese Sperre bereits gehalten. Prüfen Sie nicht nur den Namen der aufgerufenen Funktion, sondern auch welche Sperren Sie halten, wenn Sie sie aufrufen.6

3.4 Schon das bloße Anlegen eines Threads hinterlässt Startwarte- und Lebensdauerprobleme

Auch CreateThread innerhalb von DllMain wird nicht empfohlen. Der neue Thread kann seine Thread-Funktion erst ausführen, wenn die DLL_THREAD_ATTACH-Benachrichtigung verarbeitet ist. Weil die aktuelle DllMain die Ladersperre hält, deadlockt das Warten innerhalb dieser DllMain auf den Start oder Abschluss des Threads.5

Nicht zu warten löst auch nicht alles. Wird die DLL entladen, nachdem DllMain zurückgekehrt ist, der von Ihnen angelegte Thread aber noch nicht zu laufen begonnen hat, zeigt die Startadresse des Threads auf bereits freigegebenen Code, und ein Absturz bleibt möglich.5

Zwei Probleme, die beim Anlegen eines Threads in DllMain bleibenEin in DllMain angelegter Thread wartet für seine Startbenachrichtigung auf die Ladersperre, sodass DllMain deadlockt, wenn sie auf Start oder Abschluss wartet; kehrt DllMain ohne Warten zurück, endet die Lebensdauer des Codes und es stürzt ab, wenn die DLL entladen wird, bevor der Thread zu laufen beginntJaNeinThread innerhalb von DllMain angelegtNeuer Thread wartet auf die StartbenachrichtigungInnerhalb von DllMain auf Start oder Abschluss warten?Gegenseitiges Warten bei gehaltener SperreRückkehr aus DllMainDLL entladen, bevor der Thread zu laufen beginntCode an der Startadresse ist weg

Abbildung 4: Innerhalb von DllMain nicht zu warten und die Lebensdauer der DLL zu schützen, die der angelegte Thread nutzt, sind zwei getrennte Anforderungen.

4. Zwei Stellen, die auch bei leerer DllMain gefährlich sind

4.1 Dynamische Initialisierung globaler und statischer Objekte

In einer mit der CRT (der C/C++-Laufzeit) gebundenen DLL laufen die Konstruktoren und Destruktoren globaler und statischer C++-Objekte über den Einstiegspunkt der CRT. Sie sind faktisch Teil von DllMain und unterliegen denselben Einschränkungen.2

Das Laden einer Konfigurationsdatei, den Start eines Loggers, COM-Initialisierung oder Thread-Start in einen Konstruktor zu legen versteckt nur den Aufrufort; der Ausführungszeitpunkt bleibt innerhalb der Ladersperre. Enthält diese komplexe Arbeit das Laden einer anderen DLL oder Thread-Synchronisation, ist sie genau so gefährlich, als stünde sie im DllMain-Rumpf.

Wie die Initialisierung globaler Objekte zur Mine wirdDas Laden der DLL erwirbt die Ladersperre und die Konstruktoren globaler Objekte laufen über die CRT, sodass ein LoadLibrary, Thread-Synchronisation oder COM-Initialisierung darin die Ausführung dessen ist, was DllMain verbietetDLL-Laden (Ladersperre erworben)CRT-EinstiegspunktKonstruktor des globalen ObjektsArbeit gleich LoadLibraryThread starten und auf Abschluss wartenNutzung von COM oder User32Alles davon sind DllMain-Verbote

Abbildung 5: Reviewen Sie nicht nur den DllMain-Rumpf, sondern auch Initialisierung und Beendigung statischer Objekte, die von der CRT aufgerufen werden.

Behandeln Sie kompilierzeitliche Konstanteninitialisierung, etwa alles, was constexpr sein kann, getrennt von komplexer Laufzeitinitialisierung. Schieben Sie dynamische Initialisierung, die Funktionsaufrufe enthält, auf und sorgen Sie dafür, dass auch der erste Zugriff außerhalb von DllMain geschieht.

4.2 MSIL, das in einem C++/CLI-Aufrufziel ausgeführt wird

Das Wrappen einer nativen DLL in C++/CLI behandelt Native DLLs aus C# aufrufen: C++/CLI-Wrapper vs. P/Invoke. In dieser Konfiguration achten Sie auf Pfade, die unter der Ladersperre MSIL (verwalteten Code) ausführen. Wenn die Ausführung des MSIL CLR-Initialisierung oder das Laden einer anderen Assembly erfordert, ist ein Deadlock möglich.3

Der Compiler gibt die Warnung C4747 für Code aus, in dem DllMain direkt versucht, MSIL auszuführen. Indirekte Ausführung über eine Funktion in einem anderen Modul kann er nicht erkennen. Das Fehlen der Warnung allein ist keine Grundlage, den Code für sicher zu erklären. Dynamische Initialisierer statischer Objekte gehören ebenfalls in den Prüfbereich.3

Ob MSIL-Ausführung unter der Ladersperre erkennbar istCode, in dem DllMain MSIL direkt ausführt, kann der Compiler mit Warnung C4747 erkennen, indirekte Ausführung über eine Funktion in einem anderen Modul jedoch nicht, daher muss man sie durch Review des Aufrufbaums und durchgängig native Kompilierung verhindernAufruf aus DllMainFührt MSIL direkt ausFührt über ein anderes Modul ausErkennbar durch Warnung C4747Compiler kann es nicht erkennenVerhindern durch Review und #pragma unmanaged

Abbildung 6: Neben dem direkten Pfad, den C4747 erkennt, reviewen Sie auch Aufrufe, die über ein anderes Modul gehen.

Die Gegenmaßnahme ist, DllMain und jede von dort erreichbare Funktion mit #pragma unmanaged nativ zu kompilieren, oder gar keine DllMain zu haben. Auch bei Letzterem dürfen indirekte Pfade wie statische Initialisierer nicht übersehen werden.3

5. Entwurf der Initialisierung — statisch machen, aufschieben, nur das Minimum behalten

5.1 Bevor Sie etwas in DllMain lassen, prüfen Sie, ob sich der Ausführungszeitpunkt ändern lässt

Der offizielle Grundsatz ist, die Initialisierung, die Sie können, zur Kompilierzeit zu erledigen und den Rest so weit wie möglich aufzuschieben. Nur Arbeit, die früh als Ladefehler erkannt werden muss, bleibt als Ausnahme und im Minimum.1

Es kann zum Beispiel die Anforderung geben, das Laden der DLL selbst scheitern zu lassen, weil eine abhängige Konfigurationsdatei beschädigt ist. Auch dann engen Sie es auf „die nötige Arbeit versuchen und sofort scheitern“ ein, statt andere Initialisierung zuerst laufen zu lassen und danach zu scheitern. Das ist keine Ausnahme, die komplexe Initialisierung zur Ladezeit erlaubt.1

Entwurfsleitlinien für die DLL-InitialisierungZuerst prüfen, ob die Initialisierung zur Kompilierzeit statisch sein kann; wenn nicht, standardmäßig bis zur ersten Nutzung aufschieben und in DllMain nur das Minimum lassen, das früh als Ladefehler erkannt werden mussJaNeinNeinJaZur Kompilierzeit entscheidbar?Zur statischen Initialisierung machenMuss der Fehler beim Laden erkannt werden?Bis zur ersten Nutzung aufschieben (der Standard)Nur das Minimum in DllMain tunMit INIT_ONCE oder funktionslokaler static absichern

Abbildung 7: Statische und aufgeschobene Initialisierung zuerst prüfen und in DllMain nur das Minimum lassen, das früh erkannt werden muss.

5.2 Aufgeschobene Initialisierung muss bis zu „wer ruft sie zuerst auf“ entworfen werden

Für den gegenseitigen Ausschluss bei der ersten Nutzung können Sie einmalige Initialisierung mit INIT_ONCE oder eine C++-funktionslokale static (eine Magic Static) nutzen. Die Idee ist, komplexe globale Objekte auf einen beim ersten Zugriff erzeugten Zeiger oder auf eine funktionslokale static zu verschieben.

Aufschieben allein bringt Sie jedoch nicht aus der Ladersperre heraus. Die Einschränkung entfällt erst, wenn der erste Zugriff von „einer gewöhnlichen API kommt, die nach abgeschlossenem DLL-Laden aufgerufen wird“. Dann kann es als gewöhnliche Initialisierung entworfen werden, die nahezu die gesamte Windows-API nutzen darf.1

Umgekehrt: geschieht dieser erste Zugriff aus DllMain oder einem statischen Initialisierer, läuft die Initialisierung am Ende doch unter der Ladersperre. Über das Herausziehen einer Initialisierungsfunktion hinaus bestätigen Sie wer sie zuerst aufruft, und wann.

Wann aufgeschobene Initialisierung aus der Ladersperre herauskommtKommt der erste Zugriff der aufgeschobenen Initialisierung von einer gewöhnlichen API nach abgeschlossenem Laden, kann außerhalb der Ladersperre initialisiert werden; kommt er aus DllMain oder einem statischen Initialisierer, läuft sie unter denselben EinschränkungenGewöhnliche API nach abgeschlossenem LadenDllMain oder statischer InitialisiererWoher kommt der erste Zugriff?Außerhalb der Ladersperre initialisierenUnter denselben Einschränkungen initialisierenMit INIT_ONCE oder funktionslokaler static absichernAuch den Zeitpunkt des ersten Zugriffs verschieben

Abbildung 8: INIT_ONCE und funktionslokale statics übernehmen den gegenseitigen Ausschluss der Initialisierung; sie garantieren nicht, dass sie außerhalb der Ladersperre aufgerufen wird.

5.3 DisableThreadLibraryCalls anhand von drei Bedingungen entscheiden

In einer DLL, die nicht von Thread-Benachrichtigungen abhängt, stoppt der Aufruf von DisableThreadLibraryCalls in DLL_PROCESS_ATTACH die Benachrichtigungen DLL_THREAD_ATTACH / DLL_THREAD_DETACH. In einem Prozess, der häufig Threads anlegt, senkt das den Benachrichtigungsaufwand.4

Vor der Anwendung prüfen Sie die statische CRT, statisches TLS und ob irgendetwas die Benachrichtigungen nutzt. Eine mit der statischen CRT gebundene DLL darf es nicht aufrufen, weil die CRT selbst die Thread-Benachrichtigungen braucht. Wenn statisches TLS über thread_local oder __declspec(thread) in Kraft ist, schlägt der Aufruf selbst fehl und gibt FALSE zurück.4

Ob DisableThreadLibraryCalls aufgerufen werden sollEine mit der statischen CRT gebundene DLL darf es nicht aufrufen, und bei statischem TLS schlägt der Aufruf selbst fehl, daher wird er nicht genutzt; eine DLL, die keines von beiden ist und keine Thread-Benachrichtigungen nutzt, kann es in DLL_PROCESS_ATTACH aufrufen, den Rückgabewert prüfen und den Benachrichtigungsaufwand senkenJaNeinJaNeinNeinJaMit der statischen CRT gebunden?Darf nicht aufgerufen werdenNutzt statisches TLS?Aufruf schlägt sowieso fehl (FALSE)Braucht Thread-Benachrichtigungen?In ATTACH aufrufen (Rückgabewert prüfen)Nicht aufrufen; die Benachrichtigungen behandeln

Abbildung 9: Unterscheiden Sie die statische CRT, wo es nicht genutzt werden darf, von statischem TLS, wo es fehlschlägt, und prüfen Sie den Rückgabewert auch dann, wenn Benachrichtigungen unnötig sind.

Es ist eine Optimierung für eine typische DLL, die die dynamisch gebundene CRT nutzt und diese Bedingungen erfüllt. DisableThreadLibraryCalls existiert, um Benachrichtigungen zu reduzieren; es ist kein Mittel, komplexe Initialisierung in DllMain zu tun.

6. Beendigung — DLL-Entladen und Prozessende trennen

6.1 Derselbe DLL_PROCESS_DETACH, aber was danach bleibt, unterscheidet sich

DLL_PROCESS_DETACH wird sowohl geliefert, wenn nur die DLL entladen wird, als auch wenn der ganze Prozess endet. Die Benachrichtigung hat denselben Namen, aber die Voraussetzungen für das Aufräumen unterscheiden sich.5

Beim Entladen über FreeLibrary läuft der Prozess danach weiter. Deshalb müssen Threads gestoppt und offene Handles, reservierte Ressourcen, Zustand, der erhalten bleiben muss, und so weiter ordentlich aufgeräumt werden. Wie Abschnitt 3.2 zeigte, deadlockt jedoch das Warten innerhalb von DllMain auf das natürliche Ende eines Threads zu diesem Zweck.5

Noch besser vermeiden Sie einen Entwurf, in dem eine entladbare DLL überhaupt Threads besitzt; Thread-Besitz auf die EXE-Seite zu verlagern ist die sicherste Option. Bei einem bestehenden Entwurf, in dem die DLL Threads besitzt, betrachten Sie das folgende Stoppprotokoll zusammen mit seinen Einschränkungen, nie eines ohne das andere.

6.2 Das offiziell dokumentierte Stoppprotokoll, wenn die DLL einen Worker besitzt

Microsofts Best Practices dokumentieren ein Stoppverfahren beim Entladen, das nicht auf „das natürliche Ende des Threads“ wartet, sondern auf „ein Signal, dass er einen konsistenten Zustand erreicht hat“.5

  1. Die DllMain-Seite signalisiert dem Worker über ein Ereignis, zu stoppen.
  2. Der Worker fährt seine laufende Arbeit bis zu einem konsistenten Zustand herunter, signalisiert den Abschluss und geht in ein unendliches Warten.
  3. Die DllMain-Seite bestätigt den konsistenten Zustand und beendet den Thread mit TerminateThread.

Es wirkt grob, ist aber unter der Einschränkung dokumentiert, dass das Warten auf ein natürliches Ende die Ende-Benachrichtigung mit der Ladersperre kollidieren lässt. Die Voraussetzung ist, dass auch die Arbeit, den konsistenten Zustand zu erreichen, denselben Einschränkungen wie DllMain gehorcht. Tritt diese Arbeit in das Laden einer anderen DLL oder in ein Warten auf die Ladersperre ein, ist ein Deadlock mit der auf das Signal wartenden Seite unvermeidlich.5

Beim Entladen auf Konsistenzabschluss statt auf natürliches Ende wartenDllMain signalisiert dem Worker zu stoppen, der Worker erreicht unter denselben Einschränkungen wie DllMain einen konsistenten Zustand, signalisiert zurück und geht in unendliches Warten, und DllMain bestätigt den konsistenten Zustand, bevor sie den Thread beendetWorker-ThreadDllMain (beim Entladen)Worker-ThreadDllMain (beim Entladen)Gewartet wird auf das Konsistenzsignal, nicht auf natürliches EndeStopp mit einem Ereignis signalisierenUnter denselben Einschränkungen in einen konsistenten ZustandErreichte Konsistenz signalisierenIn unendliches Warten gehenKonsistenz bestätigen, dann TerminateThread

Abbildung 10: Auch bei diesem Protokoll darf die Arbeit, die den konsistenten Zustand herstellt, nicht auf die Ladersperre warten.

Das heißt nicht, dass ein beliebiger laufender Thread einfach abgeschnitten werden darf. Prüfen Sie zuerst, ob der Thread außerhalb der DLL besessen werden kann.

6.3 Am Prozessende ist das Ideal, ohne etwas zu tun zurückzukehren

Wenn DLL_PROCESS_DETACH am Prozessende geliefert wird, sind die anderen Threads bereits beendet oder zwangsweise beendet, und auf die Konsistenz des Adressraums ist kein Verlass. Der Zustand abhängiger DLLs und Laufzeiten ist ebenfalls nicht vertrauenswürdig, sodass Aufräumen wie das Freigeben von Speicher eher gefährlich als hilfreich wird. Die offizielle Leitlinie sagt ebenfalls, dass der ideale Handler in diesem Fall leer ist.5

Schreiben Sie Daten, die erhalten bleiben müssen, im eigenen Beendigungscode der App heraus. Nutzen Sie DLL_PROCESS_DETACH nicht als letzten Ort, an dem noch alles aufgeräumt werden kann.

Aufräumvoraussetzungen unterscheiden sich zwischen DLL-Entladen und ProzessendeWird nur die DLL entladen, läuft der Prozess weiter, also müssen Ressourcen aufgeräumt werden, aber das Warten auf natürliches Ende innerhalb von DllMain vermeiden; am Prozessende nicht auf die Konsistenz anderer Threads und Ressourcen vertrauen, im Wesentlichen ohne etwas zu tun zurückkehren und zu speichernden Zustand vorher im Beendigungscode der App schreibenNur DLL-EntladenProzessendeGrund für DLL_PROCESS_DETACH?Prozess läuft danach weiterVerbleibende Ressourcen ordentlich aufräumenInnerhalb von DllMain nicht auf natürliches Ende wartenZustand anderer Threads und Ressourcen ist unsicherIm Wesentlichen ohne etwas zu tun zurückkehrenVorher im Beendigungscode der App speichern

Abbildung 11: Entscheiden Sie nicht nur „ist Aufräumen nötig“, sondern ob der Prozess weiterläuft und ob die für das Aufräumen genutzten Ressourcen vertrauenswürdig sind.

7. Untersuchung eines Hängers — die wartende und die haltende Seite der Ladersperre finden

7.1 Im Dump das Paar der aufeinander wartenden Threads bestätigen

Nehmen Sie einen Dump im Moment des Einfrierens und prüfen Sie den Stack jedes Threads. Der typische Fingerabdruck ist ein Paar: ein Thread, der innerhalb einer Laderfunktion in ntdll.dll auf eine Sperre wartet, deren Name mit Ldr beginnt, und ein Thread, der innerhalb von DllMain oder einem statischen Initialisierer (dynamic initializer) auf etwas anderes wartet.

Ein Thread, der mitten in einem LoadLibrary-Aufruf steht, ist ein weiterer typischer Beteiligter. Sobald die Beziehung zwischen der auf die Sperre wartenden Seite und der Seite, die sie hält und auf etwas anderes wartet, zusammenhängt, können Sie nahezu sicher auf einen Ladersperren-Deadlock schließen.

Gegenseitiges Warten auf die Ladersperre aus einem Hänger-Dump bestätigenIn den Stacks der Threads nach einem Sperrwarten in Ldr-Funktionen und einem Warten innerhalb von DllMain oder einem statischen Initialisierer suchen, bestätigen, dass beide aufeinander warten, und wenn das typische Paar nicht erscheint, auch andere Arten von Hängern untersuchenJaNicht sichtbarDump während des Hängers nehmenStack jedes Threads prüfenSperrwarten in Ldr-FunktionenWarten innerhalb von DllMain oder einem statischen InitialisiererHängt die Beziehung des gegenseitigen Wartens zusammen?Ladersperren-DeadlockAuch andere Arten von Hängern untersuchen

Abbildung 12: Entscheiden Sie nicht anhand eines einzelnen Stacks; schauen Sie auf das Paar aus der auf die Ladersperre wartenden Seite und der Seite, die sie hält und wartet.

Bedingungen wie „gelegentlich beim Start“, „nur auf einer bestimmten Maschine“ oder „nur wenn als Dienst ausgeführt“ sind ebenfalls Hinweise. Das gegenseitige Warten entsteht, wenn ein DLL-Laden mit einem Thread-Start oder -Ende zusammenfällt, daher ändern Umgebungsunterschiede und Ausführungszeitpunkt, wie es sich zeigt.

7.2 Mit Application Verifier und Review der Aufrufziele vorbeugen

Tests mit aktiviertem Application Verifier erkennen typische DllMain-Fehler zur Laufzeit.1 In C++/CLI ignorieren Sie die Warnung C4747 nicht und prüfen Sie die indirekten Pfade, die die Warnung nicht erkennt, aus der Sicht von Abschnitt 4.2.

Im Review suchen Sie unter der von DllMain erreichbaren Arbeit nach Funktionen, die LoadLibrary indirekt aufrufen. COM-Initialisierung, einige CRT-Funktionen und Delay-Load-Importe gehören zur Prüfung. Besonders leicht zu übersehen ist, dass der erste Aufruf einer delay-geladenen Importfunktion intern zu LoadLibrary wird. Auch wenn der Funktionsname außerhalb von DllMain liegt, bleibt die Einschränkung, solange der Aufrufer DllMain ist.

8. Zusammenfassung — nicht auf Funktionsnamen schauen, sondern auf „wann und unter welcher Sperre es läuft“

Die DllMain-Einschränkungen lassen sich alle von einem Punkt her verstehen: sie wird aufgerufen, während die prozessweite Ladersperre gehalten wird. Nicht nur Ihre eigene DllMain, sondern auch die statische Initialisierung und Beendigung der CRT und die indirekten Aufrufe von C++/CLI fallen in denselben Bereich.

Beginnen Sie eine Prüfung damit, ob Initialisierung statisch gemacht und der Rest bis nach abgeschlossenem Laden aufgeschoben werden kann. Prüfen Sie bis zum ersten Zugriff der aufgeschobenen Arbeit, und wenn Benachrichtigungen unnötig sind, erwägen Sie DisableThreadLibraryCalls nach Prüfung der Bedingungen zu statischer CRT und statischem TLS.

Auf der Beendigungsseite trennen Sie ein reines DLL-Entladen von einem Prozessende. Besitzt die DLL einen Worker, folgen Sie dem Protokoll aus Signal, Konsistenzprüfung und Beendigung und verlagern Sie den Thread-Besitz nach Möglichkeit auf die EXE-Seite. Bringen Sie DllMain am Prozessende nahe an leer und legen Sie das Speichern von Daten in den eigenen Beendigungscode der App.

Die Reihenfolge zum Review von DllMain und dem, was sie aufruftDen Bereich einschließlich nicht nur DllMain, sondern auch statischer Initialisierung und indirekter Aufrufe prüfen, Initialisierung auf statisch oder nach dem Laden verschieben, Stoppen und Aufräumen je nach Beendigungsgrund entwerfen, dann mit Application Verifier und Dumps bestätigenDllMain, statische Initialisierung und AufrufzieleInitialisierung auf statisch oder nach dem Laden verschiebenAuch den ersten Zugriff der aufgeschobenen Arbeit prüfenAufräumen je Beendigungsgrund entwerfenMit Verifier, Warnungen und Dumps bestätigen

Abbildung 13: Verfolgen Sie nicht nur, was die Initialisierung tut, sondern auch wann sie läuft und ihre Lebensdauer beim Beenden, und Sie können die Verbote als eine Entwurfspolitik behandeln.

Die Frage an einem Grenzfall lautet: „Ist das Arbeit, die ich ausführen darf, während ich die Ladersperre halte?“ Fragwürdige Initialisierung nicht in DllMain zu stopfen, sondern den Ausführungszeitpunkt zu ändern, ist der Ausgangspunkt eines sicheren DLL-Entwurfs.

Weiterführende Artikel

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.

Quellen

  1. Microsoft Learn, Dynamic-Link Library Best Practices. Dazu, dass DllMain aufgerufen wird, während die Ladersperre gehalten wird, sodass die Funktionen, die aufgerufen werden 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 kompilierzeitlicher 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 ↩7

  2. Microsoft Learn, DllMain entry point. Dazu, im Einstiegspunkt nur einfache Initialisierung und Beendigung auszuführen; warum LoadLibrary / FreeLibrary nicht aufgerufen werden dürfen (zirkuläre Ladereihenfolge und Nutzung einer DLL vor der Initialisierung oder nach der Beendigung); dazu, dass Kernel32.dll garantiert geladen ist, sodass sie in dem Bereich aufgerufen werden kann, der keine anderen DLLs lädt; dazu, dass keine erschöpfende Liste sicherer Funktionen existiert; 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

  3. 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 ↩3 ↩4

  4. 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 ↩3

  5. Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. Zur Struktur, die deadlockt, wenn DllMain auf das Ende eines Threads wartet (die DLL_THREAD_DETACH-Benachrichtigung des Thread-Endes braucht die Ladersperre); zum Protokoll zum Stoppen von 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, keine Garantie der Adressraumkonsistenz besteht und 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 ↩5 ↩6 ↩7 ↩8

  6. Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. Zum Definieren einer Sperrhierarchie und immer in derselben Reihenfolge erwerben; dazu, dass der Lader die Ladersperre erwirbt, bevor er DllMain aufruft, sodass die Ladersperre an der Spitze der Sperrhierarchie sitzen sollte; zum Einhalten 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

Aktuelle Artikel mit denselben Schlagwörtern führen zu verwandten Themen weiter.

Diese Seiten ordnen den Artikel in einen größeren Leistungs- und Entscheidungskontext ein.

Dieser Artikel ist direkt mit den folgenden Leistungen verbunden.

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, sondern die offizielle Entwurfspolitik, und Microsoft selbst sagt, dass die ideale DllMain ein nahezu leerer Stub ist. Sicher sind Funktionen von Kernel32.dll, das zum Zeitpunkt von DllMain garantiert geladen ist, soweit sie keine anderen DLLs laden. Sie können zum Beispiel Critical Sections und Mutexes anlegen und TLS nutzen. Umgekehrt sind LoadLibrary/FreeLibrary, Synchronisation mit anderen Threads und Aufrufe von User32, Shell, COM und Ähnlichem verboten, weil sie Deadlocks und Zugriffsverletzungen verursachen. Bei Initialisierung, der Sie unsicher sind, ist die richtige Antwort, sie nicht in DllMain zu tun, sondern bis zur ersten Nutzung aufzuschieben.
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 globaler und statischer Objekte über den Einstiegspunkt, den die CRT bereitstellt, faktisch als Teil von DllMain. 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 dasselbe in DllMain. Bei globalen Objekten mit komplexer Initialisierung verschieben Sie den Ausführungszeitpunkt aus DllMain heraus: behalten Sie einen Zeiger und erzeugen Sie das Objekt beim ersten Zugriff, oder nutzen Sie eine funktionslokale static.
Soll ich DisableThreadLibraryCalls aufrufen?
Unter Bedingungen ja. Wenn die DLL keine DLL_THREAD_ATTACH/DETACH-Benachrichtigungen braucht, stoppt der Aufruf von DisableThreadLibraryCalls in DLL_PROCESS_ATTACH die Benachrichtigung bei jeder Thread-Anlage und jedem Thread-Ende und senkt den Aufwand in einem Prozess, der häufig Threads anlegt. Es gibt jedoch zwei Ausnahmen. Rufen Sie es nicht in 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 in einer typischen DLL mit dynamisch gebundener CRT, 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, die von ihr aufgerufenen Funktionen oder die dynamischen 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 versucht, MSIL direkt auszuführen, kann indirekte Ausführung über ein anderes Modul aber 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 unterscheidet sich zwischen „Prozessende“ und „Entladen über FreeLibrary“. Bei DLL_PROCESS_DETACH am Prozessende sind die anderen Threads bereits zwangsweise 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 lautet, 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 offiziell beschriebene Protokoll befolgen: signalisieren, bis zu einem konsistenten Zustand warten und die letzte Arbeit außerhalb von DllMain erledigen.

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.

Zurück zum Blog