Bonnes pratiques pour le threading managé

Le multithreading nécessite une programmation attentive. Pour réduire la complexité de la plupart des tâches, il vous suffit de mettre en file d’attente les requêtes à exécuter par les threads d’un pool de threads. Cet article vous permet de remédier aux situations plus complexes, telles que la coordination du travail de plusieurs threads ou la gestion des threads bloqués.

Notes

À partir de .NET Framework 4, la bibliothèque parallèle de tâches et PLINQ fournissent des API qui atténuent une partie de la complexité et des risques de la programmation multithread. Pour plus d’informations, consultez la page Programmation parallèle dans .NET.

Interblocages et conditions de concurrence

Le multithreading résout les problèmes de débit et de réactivité, mais, ce faisant, occasionne de nouveaux problèmes : les interblocages et les conditions de concurrence.

Blocages

Un interblocage se produit lorsque chacun des deux threads tente de verrouiller une ressource déjà verrouillée par l’autre thread. Aucun des deux threads ne peut donc poursuivre l’exécution.

De nombreuses méthodes des classes de threading managé fournissent des délais d’expiration conçus pour faciliter la détection des interblocages. Par exemple, le code ci-après tente d’acquérir un verrou sur un objet nommé lockObject. Si le verrou n’est pas obtenu en 300 millisecondes, Monitor.TryEnter retourne false.

If Monitor.TryEnter(lockObject, 300) Then  
    Try  
        ' Place code protected by the Monitor here.  
    Finally  
        Monitor.Exit(lockObject)  
    End Try  
Else  
    ' Code to execute if the attempt times out.  
End If  
if (Monitor.TryEnter(lockObject, 300)) {  
    try {  
        // Place code protected by the Monitor here.  
    }  
    finally {  
        Monitor.Exit(lockObject);  
    }  
}  
else {  
    // Code to execute if the attempt times out.  
}  

Conditions de concurrence

Une condition de concurrence est un bogue qui survient lorsque le résultat d’un programme dépend du thread qui atteint le premier un bloc de code spécifique. L’exécution du programme à plusieurs reprises produit des résultats différents, et le résultat d’une exécution donnée n’est donc pas prévisible.

Un exemple simple de condition de concurrence correspond à l’incrémentation d’un champ. Supposons qu’une classe comporte un champ static privé (Shared en Visual Basic) qui est incrémenté chaque fois qu’une instance de la classe est créée, à l’aide d’un code tel que objCt++; (C#) ou objCt += 1 (Visual Basic). Cette opération nécessite le chargement de la valeur de objCt dans un registre, l’incrémentation de la valeur, puis son stockage dans objCt.

Dans une application multithread, il est possible qu’un thread ayant chargé et incrémenté la valeur soit devancé par un autre thread qui exécute ces trois étapes ; lorsque le premier thread reprend l’exécution et stocke sa valeur, il remplace alors la valeur de objCt sans tenir compte du fait qu’elle a changé dans l’intervalle.

Cette condition de concurrence particulière est facile à éviter en utilisant des méthodes de la classe Interlocked, telles que Interlocked.Increment. Pour découvrir d’autres techniques de synchronisation des données entre plusieurs threads, consultez l’article Synchronisation des données pour le multithreading.

Des conditions de concurrence peuvent également survenir lorsque vous synchronisez les activités de plusieurs threads. Chaque fois que vous écrivez une ligne de code, vous devez prendre en compte ce qui peut se produire si un thread est devancé par un autre thread avant d’avoir exécuté la ligne (ou avant toute instruction machine individuelle composant la ligne).

Membres static et constructeurs static

Une classe n’est pas initialisée tant que son constructeur de classe (constructeur static en C#, Shared Sub New en Visual Basic) n’a pas fini de s’exécuter. Pour empêcher l’exécution de code sur un type qui n’est pas initialisé, le common language runtime bloque tous les appels d’autres threads aux membres static de la classe (membres Shared en Visual Basic) jusqu’à la fin de l’exécution du constructeur de classe.

Par exemple, si un constructeur de classe démarre un nouveau thread, et que la procédure de thread appelle un membre static de la classe, le nouveau thread se bloque jusqu’à la fin du constructeur de classe.

Cela s’applique à n’importe quel type pouvant comporter un constructeur static.

Nombre de processeurs

Le fait que plusieurs processeurs ou un seul soient disponibles sur un système peut influencer l’architecture multithread. Pour plus d’informations, consultez Nombre de processeurs.

Utilisez la propriété Environment.ProcessorCount pour déterminer le nombre de processeurs disponibles au moment de l’exécution.

Recommandations générales

Lorsque vous utilisez plusieurs threads, tenez compte des recommandations suivantes :

  • N’utilisez pas Thread.Abort pour mettre fin à d’autres threads. L’appel de la méthode Abort sur un autre thread équivaut à lever une exception sur ce dernier sans connaître le stade précis du traitement atteint par ce thread.

  • N’utilisez pas Thread.Suspend et Thread.Resume pour synchroniser les activités de plusieurs threads. Utilisez Mutex, ManualResetEvent, AutoResetEvent et Monitor.

  • Ne contrôlez pas l’exécution des threads de travail à partir de votre programme principal (à l’aide d’événements, par exemple). Concevez plutôt votre programme pour que les threads de travail soient chargés d’attendre que le travail devienne disponible, d’exécuter ce travail, puis d’en informer les autres parties de votre programme lorsqu’ils ont terminé. Si vos threads de travail ne se bloquent pas, envisagez d’utiliser les threads d’un pool de threads. Monitor.PulseAll est utile dans les situations où les threads de travail se bloquent.

  • N’utilisez pas de types en tant qu’objets de verrou. Autrement dit, évitez un code tel que lock(typeof(X)) en C# ou SyncLock(GetType(X)) en Visual Basic, ou l’utilisation de Monitor.Enter avec des objets Type. Pour un type donné, il existe une seule instance de System.Type par domaine d’application. Si le type sur lequel vous utilisez un verrou est public, un code différent du vôtre peut utiliser des verrous sur ce type, entraînant ainsi des interblocages. Pour découvrir les autres problèmes, consultez l’article Meilleures pratiques pour la fiabilité.

  • Procédez avec précaution lorsque vous utilisez des verrous sur des instances, par exemple lock(this) en C# ou SyncLock(Me) en Visual Basic. Si un autre code de votre application, externe au type, utilise un verrou sur l’objet, des interblocages risquent de se produire.

  • Assurez-vous qu’un thread entré dans un moniteur quitte toujours ce dernier, même si une exception se produit pendant que le thread se trouve dans le moniteur. L’instruction C# lock et l’instruction Visual Basic SyncLock assurent automatiquement ce comportement, en employant un bloc finally pour garantir l’appel de la méthode Monitor.Exit. Si vous n’êtes pas en mesure de garantir l’appel de la méthode Exit, vous pouvez modifier votre conception de façon à utiliser Mutex. Un mutex est automatiquement libéré lorsque le thread auquel il appartient a terminé.

  • Utilisez plusieurs threads pour les tâches qui nécessitent des ressources différentes, et évitez d’attribuer plusieurs threads à une même ressource. Par exemple, vous tirerez avantage du fait que les tâches impliquant des E/S disposent de leur propre thread, car ce thread se bloquera lors des opérations d’E/S et permettra ainsi à d’autres threads de s’exécuter. L’entrée utilisateur est une autre ressource tirant profit d’un thread dédié. Sur un ordinateur monoprocesseur, une tâche qui exige de multiples calculs coexiste avec l’entrée utilisateur et avec les tâches qui impliquent des E/S, mais plusieurs tâches nécessitant de nombreux calculs entrent en concurrence les unes avec les autres.

  • Envisagez d’utiliser les méthodes de la classe Interlocked pour les modifications d’état simples, plutôt que de recourir à l’instruction lock (SyncLock en Visual Basic). L’instruction lock est un outil à usage général efficace, mais la classe Interlocked offre de meilleures performances pour les mises à jour qui doivent être atomiques. En interne, elle exécute un seul préfixe de verrou s’il n’existe aucun conflit. Lors des phases de révision du code, examinez le code semblable à celui des exemples ci-dessous. Dans le premier exemple, une variable d’état est incrémentée :

    SyncLock lockObject  
        myField += 1  
    End SyncLock  
    
    lock(lockObject)
    {  
        myField++;  
    }  
    

    Vous pouvez améliorer les performances en utilisant la méthode Increment au lieu de l’instructionlock, comme suit :

    System.Threading.Interlocked.Increment(myField)  
    
    System.Threading.Interlocked.Increment(myField);  
    

    Notes

    Utilisez la méthode Add pour les incréments atomiques supérieurs à 1.

    Dans le second exemple, une variable de type référence est uniquement mise à jour s’il s’agit d’une référence null (Nothing en Visual Basic).

    If x Is Nothing Then  
        SyncLock lockObject  
            If x Is Nothing Then  
                x = y  
            End If  
        End SyncLock  
    End If  
    
    if (x == null)  
    {  
        lock (lockObject)  
        {  
            x ??= y;
        }  
    }  
    

    Les performances peuvent être améliorées en utilisant à la place la méthode CompareExchange comme suit :

    System.Threading.Interlocked.CompareExchange(x, y, Nothing)  
    
    System.Threading.Interlocked.CompareExchange(ref x, y, null);  
    

    Notes

    La surcharge de méthode CompareExchange<T>(T, T, T) fournit une alternative de type sécurisé pour les types référence.

Recommandations relatives aux bibliothèques de classes

Lorsque vous concevez des bibliothèques de classes pour le multithreading, tenez compte des recommandations suivantes :

  • Dans la mesure du possible, évitez toute nécessité de synchronisation. Cette consigne s’applique tout particulièrement pour le code très sollicité. Par exemple, un algorithme peut être ajusté de façon à tolérer une condition de concurrence plutôt que de l’éliminer. Une synchronisation inutile dégrade les performances et entraîne un risque d’interblocages et de conditions de concurrence.

  • Définissez les données static (Shared en Visual Basic) comme thread-safe par défaut.

  • Ne définissez pas les données d’instance comme thread-safe par défaut. L’ajout de verrous pour créer un code thread-safe diminue les performances, multiplie les conflits de verrou et occasionne un risque d’interblocages. Dans les modèles d’application communs, le code utilisateur n’est exécuté que par un seul thread à la fois, ce qui minimise la nécessité d’une cohérence de thread. C’est la raison pour laquelle les bibliothèques de classes .NET ne sont pas thread-safe par défaut.

  • Évitez de fournir des méthodes statiques qui modifient l’état statique. Dans les scénarios de serveur courants, l’état statique est partagé entre les requêtes, ce qui signifie que plusieurs threads peuvent exécuter ce code en même temps. Ceci occasionne un risque de bogues de threading. Envisagez d’utiliser un modèle de conception qui encapsule les données dans des instances non partagées entre les requêtes. En outre, si les données static sont synchronisées, les appels entre les méthodes statiques qui modifient l’état peuvent entraîner des interblocages ou une synchronisation redondante et dégrader ainsi les performances.

Voir aussi