Aikido

Como evitar condições de corrida: acesso thread-safe ao estado partilhado

Risco de insectos

Regra
Garantir thread-safe acesso a partilhado partilhado.
Partilhado mutável estado acedido por múltiplas threads
sem sincronização causa corrida condições e tempo de execução erros.

Linguagens suportadas: Python, Java, C#

Introdução

Quando várias threads acedem e modificam variáveis partilhadas sem sincronização, ocorrem condições de corrida. O valor final depende do tempo de execução imprevisível da thread, levando à corrupção de dados, cálculos incorrectos ou erros de tempo de execução. Um contador incrementado por várias threads sem bloqueio perderá actualizações à medida que as threads lêem valores obsoletos, os incrementam e escrevem resultados contraditórios.

Porque é importante

Corrupção de dados e resultados incorrectos: As condições de corrida causam corrupção silenciosa de dados, em que os valores se tornam inconsistentes ou incorrectos. Os saldos das contas podem estar errados, as contagens de inventário podem ser negativas ou as estatísticas agregadas podem ser corrompidas. Estes erros são difíceis de reproduzir porque dependem do tempo exato do thread.

Instabilidade do sistema: O acesso não sincronizado ao estado partilhado pode bloquear as aplicações. Uma thread pode modificar uma estrutura de dados enquanto outra a lê, causando excepções como erros de ponteiro nulo ou índice fora dos limites. Na produção, isso se manifesta como falhas intermitentes sob carga.

Complexidade de depuração: As condições de corrida são notoriamente difíceis de depurar porque são não-determinísticas. O bug pode não aparecer em testes de thread único ou em ambientes de baixa carga. A reprodução requer uma intercalação específica de threads que é difícil de forçar, fazendo com que os problemas apareçam e desapareçam aleatoriamente.

Exemplos de código

Não conforme:

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

    def deposit(self, amount):
 current = self.balance
        # Condição de corrida: outra thread pode modificar o saldo aqui
 time.sleep(0.001) # Simula o tempo de processamento
 self.balance = current + amount

    def withdraw(self, amount):
        if self.balance >= amount: 
            current = self.balance
            time.sleep(0.001) 
            saldo próprio = atual - montante
            return True
        return False

Porque é que está errado: Várias threads que chamam deposit() ou withdraw() simultaneamente criam condições de corrida. Duas threads que depositam $100 cada uma podem ler o saldo como $0 e depois escrever $100, resultando num saldo final de $100 em vez de $200.

Conformidade:

importar 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

Porque é que isto é importante: O threading.Lock() assegura que apenas um thread acede ao saldo de cada vez. Quando um thread mantém o bloqueio, os outros esperam, impedindo modificações simultâneas. Privado __equilíbrio com carácter só de leitura @propriedade impede que um código externo contorne a proteção do cadeado.

Conclusão

Proteger todo o estado mutável partilhado com primitivas de sincronização adequadas, como bloqueios, semáforos ou operações atómicas. Prefira estruturas de dados imutáveis ou armazenamento local de thread quando possível. Quando a sincronização for necessária, minimize as secções críticas para reduzir a contenção e melhorar o desempenho.

FAQs

Tem perguntas?

Que primitivas de sincronização devo utilizar?

Utilizar bloqueios (mutex) para acesso exclusivo ao estado partilhado. Utilizar semáforos para limitar o acesso simultâneo a recursos. Utilizar variáveis de condição para coordenação e sinalização de threads. Para contadores ou sinalizadores simples, as operações atómicas são mais rápidas do que os bloqueios. Escolha com base no seu padrão de concorrência: bloqueios para exclusão mútua, operações atómicas para operações simples, construções de nível superior como filas para padrões de produtor-consumidor.

Como é que evito bloqueios quando utilizo vários bloqueios?

Adquira sempre os bloqueios pela mesma ordem em todos os caminhos de código. Se a função A necessitar dos bloqueios X e Y e a função B necessitar dos bloqueios Y e X, adquira-os por uma ordem consistente (sempre X e depois Y). Utilize a aquisição de bloqueios baseada no tempo limite para detetar potenciais bloqueios. Melhor ainda, redesenhe para necessitar apenas de um bloqueio por secção crítica, ou utilize estruturas de dados sem bloqueios.

Qual é o impacto da sincronização no desempenho?

A contenção de bloqueios torna o código altamente concorrente mais lento porque as threads esperam que os detentores dos bloqueios sejam libertados. No entanto, o código não sincronizado incorreto é infinitamente mais lento porque produz resultados errados. Minimize o âmbito do bloqueio (secções críticas) para proteger apenas a modificação do estado. Use bloqueios de leitura-escrita quando vários leitores não entrarem em conflito. Perfil antes de otimizar, a correção vem em primeiro lugar.

Posso utilizar armazenamento local de thread em vez de bloqueios?

Sim, quando cada thread precisa da sua própria cópia dos dados. O armazenamento local de thread elimina a sobrecarga de sincronização, fornecendo a cada thread um estado privado. Use para caches, buffers ou acumuladores por thread que serão mesclados posteriormente. No entanto, a sincronização continua a ser necessária quando as threads comunicam ou partilham resultados finais.

E quanto ao Global Interpreter Lock (GIL) do Python?

O GIL não elimina a necessidade de bloqueios. Enquanto previne a execução simultânea de bytecode Python, não torna as operações atómicas. Um simples incremento do contador += 1 envolve múltiplas operações de bytecode onde o GIL pode ser liberado entre elas. Sempre use sincronização apropriada para estado compartilhado, mesmo em CPython.

Como é que testo as condições de corrida?

Utilize higienizadores de threads e ferramentas de teste de concorrência específicas para a sua linguagem. Escreva testes de stress que geram muitos threads que executam operações simultâneas e asseguram que os invariantes se mantêm. Aumente a contagem de threads e as iterações para expor erros dependentes do tempo. No entanto, a aprovação nos testes não prova a ausência de condições de corrida, pelo que a revisão do código e a conceção cuidadosa da sincronização continuam a ser fundamentais.

O que são estruturas de dados sem bloqueio e sem espera?

As estruturas de dados sem bloqueios utilizam operações atómicas (comparar e trocar) em vez de bloqueios, garantindo o progresso de todo o sistema, mesmo que as threads sofram atrasos. As estruturas sem espera garantem o progresso por thread. Estas estruturas são complexas de implementar corretamente, mas oferecem um melhor desempenho em situações de elevada contenção. Utilize bibliotecas testadas em combate (java.util.concurrent, biblioteca atómica C++) em vez de implementar as suas próprias bibliotecas.

Obter segurança gratuitamente

Proteja seu código, nuvem e tempo de execução em um sistema central.
Encontre e corrija vulnerabilidades rapidamente de forma automática.

Não é necessário cartão de crédito | Resultados do scan em 32secs.