1. Conhecendo a condição de corrida (race condition)
Vamos relembrar a condição de corrida (race condition) — situação em que o resultado do programa depende de como as threads obtêm acesso a dados ou recursos compartilhados. Se a ordem de execução muda — o resultado torna-se imprevisível. É como se você e um amigo tentassem editar ao mesmo tempo o mesmo documento: quem escrever mais rápido “vence”, e o texto final pode ficar bem estranho.
Em Java (e em qualquer outra linguagem com suporte a multithreading), uma race condition aparece quando várias threads leem e/ou modificam a mesma variável simultaneamente sem a devida sincronização.
Por que surge uma race condition?
As threads em Java trabalham em paralelo. Se duas threads acessam a mesma variável ao mesmo tempo (por exemplo, aumentam um contador compartilhado), elas podem “atropelar” uma à outra. Mesmo que a operação pareça atômica (por exemplo, counter++), na verdade, não é!
Como funciona counter++?
A operação de incremento envolve várias etapas:
- Ler o valor atual da variável na memória.
- Aumentar esse valor em uma unidade.
- Gravar o novo valor de volta na memória.
Se, nesse momento, outra thread também faz counter++, ambas podem ler o mesmo valor, ambas incrementá-lo e ambas gravar o mesmo resultado — no fim, um incremento “se perde”.
2. Exemplo de race condition: incremento do contador
Vamos escrever um programa simples que inicia várias threads, cada uma das quais incrementa um contador compartilhado em 1. À primeira vista, se executarmos 1000 threads, o valor final do contador deveria ser 1000. Vamos verificar!
public class RaceConditionDemo {
static int counter = 0;
public static void main(String[] args) throws InterruptedException {
int threads = 1000;
Thread[] threadArray = new Thread[threads];
for (int i = 0; i < threads; i++) {
threadArray[i] = new Thread(() -> {
counter++; // Operação PERIGOSA!
});
threadArray[i].start();
}
// Aguardamos a conclusão de todas as threads
for (int i = 0; i < threads; i++) {
threadArray[i].join();
}
System.out.println("Esperado: " + threads);
System.out.println("Obtido: " + counter);
}
}
Saída esperada:
Esperado: 1000
Obtido: 843
O valor pode variar a cada execução: às vezes 900, às vezes 700 e às vezes 1000 — mas muito raramente.
Por que isso acontece?
As threads leem simultaneamente o valor de counter, o incrementam e o gravam de volta. Se duas threads leem o mesmo valor, ambas o incrementam e ambas gravam — um incremento se perde. Como resultado, o valor final é sempre menor que o esperado.
3. Outro exemplo: banco sem sincronização
Vamos imaginar que temos uma conta bancária e duas threads sacam dinheiro ao mesmo tempo.
public class BankAccount {
private int balance = 100;
public void withdraw(int amount) {
if (balance >= amount) {
// Simulação de trabalho demorado
try { Thread.sleep(1); } catch (InterruptedException ignored) {}
balance -= amount;
}
}
public int getBalance() {
return balance;
}
}
public class BankDemo {
public static void main(String[] args) throws InterruptedException {
BankAccount account = new BankAccount();
Thread t1 = new Thread(() -> account.withdraw(100));
Thread t2 = new Thread(() -> account.withdraw(100));
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Esperado: 0 ou 100");
System.out.println("Saldo efetivo: " + account.getBalance());
}
}
Às vezes, ambas as threads verão que há 100 na conta, e ambas sacarão o dinheiro. Como resultado, o saldo se tornará -100! (Na vida real isso não acontece, mas em código — facilmente.)
4. Nuances úteis
Consequências da race condition
A condição de corrida não é apenas sobre resultados “estranhos”. É uma verdadeira dor de cabeça para o programador, porque:
- Os erros nem sempre aparecem. Às vezes o programa funciona corretamente, às vezes não. Tudo depende de como as threads “conseguiram” executar suas ações.
- Testar não garante sucesso. Você pode executar o programa muitas vezes — e tudo ficar bem, e então, de repente, tudo quebrar.
- Os erros são difíceis de capturar. O comportamento depende da velocidade do processador, da carga do sistema e de outros programas em execução.
- Podem ocorrer falhas críticas: perda de dados, cálculos incorretos, queda do aplicativo.
Exemplos reais
- Aplicações financeiras: cálculo incorreto de saldo, cobranças em dobro.
- Servidores: perda de mensagens, processamento incorreto de requisições.
- Jogos: “teleporte” de personagens, pontuações atribuídas de forma errada.
Por que testar não salva da race condition?
Race condition é um típico “Heisenbug” (um bug que desaparece quando você tenta capturá-lo). Mesmo que você rode os testes mil vezes e não veja o erro — isso não significa que ele não existe! Tudo depende de como o sistema operacional irá escalonar o trabalho das threads. Às vezes tudo passa sem problemas, e às vezes as threads “colidem” e o problema surge.
Como evitar a race condition?
- Sincronização: use a palavra-chave synchronized em métodos ou blocos de código para que apenas uma thread possa modificar os dados compartilhados por vez.
- Operações atômicas: use classes do pacote java.util.concurrent.atomic (por exemplo, AtomicInteger), que fornecem operações seguras sem sincronização explícita.
- Imutabilidade: se o objeto não pode ser modificado, a race condition é impossível.
Exemplo com sincronização
public class SafeCounter {
private int counter = 0;
public synchronized void increment() {
counter++;
}
public int getValue() {
return counter;
}
}
Agora, se várias threads chamarem increment(), apenas uma thread poderá executar esse método em cada momento.
5. Erros comuns ao trabalhar com variáveis compartilhadas entre threads
Erro nº 1: Confiança ingênua na segurança de operações simples.
Muitos pensam que counter++ é uma única operação e nada de ruim pode acontecer. Na verdade, são três operações, e entre elas outra thread pode “se enfiar”.
Erro nº 2: Usar variáveis comuns para troca entre threads.
Se várias threads escrevem e leem a mesma variável sem sincronização — olá, race condition!
Erro nº 3: Esperar que o erro sempre se manifeste.
A race condition pode se manifestar apenas às vezes, o que a torna especialmente traiçoeira. Não confie que, se tudo funcionou nos testes, está tudo bem.
Erro nº 4: Ignorar sincronização ao trabalhar com coleções.
Coleções comuns como ArrayList não são thread-safe. Se várias threads adicionam ou removem elementos — podem ocorrer falhas e até a queda do programa.
Erro nº 5: Tentar “consertar” a race condition com atrasos.
Por exemplo, usando Thread.sleep(10) ou outras pausas “mágicas”. Essa abordagem não resolve o problema; apenas o mascara. A solução real é sincronização ou operações atômicas.
GO TO FULL VERSION