Duas requisições chegam no mesmo endpoint “sacar 100€” com meio milissegundo de diferença. A conta tem 150€. As duas requisições leem esse saldo, as duas veem fundos suficientes, as duas aprovam, as duas subtraem 100€. A conta termina em -50€, e nenhum trecho do código deveria ter permitido isso. Isso é uma race condition: o resultado depende de qual operação termina primeiro, e “primeiro” nunca foi algo que o código decidiu.
O que causa uma race condition?
São necessários dois ingredientes ao mesmo tempo: estado mutável compartilhado, e mais de um caminho de execução tocando nele sem coordenação. Os caminhos podem ser threads, tarefas async, processos, ou dois servidores acessando a mesma linha do banco de dados. Remova qualquer um dos dois ingredientes e o bug desaparece. Uma única thread não consegue competir contra si mesma, e um estado imutável não é corrompido só porque foi lido duas vezes.
A forma clássica é check-then-act: você lê um valor, decide algo com base nele, depois age com base nessa decisão. Se outro caminho executar sua própria sequência ler-decidir-agir nesse intervalo, os dois tomam decisões válidas para um instante que, no momento da escrita, já passou.
Um contador compartilhado mostra o mecanismo puro, sem mais nada em volta. counter += 1 parece uma única operação. Na verdade são três: ler o valor atual, somar um, escrever o resultado de volta. Se duas threads executam essa sequência num contador que começa em 0, e as duas leem antes de qualquer uma escrever, as duas calculam 1 e as duas escrevem 1. Um incremento se perdeu. Sem exceção, sem linha de log.
| |
Execute algumas vezes. A diferença não é sempre a mesma, e essa é a segunda assinatura de uma race condition: ela não se reproduz de forma confiável, porque depende do scheduling, que muda de execução para execução e de máquina para máquina.
Como você corrige uma race condition com um lock?
Torne a sequência ler-modificar-escrever atômica, de modo que, assim que uma thread a inicia, nenhuma outra thread consiga se intercalar. Um lock faz isso permitindo que apenas uma thread o possua por vez. Qualquer outra thread que chame acquire() bloqueia até que quem o possui chame release().
| |
with lock: adquire o lock na entrada e libera na saída, inclusive quando uma exceção é lançada dentro do bloco. Você nunca fica com um lock que ninguém libera.
O erro que mantém o bug vivo mesmo depois de adicionar um lock: travar a escrita, mas não a verificação. Esta função de dosagem parece segura e não é:
| |
Duas threads podem passar pela verificação antes que qualquer uma das duas tome o lock. As duas somam sua quantidade, e o total passa do limite. O lock precisa envolver a verificação e a ação como uma única seção crítica, não só a linha que parece perigosa:
| |
Sob o GIL do Python, a versão estreita costuma funcionar por acaso. Por isso ela sobrevive à code review: passa em todos os testes no seu laptop, depois falha sob carga de produção ou no dia em que você migra para uma build free-threaded.
JavaScript async pode ter race conditions?
Sim. O JavaScript executa seu código em uma única thread no navegador e na execução principal do Node, mas o modo de falha volta a aparecer sempre que dois await podem terminar numa ordem que você não planejou. Uma caixa de busca é o caso típico. O usuário digita “gato”, uma requisição sai, ele continua digitando “gatinho”, uma segunda requisição sai. Se a primeira requisição for mais lenta, a resposta dela chega depois da segunda e substitui os resultados mais recentes pelos antigos.
| |
Cada chamada se marca com um ID antes de fazer await em qualquer coisa. Quando a resposta volta, ela só é renderizada se ainda for a requisição mais recente. AbortController é a outra ferramenta comum: ele cancela a requisição antiga de verdade, em vez de deixá-la terminar e descartar o resultado, o que também economiza a ida e volta pela rede.
A versão mais sutil não tem nenhuma chamada de rede, só uma variável compartilhada atravessando um await:
| |
As duas chamadas leem totalLength como 0 antes que qualquer um dos dois await se resolva. As duas calculam seu novo total a partir do mesmo ponto de partida desatualizado, e a segunda escrita apaga a primeira. A leitura e a escrita ficam em lados opostos de um ponto de suspensão, e qualquer coisa pode rodar durante uma suspensão. Se esse scheduling ainda não for óbvio, /pt/posts/javascript/async-await-e-event-loop-javascript/ explica o que o event loop faz em cada await. A correção: resolva o valor primeiro e incorpore-o ao estado compartilhado em um único passo síncrono (const len = await getPageLength(n); totalLength += len;), ou execute as somas em sequência em vez de usar Promise.all.
Como você previne race conditions em um banco de dados?
Mova o contador para uma linha e o intervalo entre check e act se alarga, porque agora ele abrange uma ida e volta até o banco de dados. Dois servidores de aplicação, cada um executando “ler saldo, validar, escrever novo saldo” na mesma linha, podem os dois ler 150€ e aprovar um saque de 100€. Esse é o exemplo inicial, no nível do SQL.
Duas soluções, com custos diferentes.
O lock pessimista toma o lock da linha antecipadamente. No PostgreSQL isso é SELECT ... FOR UPDATE:
| |
Uma segunda transação que tenta um SELECT ... FOR UPDATE na mesma linha bloqueia até a primeira dar commit ou fazer rollback. Correto, mas toda requisição concorrente para essa linha agora entra na fila atrás do lock. Isso custa latência sob contenção, e pode causar um deadlock se duas transações travarem linhas em ordem oposta. Mantenha a transação curta: aqui a linha é encontrada pela chave primária, que é uma busca por índice. Filtre por uma coluna sem índice e a busca se torna uma varredura, que estica cada transação enquanto as outras esperam (a mecânica está em /pt/posts/databases/como-funcionam-os-indices-de-banco-de-dados/).
O lock otimista pula o lock e verifica um conflito no momento da escrita, geralmente com uma coluna de versão:
| |
Se outra transação já atualizou a linha, version não é mais 7, zero linhas correspondem, e sua aplicação percebe isso e tenta de novo com o valor atualizado. Isso escala melhor quando conflitos são raros. Só funciona se a verificação de versão e a escrita acontecerem na mesma instrução. Leia a versão, depois execute um UPDATE separado baseado nela, e você reabre o intervalo que estava tentando fechar.
Qual é a diferença entre uma race condition e um deadlock?
As duas acabam classificadas como “bugs de concorrência”, e falham de formas opostas:
| Race condition | Deadlock | |
|---|---|---|
| O que acontece | A execução termina, com um resultado errado ou inconsistente | A execução para; nada termina |
| Causa | Acesso não sincronizado a estado compartilhado | Dois ou mais locks esperando um ao outro em ciclo |
| Como você percebe | Números que não fecham, dados “às vezes” errados | O programa ou a requisição trava |
| Correção típica | Locks, operações atômicas, ou remover o estado mutável compartilhado | Ordem consistente de locks, timeouts |
Uma correção para um pode introduzir o outro. Envolver um bug check-then-act em um lock elimina a race. Se esse código também toma um segundo lock, numa ordem diferente de outro caminho de código, você trocou uma corrupção silenciosa de dados por um travamento. Adquira os locks sempre na mesma ordem em todo lugar, e mantenha a seção travada o mais curta possível dentro do que a correção permite.
Como você detecta race conditions antes da produção?
Races raramente aparecem num teste de thread única rodando sozinho no seu laptop. Elas são uma causa comum de testes flaky, o tipo discutido em /pt/posts/testing/teste-unitario-vs-integracao-vs-end-to-end/. Um teste que falha uma vez em cada cinquenta sob carga da CI e passa toda vez que você roda de novo localmente merece suspeita antes de você descartá-lo como “flaky”.
O Go traz um detector embutido para isso. Compile ou teste com -race e o runtime instrumenta os acessos de memória, sinalizando qualquer caso em que duas goroutines tocam a mesma variável concorrentemente e pelo menos uma delas escreve.
| |
Ele só detecta races que realmente acontecem durante a execução, então quanto mais seus testes se aproximam de uma carga concorrente real, mais ele encontra. Também multiplica o uso de CPU e memória várias vezes, por isso ele pertence à CI, não ao binário de produção. Python e Java não têm nada embutido que se compare, mais um motivo para confiar em locks e operações atômicas de banco de dados em vez de testar para ganhar confiança.
Caches escondem o mesmo bug. Duas requisições que checam a mesma chave, veem um valor antigo e depois o definem são check-then-act de novo: um intervalo que o padrão cache-aside em /pt/posts/backend/como-funciona-cache-redis/ precisa levar em conta.
Antes de recorrer a um lock, porém, pergunte-se se o estado mutável compartilhado precisa existir. Um saldo atualizado apenas por uma única instrução atômica como UPDATE accounts SET balance = balance - 100 WHERE id = 42 AND balance >= 100 não tem nenhum intervalo entre check e act, porque o banco de dados faz as duas coisas num só passo. A correção mais barata para uma race condition é eliminar o segundo caminho que competia com o primeiro, então comece sua próxima revisão listando todo lugar que escreve nesse estado.