Prévention des blocages dans les applications Windows

Plateformes affectées

Clients - Windows 7
Serveurs - Windows Server 2008 R2

Description

Blocages - Perspective de l’utilisateur

Les utilisateurs aiment les applications réactives. Lorsqu’ils cliquent sur un menu, ils veulent que l’application réagisse instantanément, même si elle imprime actuellement leur travail. Lorsqu’ils enregistrent un document long dans leur traitement de texte favori, ils veulent continuer à taper pendant que le disque tourne toujours. Les utilisateurs s’impatientent assez rapidement lorsque l’application ne réagit pas en temps opportun à leur entrée.

Un programmeur peut reconnaître de nombreuses raisons légitimes pour lesquelles une application ne répond pas instantanément aux entrées utilisateur. L’application peut être occupée à recalculer certaines données ou simplement à attendre que ses E/S de disque se terminent. Cependant, d’après la recherche sur les utilisateurs, nous savons que les utilisateurs sont ennuyés et frustrés après seulement quelques secondes de non-réponse. Après 5 secondes, ils tenteront de mettre fin à une application bloquée. En plus des incidents, les blocages d’application sont la source la plus courante de perturbation utilisateur lors de l’utilisation d’applications Win32.

Il existe de nombreuses causes racines différentes pour les blocages d’application, et toutes ne se manifestent pas dans une interface utilisateur qui ne répond pas. Toutefois, une interface utilisateur qui ne répond pas est l’une des expériences de blocage les plus courantes, et ce scénario bénéficie actuellement de la prise en charge la plus grande partie du système d’exploitation pour la détection et la récupération. Windows détecte, collecte automatiquement les informations de débogage et, éventuellement, arrête ou redémarre les applications bloquées. Sinon, l’utilisateur peut être amené à redémarrer l’ordinateur pour récupérer une application bloquée.

Blocages - Perspective du système d’exploitation

Lorsqu’une application (ou plus précisément, un thread) crée une fenêtre sur le bureau, elle conclut un contrat implicite avec le Gestionnaire de fenêtres de bureau (DWM) pour traiter les messages de fenêtre en temps opportun. Le DWM publie des messages (entrée clavier/souris et messages provenant d’autres fenêtres, ainsi que de lui-même) dans la file d’attente de messages spécifique au thread. Le thread récupère et distribue ces messages via sa file d’attente de messages. Si le thread ne traite pas la file d’attente en appelant GetMessage(), les messages ne sont pas traités et la fenêtre se bloque : il ne peut ni redessiner ni accepter l’entrée de l’utilisateur. Le système d’exploitation détecte cet état en attachant un minuteur aux messages en attente dans la file d’attente des messages. Si aucun message n’a été récupéré dans un délai de 5 secondes, le DWM déclare que la fenêtre doit être suspendue. Vous pouvez interroger cet état de fenêtre particulier via l’API IsHungAppWindow().

La détection n’est que la première étape. À ce stade, l’utilisateur ne peut même pas arrêter l’application . Le fait de cliquer sur le bouton X (Fermer) entraînerait un message WM_CLOSE, qui serait bloqué dans la file d’attente des messages comme n’importe quel autre message. Le Gestionnaire de fenêtres de bureau aide en masquant de manière transparente, puis en remplaçant la fenêtre suspendue par une copie « fantôme » affichant une image bitmap de la zone cliente précédente de la fenêtre d’origine (et en ajoutant « Ne répond pas » à la barre de titre). Tant que le thread de la fenêtre d’origine ne récupère pas les messages, le DWM gère les deux fenêtres simultanément, mais permet à l’utilisateur d’interagir uniquement avec la copie fantôme. À l’aide de cette fenêtre fantôme, l’utilisateur peut uniquement déplacer, réduire et, plus important encore, fermer l’application qui ne répond pas, mais pas modifier son état interne.

Toute l’expérience de fantôme ressemble à ceci :

Capture d’écran montrant la boîte de dialogue « Le Bloc-notes ne répond pas ».

Le Gestionnaire de fenêtres du bureau effectue une dernière chose ; il s’intègre à Rapport d'erreurs Windows, ce qui permet à l’utilisateur non seulement de fermer et éventuellement redémarrer l’application, mais également de renvoyer des données de débogage précieuses à Microsoft. Vous pouvez obtenir ces données de blocage pour vos propres applications en vous inscrivant sur le site web Winqual.

Windows 7 a ajouté une nouvelle fonctionnalité à cette expérience. Le système d’exploitation analyse l’application bloquée et, dans certaines circonstances, donne à l’utilisateur la possibilité d’annuler une opération de blocage et de réactiver l’application. L’implémentation actuelle prend en charge l’annulation des appels de socket bloquants ; d’autres opérations seront annulables par l’utilisateur dans les versions ultérieures.

Pour intégrer votre application à l’expérience de récupération de blocage et tirer le meilleur parti des données disponibles, procédez comme suit :

  • Assurez-vous que votre application s’inscrit pour le redémarrage et la récupération, ce qui rend le blocage aussi facile que possible pour l’utilisateur. Une application correctement inscrite peut redémarrer automatiquement avec la plupart de ses données non enregistrées intactes. Cela fonctionne à la fois pour les blocages et les blocages d’application.
  • Obtenez des informations de fréquence ainsi que des données de débogage pour vos applications bloquées et plantées à partir du site web Winqual. Vous pouvez utiliser ces informations même pendant votre version bêta pour améliorer votre code. Pour obtenir une brève vue d’ensemble, consultez « Présentation de Rapport d'erreurs Windows ».
  • Vous pouvez désactiver la fonctionnalité de fantôme dans votre application via un appel à DisableProcessWindowsGhosting (). Toutefois, cela empêche l’utilisateur moyen de fermer et de redémarrer une application bloquée et se termine souvent par un redémarrage.

Blocages - Point de vue des développeurs

Le système d’exploitation définit un blocage d’application en tant que thread d’interface utilisateur qui n’a pas traité les messages pendant au moins 5 secondes. Des bogues évidents provoquent certains blocages, par exemple, un thread qui attend un événement qui n’est jamais signalé, et deux threads tenant chacun un verrou et essayant d’acquérir les autres. Vous pouvez corriger ces bogues sans trop d’efforts. Cependant, de nombreux blocages ne sont pas si clairs. Oui, le thread d’interface utilisateur ne récupère pas les messages, mais il est tout aussi occupé à effectuer d’autres travaux « importants » et finira par revenir au traitement des messages.

Toutefois, l’utilisateur perçoit cela comme un bogue. La conception doit correspondre aux attentes de l’utilisateur. Si la conception de l’application conduit à une application qui ne répond pas, la conception doit changer. Enfin, et c’est important, l’absence de réponse ne peut pas être corrigée comme un bogue de code ; il nécessite un travail initial pendant la phase de conception. Essayer de moderniser la base de code existante d’une application pour rendre l’interface utilisateur plus réactive est souvent trop coûteux. Les instructions de conception suivantes peuvent vous aider.

  • Faire de la réactivité de l’interface utilisateur une exigence de niveau supérieur ; l’utilisateur doit toujours se sentir en contrôle de votre application
  • Assurez-vous que les utilisateurs peuvent annuler les opérations qui prennent plus d’une seconde et/ou que les opérations peuvent se terminer en arrière-plan ; fournir l’interface utilisateur de progression appropriée si nécessaire

Capture d’écran montrant la boîte de dialogue « Copie d’éléments ».

  • Mettre en file d’attente des opérations de longue durée ou de blocage en tant que tâches en arrière-plan (cela nécessite un mécanisme de messagerie bien pensé pour informer le thread d’interface utilisateur lorsque le travail est terminé)
  • Gardez le code des threads d’interface utilisateur simple ; supprimer autant d’appels d’API bloquants que possible
  • Affichez les fenêtres et les boîtes de dialogue uniquement lorsqu’elles sont prêtes et entièrement opérationnelles. Si la boîte de dialogue doit afficher des informations trop gourmandes en ressources pour être calculées, affichez d’abord certaines informations génériques et mettez-les à jour à la volée lorsque d’autres données sont disponibles. La boîte de dialogue propriétés du dossier de Windows Explorer en est un bon exemple. Il doit afficher la taille totale du dossier, des informations qui ne sont pas facilement disponibles à partir du système de fichiers. La boîte de dialogue s’affiche immédiatement et le champ « taille » est mis à jour à partir d’un thread de travail :

Capture d’écran montrant la page « Général » des propriétés Windows avec le texte « Taille », « Taille sur le disque » et « Contient » encerclé.

Malheureusement, il n’existe pas de moyen simple de concevoir et d’écrire une application réactive. Windows ne fournit pas une infrastructure asynchrone simple qui permettrait de planifier facilement les opérations de blocage ou de longue durée. Les sections suivantes présentent certaines des meilleures pratiques en matière de prévention des blocages et mettent en évidence certains des pièges courants.

Bonnes pratiques

Conserver le thread d’interface utilisateur simple

La principale responsabilité du thread d’interface utilisateur est de récupérer et de distribuer des messages. Tout autre type de travail présente le risque de suspendre les fenêtres appartenant à ce thread.

À faire :

  • Déplacer des algorithmes gourmands en ressources ou sans limite qui entraînent des opérations de longue durée vers des threads de travail
  • Identifiez autant d’appels de fonction bloquants que possible et essayez de les déplacer vers des threads de travail ; toute fonction appelant une autre DLL doit être suspecte
  • Faites un effort supplémentaire pour supprimer tous les appels d’E/S de fichiers et d’API réseau de votre thread worker. Ces fonctions peuvent se bloquer pendant de nombreuses secondes, voire minutes. Si vous devez effectuer n’importe quel type d’E/S dans le thread d’interface utilisateur, envisagez d’utiliser des E/S asynchrones
  • N’oubliez pas que votre thread d’interface utilisateur traite également tous les serveurs COM sta hébergés par votre processus ; si vous effectuez un appel bloquant, ces serveurs COM ne répondent pas jusqu’à ce que vous réutilisiez la file d’attente de messages

Ne pas:

  • Patientez sur n’importe quel objet de noyau (comme Event ou Mutex) pendant plus d’un très court laps de temps ; si vous devez attendre, envisagez d’utiliser MsgWaitForMultipleObjects(), qui se débloquera lorsqu’un nouveau message arrive
  • Partagez la file d’attente de messages de fenêtre d’un thread avec un autre thread à l’aide de la fonction AttachThreadInput(). Il est non seulement extrêmement difficile de synchroniser correctement l’accès à la file d’attente, mais il peut également empêcher le système d’exploitation Windows de détecter correctement une fenêtre bloquée
  • Utilisez TerminateThread() sur l’un de vos threads de travail. La fin d’un thread de cette façon ne lui permettra pas de libérer des verrous ou des événements de signal et peut facilement entraîner des objets de synchronisation orphelins
  • Appelez n’importe quel code « inconnu » à partir de votre thread d’interface utilisateur. Cela est particulièrement vrai si votre application a un modèle d’extensibilité ; il n’est pas garanti que le code tiers respecte vos directives de réactivité
  • Effectuez n’importe quel type d’appel de diffusion bloquant ; SendMessage(HWND_BROADCAST) vous met à la merci de toutes les applications mal écrites en cours d’exécution

Implémenter des modèles asynchrones

La suppression ou le blocage des opérations de longue durée du thread d’interface utilisateur nécessite l’implémentation d’une infrastructure asynchrone qui permet de décharger ces opérations sur des threads de travail.

À faire :

  • Utilisez des API de message de fenêtre asynchrone dans votre thread d’interface utilisateur, en particulier en remplaçant SendMessage par l’un de ses homologues non bloquants : PostMessage, SendNotifyMessage ou SendMessageCallback
  • Utilisez des threads d’arrière-plan pour exécuter des tâches de longue durée ou bloquantes. Utiliser la nouvelle API de pool de threads pour implémenter vos threads de travail
  • Assurer la prise en charge de l’annulation pour les tâches en arrière-plan de longue durée. Pour bloquer les opérations d’E/S, utilisez l’annulation d’E/S, mais uniquement en dernier recours ; il n’est pas facile d’annuler la « bonne » opération
  • Implémenter une conception asynchrone pour le code managé à l’aide du modèle IAsyncResult ou à l’aide d’événements

Utiliser les verrous avec sagesse

Votre application ou DLL a besoin de verrous pour synchroniser l’accès à ses structures de données internes. L’utilisation de plusieurs verrous augmente le parallélisme et rend votre application plus réactive. Toutefois, l’utilisation de plusieurs verrous augmente également le risque d’acquérir ces verrous dans différents ordres et de provoquer l’interblocage de vos threads. Si deux threads contiennent chacun un verrou, puis essaient d’acquérir le verrou de l’autre thread, leurs opérations forment une attente circulaire qui bloque toute progression vers l’avant pour ces threads. Vous pouvez éviter ce blocage uniquement en vous assurant que tous les threads de l’application acquièrent toujours tous les verrous dans le même ordre. Toutefois, il n’est pas toujours facile d’acquérir des verrous dans le bon ordre. Les composants logiciels peuvent être composés, mais les acquisitions de verrous ne le peuvent pas. Si votre code appelle un autre composant, les verrous de ce composant font désormais partie de votre ordre de verrouillage implicite, même si vous n’avez aucune visibilité sur ces verrous.

Les choses sont encore plus difficiles, car les opérations de verrouillage incluent beaucoup plus que les fonctions habituelles pour les sections critiques, les mutex et d’autres verrous traditionnels. Tout appel bloquant qui dépasse les limites du thread a des propriétés de synchronisation qui peuvent entraîner un blocage. Le thread appelant effectue une opération avec la sémantique « acquire » et ne peut pas débloquer tant que le thread cible n’a pas « libéré » cet appel. Un certain nombre de fonctions User32 (par exemple SendMessage), ainsi que de nombreux appels COM bloquants appartiennent à cette catégorie.

Pire encore, le système d’exploitation a son propre verrou interne spécifique au processus qui est parfois conservé pendant l’exécution de votre code. Ce verrou est acquis lorsque les DLL sont chargées dans le processus et est donc appelé « verrou de chargeur ». La fonction DllMain s’exécute toujours sous le verrou du chargeur ; si vous acquérez des verrous dans DllMain (et vous ne devez pas le faire), vous devez faire en sorte que le verrou du chargeur fasse partie de votre ordre de verrouillage. L’appel de certaines API Win32 peut également acquérir le verrou du chargeur en votre nom , des fonctions telles que LoadLibraryEx, GetModuleHandle, et en particulier CoCreateInstance.

Pour lier tout cela, examinez l’exemple de code ci-dessous. Cette fonction acquiert plusieurs objets de synchronisation et définit implicitement un ordre de verrouillage, ce qui n’est pas nécessairement évident lors d’une inspection rapide. Lors de l’entrée de fonction, le code acquiert une section critique et ne la libère pas avant la sortie de la fonction, ce qui en fait le nœud supérieur de notre hiérarchie de verrous. Le code appelle ensuite la fonction Win32 LoadIcon(), qui sous les couvertures peut appeler le chargeur de système d’exploitation pour charger ce binaire. Cette opération permet d’acquérir le verrou du chargeur, qui fait désormais également partie de cette hiérarchie de verrous (assurez-vous que la fonction DllMain n’acquiert pas le verrou g_cs). Ensuite, le code appelle SendMessage(), une opération inter-thread bloquante, qui ne retourne que si le thread d’interface utilisateur répond. Là encore, assurez-vous que le thread d’interface utilisateur n’acquiert jamais g_cs.

bool foo::bar (char* buffer)  
{  
      EnterCriticalSection(&g_cs);  
      // Get 'new data' icon  
      this.m_Icon = LoadIcon(hInst, MAKEINTRESOURCE(5));  
      // Let UI thread know to update icon SendMessage(hWnd,WM_COMMAND,IDM_ICON,NULL);  
      this.m_Params = GetParams(buffer);  
      LeaveCriticalSection(&g_cs);
      return true;  
}  

En examinant ce code, il semble clair que nous avons implicitement créé g_cs le verrou de niveau supérieur dans notre hiérarchie de verrous, même si nous voulions uniquement synchroniser l’accès aux variables membres de la classe.

À faire :

  • Concevoir une hiérarchie de verrous et y obéir. Ajoutez tous les verrous nécessaires. Il existe beaucoup plus de primitives de synchronisation que de simples Mutex et CriticalSections ; ils doivent tous être inclus. Incluez le verrou du chargeur dans votre hiérarchie si vous utilisez des verrous dans DllMain()
  • Acceptez le protocole de verrouillage avec vos dépendances. Tout code que votre application appelle ou qui peut appeler votre application doit partager la même hiérarchie de verrous
  • Verrouiller les structures de données et non les fonctions. Éloignez les acquisitions de verrous des points d’entrée de fonction et protégez uniquement l’accès aux données avec des verrous. Si moins de code fonctionne sous un verrou, il y a moins de risques de blocages
  • Analysez les acquisitions et les mises en production de verrous dans votre code de gestion des erreurs. Souvent, la hiérarchie de verrous est oubliée lors de la tentative de récupération à partir d’une condition d’erreur
  • Remplacez les verrous imbriqués par des compteurs de référence. Ils ne peuvent pas être bloqués. Les éléments verrouillés individuellement dans les listes et les tableaux sont de bons candidats
  • Soyez prudent lorsque vous attendez sur un handle de thread à partir d’une DLL. Supposez toujours que votre code peut être appelé sous le verrou du chargeur. Il est préférable de référencer vos ressources et de laisser le thread de travail effectuer son propre nettoyage (puis utiliser FreeLibraryAndExitThread pour se terminer correctement)
  • Utilisez l’API Wait Chain Traversal si vous souhaitez diagnostiquer vos propres interblocages

Ne pas:

  • Effectuez autre chose que le travail d’initialisation très simple dans votre fonction DllMain(). Pour plus d’informations, consultez Fonction de rappel DllMain. En particulier, n’appelez pas LoadLibraryEx ou CoCreateInstance
  • Écrivez vos propres primitives de verrouillage. Le code de synchronisation personnalisé peut facilement introduire des bogues subtils dans votre base de code. Utilisez plutôt la sélection complète d’objets de synchronisation de système d’exploitation
  • Effectuez n’importe quel travail dans les constructeurs et les destructeurs pour les variables globales, ils sont exécutés sous le verrou du chargeur

Soyez prudent avec les exceptions

Les exceptions permettent de séparer le flux normal du programme et la gestion des erreurs. En raison de cette séparation, il peut être difficile de connaître l’état précis du programme avant l’exception et le gestionnaire d’exceptions peut manquer des étapes cruciales dans la restauration d’un état valide. Cela est particulièrement vrai pour les acquisitions de verrous qui doivent être libérées dans le gestionnaire pour éviter les interblocages futurs.

L’exemple de code ci-dessous illustre ce problème. L’accès indépendant à la variable « buffer » entraîne parfois une violation d’accès (AV). Cet antivirus est intercepté par le gestionnaire d’exceptions natif, mais il n’a pas de moyen facile de déterminer si la section critique a déjà été acquise au moment de l’exception (l’av peut même avoir eu lieu quelque part dans le code EnterCriticalSection).

 BOOL bar (char* buffer)  
{  
   BOOL rc = FALSE;  
   __try {  
      EnterCriticalSection(&cs);  
      while (*buffer++ != '&') ;  
      rc = GetParams(buffer);  
      LeaveCriticalSection(&cs);  
   } __except (EXCEPTION_EXECUTE_HANDLER)  
   {  
      return FALSE;  
   } 
   return rc;  
}  

À faire :

  • Supprimez __try/__except chaque fois que possible ; n’utilisez pas SetUnhandledExceptionFilter
  • Encapsulez vos verrous dans des modèles de type auto_ptr personnalisés si vous utilisez des exceptions C++. Le verrou doit être libéré dans le destructeur. Pour les exceptions natives, relâchez les verrous dans votre instruction __finally
  • Soyez prudent avec le code qui s’exécute dans un gestionnaire d’exceptions natif ; l’exception a pu avoir fuyé de nombreux verrous, de sorte que votre gestionnaire ne doit pas en acquérir

Ne pas:

  • Gérez les exceptions natives si elles ne sont pas nécessaires ou requises par les API Win32. Si vous utilisez des gestionnaires d’exceptions natifs pour la création de rapports ou la récupération de données après des défaillances catastrophiques, envisagez d’utiliser le mécanisme de système d’exploitation par défaut de Rapport d'erreurs Windows à la place
  • Utilisez des exceptions C++ avec n’importe quel type de code d’interface utilisateur (utilisateur32) ; Une exception levée dans un rappel transite par les couches de code C fournies par le système d’exploitation. Ce code ne connaît pas la sémantique d’unroll C++