Trilha 09 · Concorrência e paralelismo

Race conditions

Uma race condition ocorre quando o resultado de um programa depende da ordem de execução de threads — e essa ordem é imprevisível. O bug mais clássico: duas threads incrementando o mesmo contador. Parece impossível dar errado, mas counter += 1 não é uma operação atômica.

① Intuição

counter += 1 são três operações, não uma

A linha counter += 1 parece atômica, mas o processador a executa em três passos: LOAD (lê o valor da memória para um registrador), ADD (incrementa no registrador) e STORE (escreve de volta na memória). O sistema operacional pode interromper a thread e escalonar outra entre qualquer um desses passos.

Se duas threads leem o mesmo valor antigo antes de qualquer uma escrever, ambas incrementam para o mesmo resultado e uma delas sobrescreve a outra. Uma incrementação é perdida.

Heisenbug: race conditions são notórias por desaparecer quando você adiciona print() ou conecta um debugger. Isso acontece porque o I/O e os breakpoints alteram o timing de execução entre threads — eliminando justamente a interleaving que causava o bug. O bug continua lá em produção.
② Visualização interativa

O que acontece com counter += 1 em duas threads

Compare o cenário intercalado (race condition) com o sequencial correto. Avance passo a passo para ver como o LOAD, ADD e STORE podem se sobrepor.

MEMÓRIA
counter
0
Thread 1
registrador:
Thread 2
registrador:
Inicial: counter = 0 na memória compartilhada. Ambas as threads vão executar counter += 1.
1/7
③ Explicação técnica

Por que counter += 1 não é atômico

# O problema: counter += 1 não é atômico
# Parece uma instrução, mas são três operações:
#   1. LOAD:  reg = counter    (lê da memória)
#   2. ADD:   reg = reg + 1    (incrementa)
#   3. STORE: counter = reg    (escreve na memória)

# Se T1 e T2 intercalam essas operações:
#   T1: LOAD  (lê 0)
#   T2: LOAD  (lê 0 — T1 ainda não escreveu!)
#   T1: ADD, STORE (escreve 1)
#   T2: ADD, STORE (escreve 1 — sobrescreve T1!)
#   Resultado: 1 em vez de 2 → dado corrompido

import threading

counter = 0

def buggy_increment():
    global counter
    for _ in range(500_000):
        counter += 1   # race condition aqui

threads = [threading.Thread(target=buggy_increment) for _ in range(4)]
for t in threads: t.start()
for t in threads: t.join()
print(counter)  # esperado: 2.000.000, real: < 2.000.000

Como corrigir: locks e operações atômicas

# Solução 1: Lock (mutex) — exclusão mútua
import threading
lock = threading.Lock()
counter = 0

def safe_increment():
    global counter
    for _ in range(500_000):
        with lock:  # lock()/unlock() automático
            counter += 1   # agora é atomicamente protegido

# Solução 2: operação atômica (mais rápida)
import ctypes
# C11: _Atomic int counter = 0;
# C++11: std::atomic<int> counter{0};
# counter.fetch_add(1)  ← instrução única da CPU (LOCK XADD)

# Solução 3: variável de thread local (sem compartilhamento)
local = threading.local()
# Cada thread tem sua própria cópia — sem race condition!
# Combina no final com um lock só uma vez.

# Heisenbug: race conditions somem com print/debugger
# porque a sincronização de I/O muda o timing.
Seção crítica: a parte do código que acessa dados compartilhados é chamada de seção crítica. A regra de segurança é: somente uma thread pode estar na seção crítica por vez (exclusão mútua). Locks garantem isso, mas têm custo — contention de lock pode eliminar todo o benefício do paralelismo.
④ Projeto para programar

Encontrando e corrigindo race conditions

Mini projeto: reproduza a race condition do contador em Python. Execute com 2 e 4 threads, 1.000 e 1.000.000 iterações por thread. Registre os resultados em uma tabela. Depois corrija com threading.Lock e verifique que o resultado é sempre correto. Quanto mais lenta ficou a versão com lock?

Projeto principal: implemente um banco simplificado: 100 contas com saldo inicial R$1.000 cada. Crie 10 threads que realizam transferências aleatórias entre contas. Sem proteção, o saldo total às vezes difere de R$100.000 — isso é perda de dinheiro simulada! Adicione locks por conta e verifique a invariante: saldo_total sempre = 100.000.

Desafio extra: use a ferramenta ThreadSanitizer (TSan) do compilador GCC/Clang (-fsanitize=thread) para detectar race conditions automaticamente em um programa C. Escreva 3 programas: um com race condition, um correto com mutex, e um com operações atômicas (stdatomic.h). Compare o tempo de execução dos três.

⑤ Exercícios rápidos

Teste sua intuição

Por que counter += 1 em duas threads pode resultar em counter = 1 em vez de 2?
Por que race conditions são especialmente difíceis de depurar?
Qual é a forma mais eficiente de tornar counter += 1 thread-safe sem usar um lock?
⑥ Aplicações no mundo real

Onde você encontra isso

💻

Therac-25 — race condition fatal

O Therac-25 era uma máquina de radioterapia dos anos 80 que matou 6 pacientes por overdose de radiação. A causa: uma race condition no software de controle. Um operador digitando rapidamente podia entrar num estado de configuração inválida que desativava as proteções de hardware. A lição é ensinada em toda disciplina de engenharia de software crítica.

📱

Corrupção silenciosa em apps

Race conditions em apps iOS/Android causam crashes intermitentes difíceis de reproduzir. Um exemplo clássico: cache de imagens onde duas threads tentam inserir a mesma chave simultaneamente. Em Objective-C, NSMutableDictionary não é thread-safe. A Apple fornece o Instruments Time Profiler e o Thread Sanitizer para detectar esses bugs antes do lançamento.

🗄️

Bancos de dados — TOCTOU

O ataque TOCTOU (Time Of Check To Time Of Use) é uma race condition de segurança: entre verificar uma permissão e usá-la, o estado pode mudar. Bancos de dados usam transações com isolamento (ACID) para prevenir isso. Sem transação, dois saques simultâneos do mesmo saldo podem resultar em saldo negativo — o "double spend" que o Bitcoin foi projetado para resolver.

← Anterior: Threads e processos Próxima: Mutex e semáforos →