Aikido

Como prevenir race conditions: acesso thread-safe a estado compartilhado

Risco de Bug

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 múltiplas threads acessam e modificam variáveis compartilhadas sem sincronização, ocorrem condições de corrida (race conditions). O valor final depende do tempo de execução imprevisível das threads, levando à corrupção de dados, cálculos incorretos ou erros de tempo de execução. Um contador incrementado por múltiplas threads sem bloqueio perderá atualizações, pois as threads leem valores desatualizados, os incrementam e escrevem resultados conflitantes.

Por que isso importa

Corrupção de dados e resultados incorretos: Condições de corrida causam corrupção silenciosa de dados, onde os valores se tornam inconsistentes ou incorretos. Saldos de contas podem estar errados, contagens de estoque podem ser negativas ou estatísticas agregadas podem ser corrompidas. Esses bugs são difíceis de reproduzir porque dependem do tempo exato de execução dos threads.

Instabilidade do sistema: Acesso não sincronizado a estados compartilhados pode travar aplicações. Uma thread pode modificar uma estrutura de dados enquanto outra a lê, causando exceções como erros de ponteiro nulo ou índice fora dos limites. Em produção, isso se manifesta como travamentos intermitentes sob carga.

Complexidade de depuração: 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 single-threaded ou em ambientes de baixa carga. A reprodução exige um entrelaçamento de threads específico 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 tempo de processamento
        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

Por que está errado: Múltiplas threads chamando deposit() ou withdraw() simultaneamente criam condições de corrida. Duas threads depositando $100 cada podem ambas ler o saldo como $0, então ambas escrevem $100, resultando em um saldo final de $100 em vez de $200.

✅ Compatível:

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

Por que isso importa: O threading.Lock() garante que apenas uma thread acesse o saldo por vez. Quando uma thread detém o lock, as outras esperam, impedindo modificações simultâneas. Privado __balance com somente leitura @property impede que código externo ignore a proteção de bloqueio.

Conclusão

Proteja todo o estado mutável compartilhado com primitivas de sincronização apropriadas, como locks, semáforos ou operações atômicas. Prefira estruturas de dados imutáveis ou armazenamento thread-local sempre que possível. Quando a sincronização for necessária, minimize as seções críticas para reduzir a contenção e melhorar o desempenho.

FAQs

Dúvidas?

Quais primitivas de sincronização devo usar?

Use locks (mutex) para acesso exclusivo a estados compartilhados. Use semáforos para limitar o acesso concorrente a recursos. Use variáveis de condição para coordenação e sinalização de threads. Para contadores ou flags simples, operações atômicas são mais rápidas que locks. Escolha com base no seu padrão de concorrência: locks para exclusão mútua, operações atômicas para operações simples, e construções de nível superior como filas para padrões produtor-consumidor.

Como evitar deadlocks ao usar múltiplos locks?

Sempre adquira locks na mesma ordem em todos os caminhos de código. Se a função A precisa dos locks X e Y, e a função B precisa dos locks Y e X, adquira-os em ordem consistente (sempre X e depois Y). Use aquisição de lock baseada em timeout para detectar deadlocks potenciais. Melhor ainda, reprojete para precisar de apenas um lock por seção crítica, ou use estruturas de dados lock-free.

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

A contenção de locks retarda o código altamente concorrente porque as threads esperam que os detentores dos locks os liberem. No entanto, o código não sincronizado incorreto é infinitamente mais lento porque produz resultados errados. Minimize o escopo do lock (seções críticas) para proteger apenas a modificação de estado. Use locks de leitura-escrita quando múltiplos leitores não entram em conflito. Faça o profiling antes de otimizar, a correção vem primeiro.

Posso usar armazenamento thread-local em vez de locks?

Sim, quando cada thread precisa de sua própria cópia de dados. O armazenamento local de thread elimina a sobrecarga de sincronização, dando a cada thread um estado privado. Use para caches, buffers ou acumuladores por thread que são mesclados posteriormente. No entanto, você ainda precisa de sincronização quando as threads se comunicam ou compartilham resultados finais.

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

O GIL não elimina a necessidade de locks. Embora impeça a execução simultânea de bytecode Python, não torna as operações atômicas. Um simples incremento de contador += 1 envolve múltiplas operações de bytecode onde o GIL pode ser liberado entre elas. Sempre use a sincronização adequada para estados compartilhados, mesmo no CPython.

Como testar condições de corrida?

Use sanitizadores de thread e ferramentas de teste de concorrência específicas para sua linguagem. Escreva testes de estresse que criem muitas threads realizando operações concorrentes e afirmem que os invariantes são mantidos. Aumente a contagem de threads e as iterações para expor bugs dependentes de tempo. No entanto, testes aprovados não provam a ausência de condições de corrida, então a revisão de código e o design cuidadoso da sincronização continuam sendo críticos.

O que são estruturas de dados lock-free e wait-free?

Estruturas de dados lock-free usam operações atômicas (compare-and-swap) em vez de locks, garantindo o progresso em todo o sistema mesmo que as threads sejam atrasadas. Estruturas wait-free garantem o progresso por thread. Estas são complexas de implementar corretamente, mas oferecem melhor desempenho sob alta contenção. Use bibliotecas comprovadas (java.util.concurrent, biblioteca atômica C++) em vez de implementar as suas próprias.

Fique seguro agora

Proteja seu código, Cloud e runtime em um único sistema centralizado.
Encontre e corrija vulnerabilidades rapidamente de forma automática.

Não é necessário cartão de crédito | Resultados da varredura em 32 segundos.