Race Condition
Uma race condition é quando a corretude do programa depende da ordem ou do tempo em que as threads são escalonadas. Se existe uma intercalação que leva o programa a um estado errado, tem uma race condition, mesmo que essa intercalação seja rara.
Isso soa parecido com data race, e por isso os dois termos vivem sendo trocados. Mas eles falam de coisas diferentes. Data race é sobre acessos à memória sem coordenação. Race condition é sobre a lógica do programa depender de tempo. A confusão é natural, porque no contador sem sincronização os dois estavam presentes ao mesmo tempo. Só que dá pra ter um sem o outro. E o caso mais instrutivo é o de uma race condition que não tem nenhum data race.
Uma race condition sem data race
Imagine uma conta bancária cujo saldo é lido e escrito sempre de forma protegida. Vamos usar Kotlin, porque a JVM deixa o exemplo limpo: cada operação individual é sincronizada, então não tem data race nenhum sobre o saldo.
class Conta(private var total: Int) {
@Synchronized fun saldo(): Int = total
@Synchronized fun debita(valor: Int) { total -= valor }
}
fun sacar(conta: Conta, valor: Int) {
if (conta.saldo() >= valor) { // checa
conta.debita(valor) // age
}
}
Código 7-2: Cada acesso ao saldo é sincronizado, mas o saque ainda tem uma race condition.
Tanto saldo() quanto debita() são @Synchronized. Nenhuma thread lê o saldo
enquanto outra está no meio de uma escrita. Um detector de data race passa esse
código sem uma reclamação sequer, porque não existe acesso desprotegido à
memória.
E mesmo assim sacar está errado. O problema está entre as duas linhas. A função
checa o saldo, e só depois debita, e nada garante que o saldo continua o mesmo
entre uma coisa e outra. Suponha uma conta com saldo 100, e duas threads
tentando sacar 100 cada, ao mesmo tempo:
| Passo | Thread A | Thread B | saldo |
|---|---|---|---|
| 1 | saldo() → 100 | 100 | |
| 2 | saldo() → 100 | 100 | |
| 3 | 100 >= 100 ✓ | 100 | |
| 4 | 100 >= 100 ✓ | 100 | |
| 5 | debita(100) | 0 | |
| 6 | debita(100) | -100 |
As duas threads leram 100, as duas passaram no teste, e as duas debitaram. A conta terminou com saldo -100, sacando 200 de uma conta que tinha 100. Cada operação foi atômica. A sequência checa-então-age não foi, e é aí que a ordem de escalonamento decide se o programa está correto ou não.
Esse padrão tem nome, check-then-act, e é uma das formas mais comuns de race condition. Proteger cada acesso individualmente não resolve, porque o problema não está em nenhum acesso isolado. Está no fato de que a decisão tomada no passo 3 já estava velha no passo 5. O que precisa ser indivisível é o par checa-e-age inteiro, não cada metade.
Data race e race condition são independentes
Já temos os dois lados. O contador sem sincronização era data race e race condition. O saque é race condition sem data race. Falta o terceiro caso, e ele também existe: dá pra ter um data race que, na prática, não corrompe a lógica do programa, como um contador de estatísticas aproximado onde perder alguns incrementos não muda nenhuma decisão. Continua sendo comportamento indefinido em C, continua sendo uma prática ruim, mas não é uma race condition no sentido de levar o programa a um estado logicamente errado.
O que fica: eliminar data races é necessário, mas não é suficiente. Um programa pode estar livre de data races, com todos os acessos devidamente protegidos, e ainda assim produzir um saldo negativo porque a lógica dele depende de uma ordem que o escalonador não garante.