Aikido

Comment prévenir les conditions de concurrence : l'accès thread-safe à l'état partagé

Risque de bug

Règle
Assurer l'accès l'accès aux partagé partagé.
Les états partagés mutable mutable accessible par plusieurs threads
sans synchronisation provoque course conditions et d'exécution d'exécution.

Langages pris en charge : Python, Java, C#

Introduction

Lorsque plusieurs threads accèdent et modifient des variables partagées sans synchronisation, des race conditions se produisent. La valeur finale dépend du timing imprévisible de l'exécution des threads, entraînant une corruption des données, des calculs incorrects ou des erreurs runtime. Un compteur incrémenté par plusieurs threads sans verrouillage manquera des mises à jour car les threads liront des valeurs obsolètes, les incrémenteront et écriront des résultats conflictuels.

Pourquoi c'est important

Corruption de données et résultats incorrects : Les conditions de concurrence entraînent une corruption silencieuse des données où les valeurs deviennent incohérentes ou incorrectes. Les soldes de compte peuvent être erronés, les décomptes d'inventaire peuvent être négatifs, ou les statistiques agrégées peuvent être corrompues. Ces bugs sont difficiles à reproduire car ils dépendent du timing exact des threads.

Instabilité du système : L'accès non synchronisé à un état partagé peut faire planter les applications. Un thread peut modifier une structure de données pendant qu'un autre la lit, provoquant des exceptions comme des erreurs de pointeur nul ou des dépassements d'index. En production, ces problèmes se manifestent par des plantages intermittents sous charge.

Complexité du débogage : Les conditions de concurrence (race conditions) sont notoirement difficiles à déboguer car elles sont non déterministes. Le bug peut ne pas apparaître dans les tests mono-thread ou les environnements à faible charge. La reproduction nécessite un entrelacement de threads spécifique difficile à forcer, ce qui fait que les problèmes apparaissent et disparaissent aléatoirement.

Exemples de code

❌ Non conforme :

class BankAccount:
    def __init__(self):
        self.balance = 0

    def deposit(self, amount):
        current = self.balance
        # Condition de concurrence : un autre thread peut modifier le solde ici
        time.sleep(0.001)  # Simule le temps de traitement
        self.balance = current + amount

    def withdraw(self, amount):
        if self.balance >= amount:
            current = self.balance
            time.sleep(0.001)
            self.balance = current - amount
            return True
        return False

Pourquoi c'est incorrect : Plusieurs threads appelant simultanément deposit() ou withdraw() créent des conditions de concurrence. Deux threads déposant 100 $ chacun pourraient tous deux lire le solde comme 0 $, puis tous deux écrire 100 $, ce qui entraînerait un solde final de 100 $ au lieu de 200 $.

✅ Conforme :

import threading

class BankAccount:
    def __init__(self):
        self.__balance = 0
        self.__lock = threading.Lock()

    @property
    def balance(self):
        with self.__lock:
            return self.__balance

    def deposit(self, amount):
        with self.__lock:
            current = self.__balance
            time.sleep(0.001)
            self.__balance = current + amount

    def withdraw(self, amount):
        with self.__lock:
            if self.__balance >= amount:
                current = self.__balance
                time.sleep(0.001)
                self.__balance = current - amount
                return True
            return False

Pourquoi c'est important : Le threading.Lock() garantit qu'un seul thread accède au solde à la fois. Lorsqu'un thread détient le verrou, les autres attendent, empêchant les modifications simultanées. Privé __balance avec readonly @property empêche le code externe de contourner la protection par verrouillage.

Conclusion

Protégez tout état mutable partagé avec des primitives de synchronisation appropriées telles que les verrous, les sémaphores ou les opérations atomiques. Préférez les structures de données immuables ou le stockage local aux threads lorsque cela est possible. Lorsque la synchronisation est nécessaire, minimisez les sections critiques pour réduire la contention et améliorer les performances.

FAQ

Des questions ?

Quelles primitives de synchronisation devrais-je utiliser ?

Utilisez des verrous (mutex) pour un accès exclusif à l'état partagé. Utilisez des sémaphores pour limiter l'accès concurrent aux ressources. Utilisez des variables de condition pour la coordination et la signalisation des threads. Pour les compteurs ou drapeaux simples, les opérations atomiques sont plus rapides que les verrous. Choisissez en fonction de votre modèle de concurrence : verrous pour l'exclusion mutuelle, opérations atomiques pour les opérations simples, constructions de niveau supérieur comme les files d'attente pour les modèles producteur-consommateur.

Comment éviter les interblocages lors de l'utilisation de plusieurs verrous ?

Acquérez toujours les verrous dans le même ordre sur tous les chemins de code. Si la fonction A a besoin des verrous X et Y, et la fonction B a besoin des verrous Y et X, acquérez-les dans un ordre cohérent (toujours X puis Y). Utilisez l'acquisition de verrous basée sur un timeout pour détecter les deadlocks potentiels. Mieux encore, repensez la conception pour n'avoir besoin que d'un seul verrou par section critique, ou utilisez des structures de données sans verrou.

Quel est l'impact sur les performances de la synchronisation ?

La contention de verrous ralentit le code hautement concurrent, car les threads attendent que les détenteurs de verrous les libèrent. Cependant, un code non synchronisé incorrect est infiniment plus lent, car il produit des résultats erronés. Minimisez la portée des verrous (sections critiques) pour ne protéger que la modification de l'état. Utilisez des verrous de lecture-écriture lorsque plusieurs lecteurs ne sont pas en conflit. Profilez avant d'optimiser, la correction prime.

Puis-je utiliser le stockage thread-local au lieu des verrous ?

Oui, lorsque chaque thread a besoin de sa propre copie de données. Le stockage local aux threads élimine la surcharge de synchronisation en donnant à chaque thread un état privé. À utiliser pour les caches, les tampons ou les accumulateurs par thread qui seront fusionnés ultérieurement. Cependant, vous avez toujours besoin de synchronisation lorsque les threads communiquent ou partagent les résultats finaux.

Qu'en est-il du Global Interpreter Lock (GIL) de Python ?

Le GIL n'élimine pas le besoin de verrous. Bien qu'il empêche l'exécution simultanée de bytecode Python, il ne rend pas les opérations atomiques. Une simple incrémentation de compteur += 1 implique plusieurs opérations de bytecode où le GIL peut être libéré entre elles. Utilisez toujours une synchronisation appropriée pour l'état partagé, même en CPython.

Comment tester les conditions de concurrence ?

Utilisez des sanitizers de threads et des outils de test de concurrence spécifiques à votre langage. Rédigez des tests de charge qui génèrent de nombreux threads effectuant des opérations concurrentes et affirment que les invariants sont respectés. Augmentez le nombre de threads et d'itérations pour exposer les bugs dépendants du timing. Cependant, la réussite des tests ne prouve pas l'absence de conditions de concurrence, donc la revue de code et une conception minutieuse de la synchronisation restent essentielles.

Que sont les structures de données lock-free et wait-free ?

Les structures de données sans verrou utilisent des opérations atomiques (compare-and-swap) au lieu de verrous, garantissant une progression à l'échelle du système même si les threads sont retardés. Les structures sans attente garantissent une progression par thread. Celles-ci sont complexes à implémenter correctement, mais offrent de meilleures performances en cas de forte contention. Utilisez des bibliothèques éprouvées (java.util.concurrent, bibliothèque atomique C++) plutôt que d'implémenter les vôtres.

Sécurisez-vous maintenant.

Sécuriser votre code, votre cloud et votre runtime dans un système centralisé unique.
Détectez et corrigez les vulnérabilités rapidement et automatiquement.

Pas de carte de crédit requise | Résultats du scan en 32 secondes.