Bonnes pratiques en matière de bibliothèque de liens dynamiques

**Mise à jour :**

  • 17 mai 2006

API importantes

La création de DLL présente un certain nombre de défis pour les développeurs. Les DLL ne bénéficient d’aucune gestion de versions appliquée par le système. Quand plusieurs versions d’une DLL existent sur un système, la facilité de remplacement couplée à l’absence de schéma de gestion de versions crée des conflits de dépendances et d’API. La complexité dans l’environnement de développement, l’implémentation du chargeur et les dépendances DLL créent une fragilité au niveau de l’ordre de chargement et du comportement des applications. Enfin, de nombreuses applications s’appuient sur des DLL et ont des ensembles complexes de dépendances qui doivent être respectés pour que les applications fonctionnent correctement. Ce document fournit des instructions pour les développeurs de DLL afin de les aider à créer des DLL plus robustes, portables et extensibles.

Si une synchronisation est incorrecte dans DllMain, l’application peut bloquer ou accéder à des données ou à du code dans une DLL non initialisée. L’appel de certaines fonctions à partir de DllMain provoque ce type de problèmes.

what happens when a library is loaded

Bonnes pratiques générales

DllMain est appelée pendant que le verrou du chargeur est maintenu. C’est pourquoi des restrictions significatives sont imposées aux fonctions qui peuvent être appelées dans DllMain. Par conséquent, DllMain est conçue pour effectuer des tâches d’initialisation minimales à l’aide d’une petite partie de l’API Microsoft® Windows®. Vous ne pouvez pas appeler une fonction dans DllMain qui tente directement ou indirectement d’acquérir le verrou du chargeur. Sinon, vous allez introduire la possibilité que votre application bloque ou plante. Une erreur dans une implémentation DllMain peut compromettre l’ensemble du processus et tous ses threads.

La DllMain idéale serait tout simplement un stub vide. Toutefois, compte tenu de la complexité de nombreuses applications, c’est généralement trop restrictif. Une bonne règle de base pour DllMain consiste à reporter l’initialisation le plus possible. Une initialisation tardive augmente la robustesse de l’application, car celle-ci n’est pas effectuée pendant que le verrou du chargeur est maintenu. De plus, elle vous permet d’utiliser sans problème une partie beaucoup plus grande de l’API Windows.

Certaines tâches d’initialisation ne peuvent pas être reportées. Par exemple, une DLL qui dépend d’un fichier de configuration ne doit pas être chargée si le fichier est mal formé ou contient du garbage. Pour ce type d’initialisation, la DLL doit tenter l’action et échouer rapidement au lieu de gaspiller des ressources en effectuant d’autres tâches.

Vous ne devez jamais effectuer les tâches suivantes à partir de DllMain :

  • Appeler LoadLibrary ou LoadLibraryEx (directement ou indirectement). Cela peut entraîner un blocage ou un plantage.
  • Appeler GetStringTypeA, GetStringTypeEx ou GetStringTypeW (directement ou indirectement). Cela peut entraîner un blocage ou un plantage.
  • Effectuer une synchronisation avec les autres threads. Cela peut entraîner un blocage.
  • Acquérir un objet de synchronisation appartenant au code qui attend l’acquisition du verrou du chargeur. Cela peut entraîner un blocage.
  • Initialiser les threads COM en utilisant CoInitializeEx. Sous certaines conditions, cette fonction peut appeler LoadLibraryEx.
  • Appeler les fonctions de Registre.
  • Appeler CreateProcess. La création d’un processus peut charger une autre DLL.
  • Appeler ExitThread. Fermer un thread pendant un détachement de DLL peut entraîner une nouvelle acquisition du verrou du chargeur, ce qui provoque un blocage ou un plantage.
  • Appeler CreateThread. La création d’un thread peut fonctionner si vous n’effectuez pas de synchronisation avec les autres threads, mais c’est risqué.
  • Appeler ShGetFolderPathW. Un appel d’API d’interpréteur de commandes/de dossier connu peut entraîner une synchronisation de threads et donc aboutir à des blocages.
  • Créer un canal nommé ou un autre objet nommé (Windows 2000 uniquement). Dans Windows 2000, les objets nommés sont fournis par la DLL Terminal Services. Si cette DLL n’est pas initialisée, les appels à la DLL peuvent provoquer le plantage du processus.
  • Utiliser la fonction de gestion de la mémoire du CRT (C Runtime) dynamique. Si la DLL CRT n’est pas initialisée, les appels à ces fonctions peuvent provoquer le plantage du processus.
  • Appeler des fonctions dans User32.dll ou Gdi32.dll. Certaines fonctions chargent une autre DLL, qui peut ne pas être initialisée.
  • Utiliser du code managé.

Les tâches suivantes peuvent être effectuées sans problème dans DllMain :

  • Initialiser des structures et des membres de données statiques au moment de la compilation.
  • Créer et initialiser des objets de synchronisation.
  • Allouer de la mémoire et initialiser des structures de données dynamiques (en évitant les fonctions listées ci-dessus.)
  • Configurer le stockage local des threads (TLS).
  • Ouvrir, lire et écrire dans des fichiers.
  • Appeler des fonctions dans Kernel32.dll (à l’exception des fonctions listées ci-dessus).
  • Définir les pointeurs globaux sur NULL, en désactivant l’initialisation des membres dynamiques. Dans Microsoft Windows Vista™, vous pouvez utiliser les fonctions d’initialisation ponctuelles pour vous assurer qu’un bloc de code n’est exécuté qu’une seule fois dans un environnement multithread.

Blocages causés par l’inversion de l’ordre des verrous

Lorsque vous implémentez du code qui utilise plusieurs objets de synchronisation tels que les verrous, il est essentiel de respecter l’ordre des verrous. Quand il est nécessaire d’acquérir plusieurs verrous à la fois, vous devez définir une priorité explicite appelée hiérarchie des verrous ou ordre des verrous. Par exemple, si le verrou A est acquis avant le verrou B quelque part dans le code et que le verrou B est acquis avant le verrou C ailleurs dans le code, l’ordre des verrous est A, B, C et cet ordre doit être suivi tout au long du code. Une inversion de l’ordre des verrous se produit lorsque l’ordre de verrouillage n’est pas suivi (par exemple, si le verrou B est acquis avant le verrou A, l’inversion de l’ordre des verrous peut entraîner des blocages difficiles à déboguer). Pour éviter de tels problèmes, tous les threads doivent acquérir des verrous dans le même ordre.

Il est important de noter que le chargeur appelle DllMain avec le verrou du chargeur déjà acquis, de sorte que le verrou du chargeur doit avoir la priorité la plus élevée dans la hiérarchie de verrous. Notez également que le code doit uniquement acquérir les verrous dont il a besoin pour la synchronisation appropriée ; il n’est pas obligé d’acquérir chaque verrou défini dans la hiérarchie. Par exemple, si une section de code nécessite uniquement les verrous A et C pour une synchronisation appropriée, le code doit acquérir le verrou A avant d’acquérir le verrou C ; il n’est pas nécessaire que le code acquiert également le verrou B. De plus, le code DLL ne peut pas acquérir explicitement le verrou du chargeur. Si le code doit appeler une API telle que GetModuleFileName qui peut acquérir indirectement le verrou du chargeur et que le code doit également acquérir un verrou privé, le code doit appeler GetModuleFileName avant d’acquérir le verrou P, ce qui garantit le respect de l’ordre de chargement.

La figure 2 est un exemple illustrant l’inversion de l’ordre des verrous. Considérez une DLL dont le thread principal contient DllMain. Le chargeur de bibliothèque acquiert le verrou de chargeur L, puis appelle DllMain. Le thread principal crée des objets de synchronisation A, B et G pour sérialiser l’accès à ses structures de données, puis tente d’acquérir le verrou G. Un thread de travail qui a déjà acquis le verrou G appelle alors une fonction telle que GetModuleHandle qui tente d’acquérir le verrou de chargeur L. Par conséquent, le thread de travail est bloqué sur L et le thread principal est bloqué sur G, ce qui entraîne un blocage.

deadlock caused by lock order inversion

Pour empêcher les blocages provoqués par l’inversion de l’ordre des verrous, tous les threads doivent tenter d’acquérir tout le temps des objets de synchronisation dans l’ordre de chargement défini.

Bonnes pratiques pour la synchronisation

Considérez une DLL qui crée des threads de travail dans le cadre de son initialisation. Une fois la DLL nettoyée, il est nécessaire d’effectuer une synchronisation avec tous les threads de travail pour vous assurer que les structures de données sont dans un état cohérent, puis de mettre fin aux threads de travail. Aujourd’hui, il n’existe aucun moyen simple de résoudre entièrement le problème pour synchroniser et arrêter proprement les DLL dans un environnement multithread. Cette section décrit les bonnes pratiques actuelles pour synchroniser les threads pendant l’arrêt des DLL.

Synchronisation de threads dans DllMain pendant la fermeture du processus

  • Avant que DllMain ne soit appelée lors de la fermeture du processus, tous les threads du processus ont été nettoyés de force et il est possible que l’espace d’adressage soit incohérent. La synchronisation n’est pas nécessaire dans ce cas. En d’autres termes, le gestionnaire DLL_PROCESS_DETACH idéal est vide.
  • Windows Vista garantit que les structures de données principales (variables d’environnement, répertoire actif, tas de processus, etc) sont dans un état cohérent. Toutefois, les autres structures de données peuvent être endommagées, de sorte que le nettoyage de la mémoire n’est pas sans danger.
  • L’état persistant qui doit être enregistré doit être vidé dans un stockage permanent.

Synchronisation de threads dans DllMain pour DLL_THREAD_DETACH pendant le déchargement de DLL

  • Lorsque la DLL est déchargée, l’espace d’adressage n’est pas jeté. Par conséquent, la DLL est censée effectuer un arrêt propre. Cela inclut la synchronisation des threads, les handles ouverts, l’état persistant et les ressources allouées.
  • La synchronisation de threads est délicate, car attendre sur les threads à fermer dans DllMain peut entraîner un blocage. Par exemple, la DLL A détient le verrou du chargeur. Elle signale le thread T comme étant à fermer et attend que le thread soit fermé. Le thread T ferme et le chargeur tente d’acquérir le verrou du chargeur pour appeler la DllMain de la DLL A avec DLL_THREAD_DETACH. Cela provoque un interblocage. Pour réduire le risque d’un blocage :
    • LA DLL A obtient un message DLL_THREAD_DETACH dans sa DllMain et définit un événement pour le thread T, qui le signale comme étant à fermer.
    • Le thread T termine sa tâche en cours, se met dans un état cohérent, signale la DLL A et attend indéfiniment. Notez que les routines de vérification de cohérence doivent suivre les mêmes restrictions que DllMain pour éviter tout blocage.
    • LA DLL A met fin à T, sachant qu’il est dans un état cohérent.

Si une DLL est déchargée après que tous ses threads ont été créés, mais avant qu’ils ne commencent à s’exécuter, les threads peuvent planter. Si la DLL a créé des threads dans sa DllMain dans le cadre de son initialisation, certains threads n’ont peut-être pas terminé l’initialisation et leur message DLL_THREAD_ATTACH attend toujours d’être remis à la DLL. Dans ce cas, si la DLL est déchargée, elle commence à mettre fin aux threads. Toutefois, certains threads peuvent être bloqués derrière le verrou du chargeur. Leur message DLL_THREAD_ATTACH est traité une fois que la DLL a été démappée, ce qui provoque le plantage du processus.

Recommandations

Les instructions suivantes sont recommandées :

  • Utilisez Application Verifier pour intercepter les erreurs les plus courantes dans DllMain.
  • Si vous utilisez un verrou privé dans DllMain, définissez une hiérarchie de verrouillage et utilisez-la de manière cohérente. Le verrou du chargeur doit se trouver en bas de cette hiérarchie.
  • Vérifiez qu’aucun appel ne dépend d’une autre DLL qui n’a peut-être pas encore été entièrement chargée.
  • Effectuez des initialisations simples de façon statique au moment de la compilation, plutôt que dans DllMain.
  • Différez les appels dans DllMain qui peuvent attendre.
  • Différez les tâches d’initialisation qui peuvent attendre. Certaines conditions d’erreur doivent être détectées tôt afin que l’application puisse gérer correctement les erreurs. Toutefois, il existe des compromis entre cette détection qui se produit tôt et la perte de robustesse qui peut en résulter. Le mieux est souvent de différer l’initialisation.