DllMain e il loader lock — Il vero motivo per cui vi dicono di «non fare niente nell'inizializzazione della DLL»

· · Windows, DLL, Sviluppo Windows, C++, Risoluzione dei problemi, Multithreading, Win32 API

«L’app si blocca all’avvio, ma solo in un ambiente particolare.» «Quando carichiamo la nostra DLL, LoadLibrary a volte non ritorna mai.» «Va in deadlock solo al momento dell’avvio di un servizio.» — Seguite indagini come queste abbastanza lontano e, più spesso che no, arrivate allo stesso posto. Il codice di inizializzazione della DLL — cioè, DllMain.

La documentazione di Microsoft avverte su DllMain con un tono insolitamente forte. Non chiamate LoadLibrary. Non sincronizzatevi con altri thread. Non chiamate funzioni User, Shell o COM. La DllMain ideale è uno stub vuoto — perché il linguaggio è così forte? La ragione si concentra in un singolo meccanismo interno, il loader lock. Rivolto agli sviluppatori che scrivono DLL, plug-in e wrapper C++/CLI su Windows, questo articolo spiega, a partire dalle fonti primarie, come funziona il loader lock, la struttura che fa tenere un deadlock, e il progetto sicuro e la procedura di indagine.

1. Prima di tutto, la conclusione

  • DllMain viene chiamata tenendo il loader lock, un lock condiviso di cui ce n’è esattamente uno per processo. Quindi chiamare, da DllMain, un lavoro che tenta di prendere il loader lock (direttamente o indirettamente) crea la possibilità di un deadlock, o di un crash per aver toccato una DLL che non è ancora stata inizializzata.1
  • Chiamare LoadLibrary / FreeLibrary è vietato. Crea una dipendenza circolare sull’ordine di caricamento e può far girare codice di inizializzazione contro una DLL la cui propria inizializzazione non è ancora girata.2
  • Sincronizzarsi con altri thread è altrettanto vietato. Le notifiche delle DLL sono serializzate, quindi attendere dentro DllMain che un thread parta o esca lascia quel thread stesso fermo in attesa del loader lock, e andate in deadlock.23
  • Ciò che potete chiamare in sicurezza è, in pratica, solo un sottoinsieme di Kernel32.dll. E la documentazione ufficiale afferma chiaramente che «un elenco completo di funzioni sicure non esiste». Le funzioni User, Shell e COM caricano altri componenti e provocano access violation.2
  • In una DLL collegata con la CRT, le stesse restrizioni si applicano a costruttori e distruttori dei globali. Girano come parte di fatto di DllMain.2
  • Il progetto corretto è «rinviare». Fate l’inizializzazione che potete a compile time (in modo statico); rinviate ciò che non potete fino al primo uso. È la best practice ufficiale.1
  • Le DLL miste C++/CLI sono particolarmente pericolose. Per evitare di eseguire MSIL sotto il loader lock, DllMain e il suo albero di chiamate devono essere compilati nativi.4

2. Quando e come viene chiamata DllMain

DllMain è il punto di ingresso che il loader del sistema operativo chiama quando una DLL entra o esce da un processo o da un thread. Ci sono quattro notifiche.

Notifica Momento
DLL_PROCESS_ATTACH Quando la DLL viene caricata nel processo
DLL_THREAD_ATTACH Quando un nuovo thread viene avviato nel processo
DLL_THREAD_DETACH Quando un thread esce normalmente
DLL_PROCESS_DETACH Quando la DLL viene scaricata, o quando il processo esce

Due fatti sono facili da perdere. Primo, ogni volta che viene creato un singolo thread, la DllMain di ogni DLL già caricata viene chiamata con DLL_THREAD_ATTACH. In altre parole DllMain non è «qualcosa che gira una volta quando la mia DLL viene caricata»; è codice che continua a essere chiamato per l’attività di thread del processo. Se non vi serve, potete fermarlo chiamando DisableThreadLibraryCalls dentro DLL_PROCESS_ATTACH (non chiamatela da una DLL collegata con la CRT statica).5

Secondo, in una DLL collegata con la CRT (il runtime C/C++), i costruttori e i distruttori di oggetti C++ globali e statici girano, tramite il punto di ingresso della CRT, come parte di DllMain.2 Anche se pensate «la nostra DllMain è vuota, quindi siamo al sicuro», un oggetto globale con un’inizializzazione elaborata è lo stesso che far girare quel lavoro in DllMain.

I quattro momenti in cui DllMain viene chiamataDLL_PROCESS_ATTACH gira al caricamento della DLL; DLL_THREAD_ATTACH e DETACH girano su ogni DLL già caricata a ogni avvio e uscita di thread nel processo; DLL_PROCESS_DETACH gira allo scaricamento o all'uscita del processo; e i costruttori degli oggetti statici girano anche qui tramite la CRTCaricamento DLLDLL_PROCESS_ATTACHDLL_THREAD_ATTACH (a ogni avvio di thread)DLL_THREAD_DETACH (a ogni uscita di thread)DLL_PROCESS_DETACH (allo scaricamento o all'uscita)Anche la costruzione degli oggetti statici gira qui

Figura 1: DllMain viene chiamata non solo al caricamento ma a ogni avvio e uscita di thread, e anche l’inizializzazione degli oggetti statici gira come parte di quello.

3. Il loader lock — Un lock che serializza ogni notifica

Perché le restrizioni su DllMain da sola sono così severe? La risposta è nella struttura del loader.

Per tenere coerente una serie di operazioni — caricamento, scaricamento e le varie notifiche delle DLL — il loader del sistema operativo serializza il lavoro con un singolo loader lock per processo. E il punto importante è che DllMain viene chiamata mentre questo loader lock è tenuto.1 Per tutto il tempo in cui siete dentro DllMain, ogni altro caricamento di DLL in quel processo, e ogni notifica di avvio di thread, aspetta che questo lock venga rilasciato.

Da quella struttura, le ragioni dei divieti seguono una dopo l’altra.

  • Non dovete chiamare LoadLibrary perché crea reentrancy del loader lock, o una dipendenza circolare sull’ordine di caricamento. Può anche risultare nella chiamata di una funzione su una DLL la cui inizializzazione non è ancora finita.2
  • Sincronizzarsi con altri thread è pericoloso perché il thread che state aspettando ha momenti in cui ha bisogno del loader lock (notifiche all’avvio e all’uscita, chiamate alle API della famiglia GetModuleHandle, e così via). Voi tenete il loader lock e aspettate l’altra parte; l’altra parte aspetta il loader lock — la classica inversione dell’ordine dei lock.6
  • Le funzioni User, Shell e COM sono pericolose perché caricano internamente altri componenti di sistema. Toccate un componente prima che sia inizializzato, o dopo che è stato smontato, e ottenete un access violation.2
Perché attendere un thread dentro DllMain va in deadlockDllMain, tenendo il loader lock, aspetta che un thread worker esca, ma il worker che sta tentando di uscire aspetta che il loader lock venga rilasciato per poter ricevere DLL_THREAD_DETACH, quindi si aspettano l'un l'altro e vanno in deadlockWorkerDllMainLoader(tiene il lock)WorkerDllMainLoader(tiene il lock)La notifica di uscita ha bisogno del lockDllMain tiene il lock, W aspettaDLL_PROCESS_DETACHRichiedi l'uscita e attendiFinisci il lavoro, poi esci

Figura 2: «DllMain aspetta che un thread esca» è un deadlock strutturale, perché l’uscita del thread stessa ha bisogno del loader lock.

Il punto è che questa non è il tipo di cosa che «succede se siete sfortunati»; è strutturalmente garantito che tenga. La documentazione vi dice di trattare il loader lock come il vertice della gerarchia di lock che l’app definisce (quello preso per primo). Dentro DllMain tenete già quel lock di vertice, quindi qualsiasi atto di andare avanti da lì ad aspettare qualcos’altro è pericoloso — è un modo utile di ricordarlo.6

Inversione dell'ordine dei lock tra il loader lock e un lock privatoDllMain, tenendo il loader lock, va a prendere un lock privato, mentre un worker, tenendo quel lock privato, va a prendere il loader lock per GetModuleHandle o simile, quindi l'ordine di acquisizione si inverte e vanno in deadlockDllMain: tiene il loader lockVa a prendere il lock privato GWorker: tiene il lock privato GVa a prendere il loader lockDeadlock da ordine di acquisizione invertitoGetModuleHandle e simili lo richiedono internamente

Figura 3: Anche un’API innocua come GetModuleHandle richiede internamente il loader lock, quindi un’inversione di ordine con un lock privato può tenere.

Inoltre, chiamare CreateThread da dentro DllMain stessa non è raccomandato. Il thread creato ha bisogno del loader lock per elaborare la notifica DLL_THREAD_ATTACH, quindi non può iniziare a girare finché la DllMain che sta eseguendo attualmente non ritorna e rilascia il lock. Quindi attendere dentro DllMain che quel thread parta o finisca è un deadlock immediato. C’è anche un problema di lifetime — se, dopo che DllMain ritorna, la DLL viene scaricata mentre è ancora rimasto un thread che non ha ancora iniziato a girare, l’indirizzo di avvio del thread punta ancora a codice già liberato e andate in crash.3

4. Due mine che gli sviluppatori C++ calpestano facilmente

Mina 1: Inizializzazione dinamica degli oggetti globali. Come ha detto il capitolo 2, i costruttori degli oggetti statici girano sotto le restrizioni di DllMain. Leggere un file di configurazione, alzare un impianto di logging, inizializzare COM, avviare un thread — nel momento in cui mettete in una DLL un globale il cui costruttore fa quel tipo di lavoro, state eseguendo «cose che non dovete fare in DllMain». L’inizializzazione costante fissata a compile time (qualsiasi cosa possiate rendere constexpr) è sicura; l’inizializzazione che coinvolge una chiamata di funzione va rinviata.

Il percorso per cui inizializzare un oggetto globale diventa una minaIl loader lock viene preso al caricamento della DLL, e i costruttori degli oggetti globali girano tramite il punto di ingresso della CRT, quindi LoadLibrary, sincronizzazione di thread e inizializzazione COM dentro quei costruttori sono esecuzioni dei divieti di DllMainCaricamento DLL (loader lock acquisito)Punto di ingresso della CRTCostruttore di un oggetto globaleLavoro equivalente a LoadLibraryAvviare un thread e aspettarne la fineUsare COM o User32Tutte queste cadono sotto i divieti di DllMain

Figura 4: Anche «DllMain è vuota, quindi siamo al sicuro» rianima lo stesso pericolo nel momento in cui avete un globale con un’inizializzazione elaborata.

Mina 2: C++/CLI (assembly misti). In una configurazione che wrappa una DLL nativa con C++/CLI (la forma trattata nell’articolo sul wrapper), c’è il pericolo di eseguire MSIL (codice managed) sotto il loader lock. Eseguire MSIL può innescare l’inizializzazione del CLR o il caricamento di un altro assembly. Il compilatore emette l’avviso C4747 sul codice in cui DllMain esegue MSIL direttamente, ma non può rilevare l’esecuzione indiretta attraverso una funzione in un altro modulo. Compilate DllMain e le funzioni chiamate da essa come native con #pragma unmanaged, o usate una configurazione che non ha affatto una DllMain.4

Se l'esecuzione di MSIL sotto il loader lock può essere rilevataIl codice in cui DllMain esegue MSIL direttamente può essere rilevato dal compilatore con l'avviso C4747, ma l'esecuzione indiretta attraverso una funzione in un altro modulo no, quindi dovete prevenirla rivedendo l'albero di chiamate e insistendo su una compilazione nativaChiamate da DllMainEsegui MSIL direttamenteEsegui tramite un altro moduloRilevabile con l'avviso C4747Il compilatore non può rilevarloPrevieni con review e #pragma unmanaged

Figura 5: C4747 vi protegge solo contro l’esecuzione diretta. I percorsi indiretti si possono catturare solo con la review.

5. Il progetto corretto — Fate di «rinviare» la politica predefinita

La raccomandazione ufficiale di best practice è chiara.1

  1. Finite l’inizializzazione che potete a compile time (in modo statico). Prima chiedetevi se un’inizializzazione dinamica può essere sostituita con una statica.
  2. Rinviate il resto fino al primo uso. Finché il primo uso avviene da un’API ordinaria che viene chiamata dopo che la DLL ha finito di caricarsi, l’inizializzazione gira fuori dal loader lock e potete usare in sicurezza quasi tutta l’API Windows. Per l’esclusione al primo accesso potete usare INIT_ONCE (inizializzazione una tantum) o i magic statics C++ (statici locali di funzione). Il rinvio non è una panacea, però — se quel primo accesso stesso è fatto da DllMain o da un inizializzatore statico, l’inizializzatore gira ancora sotto il loader lock e siete di nuovo sotto le stesse restrizioni.
  3. Fate un’eccezione solo per i fallimenti che dovete rilevare presto. Potete avere un requisito per cui un file di configurazione rotto deve far fallire il caricamento stesso. Anche allora, tenetelo al minimo di «prova e fallisci immediatamente».
  4. Considerate DisableThreadLibraryCalls in DLL_PROCESS_ATTACH. Se la DLL non usa le notifiche di thread, potete togliere il costo della notifica stesso (tranne quando usate la CRT statica o TLS statico).5
  5. Ispezionate con Application Verifier. Molte delle chiamate pericolose dentro DllMain sono quelle che Application Verifier rileverà a runtime.1
Guida di progetto per l'inizializzazione di una DLLPrima considerate se l'inizializzazione può essere un'inizializzazione statica a compile time; se no, il default è rinviare al primo uso, e lasciare in DllMain solo il minimo che deve essere rilevato presto come fallimento di caricamentononoSi può decidere a compile time?Falla diventare inizializzazione staticaIl fallimento deve essere rilevato al caricamento?Rinvia al primo uso (il default)Fai solo il minimo in DllMainEscludi con INIT_ONCE o uno statico locale di funzione

Figura 6: L’ordine di decisione è «può essere statica → può essere rinviata», e ciò che lasciate in DllMain è solo il minimo che deve essere rilevato presto.

Se applicare DisableThreadLibraryCalls si può decidere in modo meccanico con il ramo seguente.

Se chiamare DisableThreadLibraryCallsNon chiamatela da una DLL collegata con la CRT statica; se è in effetto TLS statico la chiamata stessa fallisce quindi non la chiamate; se nessuno dei due si applica e la DLL non usa le notifiche di thread, chiamatela in DLL_PROCESS_ATTACH, controllando il valore di ritorno, per tagliare il costo della notificanononoCollegata con la CRT statica?Non va chiamataUsate TLS statico?La chiamata fallisce comunque (FALSE)Servono le notifiche di thread?Chiamala in ATTACH (controlla il valore di ritorno)Non chiamarla; gestisci le notifiche

Figura 7: Le tre condizioni di CRT statica, TLS statico, e se servono le notifiche decidono in modo univoco se dovete chiamarla.

Per fermare i thread allo scaricamento, la documentazione ufficiale dà un protocollo concreto. Piuttosto che «aspettare» che i thread worker escano in DLL_PROCESS_DETACH (su uno scaricamento via FreeLibrary), la forma è (1) segnalare l’uscita con un evento, (2) il lato thread piega il suo lavoro fino a uno stato coerente, segnala indietro, ed entra in un’attesa infinita, (3) il lato DllMain conferma lo stato coerente e poi piega il thread con TerminateThread.3 Sembra rude, ma è documentato come la risposta realistica dentro il vincolo «non dovete aspettare l’uscita naturale di un thread dentro DllMain».

Protocollo per fermare un thread allo scaricamentoDllMain segnala al thread worker di uscire con un evento; il worker piega il suo lavoro fino a uno stato coerente, segnala indietro, ed entra in un'attesa infinita; DllMain conferma lo stato coerente e poi termina il threadThread workerDllMain (gestione DETACH)Thread workerDllMain (gestione DETACH)Nessuna attesa dell'uscita naturale, quindi nessun deadlockSegnala l'uscita con un eventoPiega il lavoro fino a uno stato coerenteSegnala coerenza completa e aspetta per sempreTermina con TerminateThread

Figura 8: Invece di «aspettare un’uscita naturale», «aspettare un segnale di coerenza e poi tagliarlo» evita una collisione con il loader lock.

Come questione di primi principi, il progetto più sicuro è evitare di possedere thread in una DLL che può essere scaricata, e tenere la proprietà dei thread sul lato EXE.

DLL_PROCESS_DETACH all’uscita del processo è l’opposto: non fare niente e ritornare è l’ideale. A questo punto ogni altro thread è già stato terminato forzatamente, e non potete fare affidamento nemmeno sullo stato delle DLL dipendenti o del runtime. Un lavoro elaborato qui provoca solo deadlock e crash. I dati che devono essere persistiti vanno scritti nel percorso di spegnimento proprio dell’app; non dipendete da questa notifica.3

6. Come indagare quando ci finite dentro

Gli hang da loader lock hanno un’impronta riconoscibile.

Guardate gli stack in un dump di hang. Prendete un dump del momento congelato e ispezionate lo stack di ogni thread. Se trovate una coppia di un thread in attesa su un lock dentro le funzioni del loader di ntdll.dll (la famiglia i cui nomi iniziano con Ldr) e un thread in attesa di qualcos’altro dentro DllMain o un inizializzatore statico (dynamic initializer), siete quasi certi. Un thread fermo in mezzo a una chiamata LoadLibrary è un altro personaggio tipico.

L'impronta di un hang da loader lockIn un dump di hang, se trovate sia un thread in attesa su un lock dentro le funzioni del loader di ntdll sia un thread in attesa di qualcos'altro dentro DllMain o un inizializzatore statico, potete trattarlo come un deadlock da loader lock con quasi certezzanoDump di hangThread in attesa su un lock in funzioni della famiglia LdrThread in attesa dentro DllMain o un inizializzatore staticoEntrambi presenti?Quasi certamente un deadlock da loader lockIndaga come un hang di un altro tipo

Figura 9: Gli hang da loader lock hanno l’impronta riconoscibile «in attesa in Ldr + in attesa dentro DllMain».

Sospettate il carattere «dipendente dal timing». Un deadlock da loader lock tiene solo nel momento in cui un caricamento di DLL coincide con un avvio o un’uscita di thread. Condizioni di riproduzione come «occasionalmente all’avvio», «solo su una macchina particolare» e «solo quando gira come servizio» sono segni di questo tipo di problema.

Eseguite un’ispezione preventiva. Abilitate Application Verifier ed eseguite i vostri test, e potete rilevare le chiamate pericolose dentro DllMain a runtime.1 Per C++/CLI, non ignorate l’avviso C4747; nelle review delle funzioni raggiungibili da DllMain, aggiungete l’angolo delle «funzioni che chiamano LoadLibrary indirettamente» (inizializzazione COM, alcune funzionalità della CRT, import delay-loaded, e così via) alla checklist di review, e catturerete gli incidenti prima di spedirli. La prima chiamata di un import delay-loaded che diventa internamente LoadLibrary è un punto facile da perdere.

  • DllMain viene chiamata tenendo il loader lock (uno per processo, il lock che serializza ogni notifica delle DLL). Ogni restrizione segue da quello.
  • Il nucleo dei divieti è «non chiamate LoadLibrary / FreeLibrary», «non sincronizzatevi con altri thread», e «non chiamate funzioni che dipendono da una DLL diversa da Kernel32». I costruttori e i distruttori di oggetti statici che girano tramite la CRT cadono sotto le stesse restrizioni.
  • La politica di progetto di base è il rinvio. Rendete statica l’inizializzazione che potete rendere statica; rinviate il resto al primo uso. Usate DisableThreadLibraryCalls e Application Verifier.
  • Fermare i thread allo scaricamento segue il protocollo ufficiale (segnala → conferma la coerenza → termina). DLL_PROCESS_DETACH all’uscita del processo è idealmente vuoto.
  • In C++/CLI, eseguire MSIL sotto il loader lock è una mina a sé. Insistete su una compilazione nativa dell’albero di chiamate di DllMain.

Le restrizioni di DllMain sembrano, all’inizio, un elenco irragionevole di divieti. Ma una volta che tenete il singolo punto che «viene chiamata tenendo il loader lock, il lock di vertice», ogni divieto è una riformulazione dello stesso principio. Ricordatelo come un principio e, quando incontrate un caso limite che non è nella documentazione, dovreste comunque essere in grado di fare la domanda giusta: «È un lavoro che mi è consentito fare tenendo il lock?»

Articoli correlati

Aree di consulenza correlate

KomuraSoft LLC si occupa di indagine sulle cause di hang e deadlock all’avvio o al caricamento di una DLL (analisi di dump), di revisioni di progetto intorno a DllMain e all’inizializzazione statica, e di rimedio di wrapper C++/CLI e DLL plug-in verso un progetto di inizializzazione sicuro. Potete consultarci anche allo stadio difficile da riprodurre di «si blocca all’avvio solo in un ambiente particolare».

Riferimenti

  1. Microsoft Learn, Dynamic-Link Library Best Practices. Sul fatto che DllMain viene chiamata mentre il loader lock è tenuto, così che le funzioni che potete chiamare sono severamente ristrette; sul fatto che la DllMain ideale è uno stub vuoto e l’inizializzazione va rinviata il più possibile; sulla raccomandazione dell’inizializzazione statica a compile time; sul fatto di fare solo il minimo per i fallimenti che devono essere rilevati presto; e sul fatto di rilevare gli errori tipici di DllMain con Application Verifier.  2 3 4 5 6

  2. Microsoft Learn, DllMain entry point. Sul fatto di eseguire solo inizializzazione e terminazione semplici nel punto di ingresso; sul perché non dovete chiamare LoadLibrary / FreeLibrary (ordine di caricamento circolare e uso di una DLL prima dell’inizializzazione o dopo la terminazione); sul fatto che Kernel32.dll è garantita già caricata, così da poterla chiamare nel raggio che non carica altre DLL; sul fatto che non esiste un elenco esaustivo di funzioni sicure; sul fatto che le funzioni User, Shell e COM provocano access violation; sul fatto che le notifiche delle DLL sono serializzate, così che la comunicazione con altri thread o processi provoca deadlock; e sul fatto che le stesse restrizioni si applicano a costruttori e distruttori di oggetti statici quando è collegata la CRT.  2 3 4 5 6 7

  3. Microsoft Learn, Dynamic-Link Library Best Practices - Best Practices for Synchronization. Sulla struttura che va in deadlock se aspettate che un thread esca dentro DllMain (la notifica DLL_THREAD_DETACH dell’uscita del thread ha bisogno del loader lock); sul protocollo per fermare un thread allo scaricamento (segnala con un evento, conferma uno stato coerente, poi termina); sul fatto che DLL_PROCESS_DETACH all’uscita del processo ha gli altri thread già terminati forzatamente e nessuna garanzia di coerenza dello spazio degli indirizzi, così che il gestore ideale è vuoto; e sul fatto che creare un thread in DllMain lascia le notifiche in coda con l’inizializzazione incompleta e provoca problemi.  2 3 4

  4. Microsoft Learn, Initialization of Mixed Assemblies. Sul fatto di non eseguire MSIL sotto il loader lock; sul fatto di non compilare DllMain e il suo albero di chiamate in MSIL e di affrontarlo via #pragma unmanaged; sul fatto che l’avviso C4747 viene emesso quando DllMain tenta di eseguire MSIL direttamente, ma l’esecuzione indiretta attraverso un altro modulo non è rilevabile; e sul fatto che gli inizializzatori dinamici di oggetti statici possono provocare lo stesso problema.  2

  5. Microsoft Learn, DisableThreadLibraryCalls function (libloaderapi.h). Sul fatto di disabilitare le notifiche DLL_THREAD_ATTACH / DLL_THREAD_DETACH per ridurre l’overhead alla creazione e distruzione di thread; sul fatto di non chiamarla da una DLL collegata con la CRT statica; e sul fatto che l’ottimizzazione non viene eseguita quando è in effetto TLS statico (thread_local o __declspec(thread)).  2

  6. Microsoft Learn, Dynamic-Link Library Best Practices - Deadlocks Caused by Lock Order Inversion. Sul fatto di definire una gerarchia di lock e acquisire sempre nello stesso ordine; sul fatto che il loader acquisisce il loader lock prima di chiamare DllMain, così che il loader lock dovrebbe stare al vertice della gerarchia di lock; sull’osservare l’ordine di acquisizione tra API che prendono il loader lock indirettamente, come GetModuleFileName, e lock privati; e su un esempio concreto di deadlock da inversione dell’ordine dei lock.  2

Articoli recenti con gli stessi tag per approfondire argomenti vicini.

Queste pagine collocano l’argomento in un contesto più ampio di servizi e decisioni.

L’articolo è direttamente collegato ai servizi seguenti.

Domande frequenti

Domande che ricorrono nelle consulenze sull’argomento dell’articolo.

Si può davvero non fare proprio niente in DllMain?
«Non fare niente» non è un'iperbole; è la posizione ufficiale di progetto, e Microsoft stessa dice che la DllMain ideale è uno stub quasi vuoto. Ciò che è sicuro è un sottoinsieme delle funzioni di Kernel32.dll — Kernel32 è garantito già caricato al momento in cui DllMain gira — nel raggio che non carica altre DLL. Creare una critical section o un mutex, e usare TLS, sono esempi di ciò che potete fare. Viceversa, LoadLibrary/FreeLibrary, sincronizzarsi con altri thread, e chiamare funzioni di User32, Shell, COM e simili sono vietati perché provocano deadlock e access violation. L'inizializzazione di cui non siete sicuri non va fatta in DllMain; rinviatele fino alla prima volta in cui viene usata.
Anche i costruttori dei globali C++ (oggetti statici) cadono sotto le restrizioni di DllMain?
Sì. Quando la DLL è collegata con la CRT (il runtime C++), i costruttori e i distruttori di oggetti globali e statici girano, tramite il punto di ingresso che la CRT fornisce, come parte di fatto di DllMain. Questo significa che chiamare LoadLibrary da un costruttore, avviare un altro thread e aspettare che finisca, inizializzare COM, e così via, portano tutti lo stesso pericolo di farli in DllMain. Per un oggetto globale con inizializzazione non banale, tenete un puntatore e costruitelo al primo accesso, o usate uno statico locale di funzione, così che il lavoro giri fuori da DllMain.
Devo chiamare DisableThreadLibraryCalls?
Condizionalmente, sì. Se la DLL non ha bisogno delle notifiche DLL_THREAD_ATTACH/DETACH, chiamare DisableThreadLibraryCalls in DLL_PROCESS_ATTACH ferma le notifiche per ogni creazione e uscita di thread e riduce l'overhead in un processo che crea thread di frequente. Ci sono due eccezioni. Non chiamatela da una DLL collegata con la CRT statica (la CRT statica ha bisogno delle notifiche di thread). E se è in effetto TLS statico via thread_local o __declspec(thread), la chiamata stessa fallisce e restituisce FALSE, quindi fate l'abitudine a controllare il valore di ritorno. Usatela su una DLL tipica che usa la CRT collegata dinamicamente, dopo aver confermato che niente dipende dalle notifiche di thread.
Perché una DLL C++/CLI (mista managed) si blocca all'avvio?
La causa tipica è tentare di eseguire MSIL (codice managed) mentre il loader lock è tenuto. In un assembly misto C++/CLI, se DllMain, le funzioni chiamate da essa, o gli inizializzatori dinamici dei globali sono compilati in MSIL, l'inizializzazione del CLR o il caricamento di un altro assembly può essere richiesto sotto il loader lock, e questo può andare in deadlock. Il compilatore emette l'avviso C4747 quando DllMain stessa tenta di eseguire MSIL direttamente, ma non può rilevare l'esecuzione indiretta attraverso un altro modulo. La mitigazione è compilare DllMain e il suo albero di chiamate come nativo con #pragma unmanaged — o non avere affatto una DllMain.
Posso ripulire le risorse in DLL_PROCESS_DETACH?
La risposta cambia tra «uscita del processo» e «scaricamento via FreeLibrary». Su DLL_PROCESS_DETACH all'uscita del processo, gli altri thread sono già stati terminati, e non c'è garanzia che lo spazio degli indirizzi sia ancora coerente, quindi una pulizia come liberare memoria è in realtà pericolosa; la guida ufficiale è che «il gestore ideale è vuoto». Scrivete i dati che devono essere persistiti nel percorso di spegnimento proprio dell'app, e qui essenzialmente non fate niente e ritornate. Su uno scaricamento via FreeLibrary, il processo continua, quindi serve una pulizia completa — fermare i thread, chiudere gli handle, e così via. Attendere l'uscita di un thread dentro DllMain va in deadlock, però, quindi dovete seguire il protocollo ufficiale: segnalare, aspettare fino a uno stato coerente, e finire il lavoro fuori da DllMain.

Profilo dell’autore

Pagina di presentazione dell’autore dell’articolo.

Go Komura

Rappresentante di KomuraSoft LLC

Specializzato nello sviluppo di software Windows, nella consulenza tecnica e nell’analisi dei malfunzionamenti, soprattutto nei progetti con sistemi esistenti e guasti difficili da riprodurre.

Torna al blog