Aikido

Come prevenire le condizioni di competizione: accesso thread-safe allo stato condiviso

Rischio di bug

Regola
Assicurarsi che che l'accesso sia thread-safe l'accesso alle stato stato condiviso.
Stato stato stato a cui si accede da più thread
senza sincronizzazione causa condizioni condizioni di e errori errori di runtime.

Linguaggi supportati: Python, Java, C#

Introduzione

Quando più thread accedono e modificano variabili condivise senza sincronizzazione, si verificano condizioni di competizione. Il valore finale dipende dai tempi di esecuzione imprevedibili dei thread, il che può causare il danneggiamento dei dati, calcoli errati o errori di esecuzione. Un contatore incrementato da più thread senza blocco non rifletterà gli aggiornamenti, poiché i thread leggono valori non aggiornati, li incrementano e riscrivono risultati in conflitto tra loro.

Perché è importante

Corruzione dei dati e risultati errati: le condizioni di competizione causano una corruzione silenziosa dei dati, in cui i valori diventano incoerenti o errati. I saldi dei conti possono risultare errati, le giacenze di magazzino possono essere negative oppure le statistiche aggregate possono risultare corrotte. Questi bug sono difficili da riprodurre perché dipendono dall’esatta sincronizzazione dei thread.

Instabilità del sistema: l'accesso non sincronizzato allo stato condiviso può causare il crash delle applicazioni. Un thread potrebbe modificare una struttura dati mentre un altro la sta leggendo, provocando eccezioni quali errori di puntatore nullo o indice fuori limiti. In produzione, ciò si manifesta con crash intermittenti sotto carico.

Complessità del debug: le condizioni di competizione sono notoriamente difficili da risolvere perché sono non deterministiche. Il bug potrebbe non manifestarsi nei test a thread singolo o in ambienti a basso carico. Per riprodurlo è necessario uno specifico intreccio di thread difficile da forzare, il che fa sì che i problemi compaiano e scompaiano in modo casuale.

Esempi di codice

❌ Non conforme:

classe ContoBancario:
    def __init__(self):
        self.saldo = 0

    def deposit(self, importo):
        saldo_attuale = self.saldo
        # Condizione di competizione: un altro thread potrebbe modificare il saldo in questo punto
        time.sleep(0.001)  # Simula il tempo di elaborazione
        self.balance = current + amount

    def prelievo(self, importo):
        se saldo.saldo >= importo:
            current = self.saldo
            time.sleep(0.001)
            saldo = saldo_attuale - importo
            return True
        restituisci False

Perché è sbagliato: più thread che chiamano contemporaneamente le funzioni deposit() o withdraw() creano condizioni di competizione. Due thread che depositano ciascuno 100 $ potrebbero entrambi leggere un saldo pari a 0 $, quindi entrambi scrivere 100 $, con il risultato che il saldo finale sarebbe di 100 $ anziché 200 $.

✅ Conforme:

import threading

classe ContoBancario:
    def __init__(self):
        self.__balance = 0
        self.__lock = threading.Lock()

    @property
    def balance(self):
        con self.__lock:
            restituisci self.__balance

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

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

Perché è importante: Il threading.Lock() garantisce che solo un thread alla volta possa accedere a balance. Quando un thread detiene il blocco, gli altri attendono, impedendo modifiche simultanee. Privato __saldo__ con readonly @proprietà impedisce che codice esterno eluda la protezione tramite blocco.

Conclusione

Proteggere tutti gli stati mutabili condivisi utilizzando primitive di sincronizzazione appropriate, quali blocchi (lock), semafori o operazioni atomiche. Quando possibile, privilegiare strutture dati immutabili o l'archiviazione locale del thread. Quando la sincronizzazione è necessaria, ridurre al minimo le sezioni critiche per diminuire il contenzioso e migliorare le prestazioni.

Domande frequenti

Hai delle domande?

Quali primitive di sincronizzazione dovrei utilizzare?

Utilizza i blocchi (mutex) per l'accesso esclusivo allo stato condiviso. Utilizza i semafori per limitare l'accesso concorrente alle risorse. Utilizza le variabili di condizione per il coordinamento e la segnalazione tra thread. Per semplici contatori o flag, le operazioni atomiche sono più veloci dei blocchi. Scegli in base al tuo modello di concorrenza: blocchi per l'esclusione reciproca, operazioni atomiche per operazioni semplici, costrutti di livello superiore come le code per i modelli produttore-consumatore.

Come posso evitare i deadlock quando utilizzo più blocchi?

Acquisire sempre i blocchi nello stesso ordine in tutti i percorsi di codice. Se la funzione A richiede i blocchi X e Y, e la funzione B richiede i blocchi Y e X, acquisirli in un ordine coerente (sempre prima X e poi Y). Utilizzare l'acquisizione dei blocchi basata su timeout per individuare potenziali deadlock. Meglio ancora, riprogettare il codice in modo che sia necessario un solo blocco per ogni sezione critica, oppure utilizzare strutture dati senza blocchi.

Qual è l'impatto della sincronizzazione sulle prestazioni?

La contesa sui blocchi rallenta il codice altamente concorrente perché i thread devono attendere che chi detiene i blocchi li rilasci. Tuttavia, un codice non sincronizzato in modo corretto è infinitamente più lento perché produce risultati errati. Ridurre al minimo l'ambito dei blocchi (sezioni critiche) per proteggere solo le modifiche di stato. Utilizzare blocchi di lettura-scrittura quando più lettori non entrano in conflitto. Eseguire il profiling prima di ottimizzare: la correttezza viene prima di tutto.

Posso utilizzare la memoria locale del thread al posto dei blocchi?

Sì, quando ogni thread necessita di una propria copia dei dati. La memoria locale del thread elimina il sovraccarico di sincronizzazione fornendo a ciascun thread uno stato privato. È utile per cache, buffer o accumulatori specifici per ogni thread che vengono successivamente unificati. Tuttavia, la sincronizzazione rimane necessaria quando i thread comunicano tra loro o condividono i risultati finali.

E il Global Interpreter Lock (GIL) di Python?

Il GIL non elimina la necessità dei blocchi (lock). Sebbene impedisca l'esecuzione simultanea del bytecode di Python, non rende le operazioni atomiche. Un semplice incremento del contatore `counter += 1` comporta diverse operazioni di bytecode, tra le quali il GIL può essere rilasciato. Utilizzate sempre una sincronizzazione adeguata per gli stati condivisi, anche in CPython.

Come posso verificare la presenza di condizioni di competizione?

Utilizzate strumenti di sanificazione dei thread e di test di concorrenza specifici per il vostro linguaggio di programmazione. Scrivete test di stress che generino numerosi thread che eseguono operazioni in concorrenza e verifichino il rispetto delle invarianti. Aumentate il numero di thread e di iterazioni per individuare eventuali bug legati alla temporizzazione. Tuttavia, il superamento dei test non garantisce l’assenza di condizioni di competizione, pertanto la revisione del codice e un’attenta progettazione della sincronizzazione rimangono fondamentali.

Cosa sono le strutture dati lock-free e wait-free?

Le strutture dati lock-free utilizzano operazioni atomiche (compare-and-swap) al posto dei blocchi (lock), garantendo l'avanzamento a livello di sistema anche in caso di ritardi dei thread. Le strutture wait-free garantiscono l'avanzamento a livello di singolo thread. Sono complesse da implementare correttamente, ma offrono prestazioni migliori in condizioni di elevata contesa. È consigliabile utilizzare librerie collaudate (java.util.concurrent, libreria atomica C++) piuttosto che implementarne di proprie.

Metti in sicurezza ora

Metti in sicurezza il tuo codice, il cloud e il runtime in un unico sistema centralizzato.
Trova e risolvi le vulnerabilità rapidamente e automaticamente.

Nessuna carta di credito richiesta | Risultati della scansione in 32 secondi.