Concorrência: race condition, lock e deadlock
Os bugs que não acontecem na sua máquina, não acontecem no teste, e acontecem em produção. Como reconhecê-los lendo código.
Bug de concorrência tem uma característica cruel: ele depende de tempo. De duas coisas acontecerem na ordem errada, o que é raro. Mas com um milhão de requisições por dia, raro é todo dia.
Ele não reproduz na sua máquina. Não aparece no teste. Aparece em produção, some, e volta em duas semanas.
A boa notícia é que a variedade é pequena. Praticamente todo bug de concorrência cai em um punhado de padrões, e dá para reconhecê-los lendo código.
Concorrência não é paralelismo
A distinção do Rob Pike, e ela é útil:
Concorrência é lidar com várias coisas ao mesmo tempo: estrutura do programa. Você organiza o código em tarefas que podem progredir de forma independente. Faz sentido mesmo num único núcleo: enquanto uma espera o disco, a outra trabalha.
Paralelismo é fazer várias coisas ao mesmo tempo: execução simultânea de fato, que exige mais de um núcleo.
Por que importa: um programa concorrente num núcleo só já resolve o problema de I/O, que é o caso da maioria das aplicações web. E o inverso é verdade: colocar mais núcleos num programa mal estruturado não acelera nada.
Race condition: o exemplo canônico
contador++Parece uma operação. São três: ler o valor da memória, somar um, gravar de volta.
Duas threads, valor em 10:
A lê 10. B lê 10. A soma → 11. B soma → 11. A grava 11. B grava 11.
Dois incrementos, e o contador foi de 10 para 11. Um sumiu. Sem erro, sem exceção, sem log.
Seção crítica é o trecho que mexe em estado compartilhado e precisa rodar sem interrupção. A solução é exclusão mútua: só um por vez ali dentro.
E o ponto que separa quem entende de quem decorou: o problema não é a variável compartilhada. É a variável compartilhada mutável.
Dado imutável nunca tem race. É por isso que linguagens funcionais sofrem menos, e é por isso que, para muitos casos, a melhor solução não é colocar lock, e sim parar de compartilhar. Passar mensagem em vez de compartilhar memória.
Antes de sair colocando mutex, pergunte: esse estado precisa mesmo ser compartilhado?
Os primitivos, e quando usar cada um
Mutex: só um por vez. É o que você usa em noventa por cento dos casos.
Semáforo: no máximo N por vez. Serve para limitar recurso: no máximo dez chamadas simultâneas àquela API.
Lock de leitura e escrita: vários leitores ao mesmo tempo, ou um escritor sozinho. Vale quando a leitura domina de verdade; se a escrita for frequente, o custo extra não compensa.
Spinlock: em vez de dormir, a thread fica girando e checando. Só faz sentido quando a espera é de nanossegundos, como dentro de um kernel. Em código de aplicação, spinlock é quase sempre erro.
Operações atômicas: o processador oferece instruções que fazem ler-modificar-gravar sem interrupção. A mais importante é o compare-and-swap (CAS): "se o valor ainda for X, troque por Y; senão me avise que mudou".
Com CAS você constrói algoritmos sem lock: leia, calcule, tente trocar, e se falhou, tente de novo.
Vantagem: nenhuma thread bloqueia outra. Desvantagem: sob muita disputa, todo mundo repete trabalho, e o desempenho pode ficar pior que um mutex simples.
E existe o problema ABA: o valor mudou de A para B e voltou para A. O CAS acha que nada aconteceu. Resolve-se com um contador de versão junto do valor.
A regra prática: use o mais simples que resolve. Mutex primeiro. Atômico quando o mutex virar gargalo comprovado por medição. Lock-free só se você tiver certeza de que precisa, e você provavelmente não precisa.
- Mutexum por vezResolve noventa por cento dos casos. É por onde se começa.
- Semáforono máximo NLimita recurso: no máximo dez chamadas simultâneas àquela API.
- Lock de leitura e escritaleitura dominaVários leitores, ou um escritor sozinho. Se a escrita for frequente, o custo extra não compensa.
- Operações atômicassem bloquearCompare-and-swap: leia, calcule, tente trocar. Sob muita disputa, todo mundo repete trabalho.
- Spinlockquase sempre erroSó quando a espera é de nanossegundos, como dentro de um kernel. Em aplicação, não.
Deadlock e a regra que resolve
Thread A tem o lock 1 e quer o 2. Thread B tem o lock 2 e quer o 1. Ninguém solta, ninguém anda.
Coffman mostrou que deadlock precisa de quatro condições simultâneas: exclusão mútua, segurar e esperar, ausência de preempção, e espera circular. Quebre qualquer uma e não existe deadlock.
Na prática você quebra a quarta, e a regra cabe numa frase:
Sempre adquira os locks na mesma ordem global.
Se todo mundo pega o lock 1 antes do 2, nunca existe ciclo. Defina a ordem (por endereço de memória, por nome, por id) e documente. É uma convenção que elimina uma classe inteira de bugs.
Segunda linha de defesa: timeout. Em vez de esperar para sempre, espere no máximo N e falhe. Você troca um travamento eterno por um erro tratável.
E isso vale para banco também. Duas transações atualizando as mesmas linhas em ordem invertida dão deadlock; o banco detecta, escolhe uma vítima e aborta. Mesma solução: ordem consistente de acesso às linhas, inclusive adicionando ORDER BY na seleção que precede o UPDATE.
Livelock é o primo: ninguém trava, mas ninguém progride. Duas pessoas num corredor tentando dar passagem uma para a outra.
Starvation: uma thread nunca consegue a vez porque outras sempre chegam antes. Acontece com prioridade mal configurada e com lock não justo.
O modelo de memória
Este é o nível que quase ninguém conhece e que explica bugs impossíveis.
O compilador e o processador reordenam instruções para otimizar. Numa única thread, o resultado é sempre o esperado. Entre threads, não.
Uma thread pode ver as escritas de outra numa ordem diferente da que foram feitas. Você inicializa um objeto e depois publica a referência; outra thread vê a referência e o objeto meio construído.
As ferramentas: volatile em Java e C# garante visibilidade: a leitura vai à memória em vez de usar um valor em registrador. Barreiras de memória impedem reordenação. E a relação "acontece antes" (happens-before) é o vocabulário formal para raciocinar sobre isso.
A recomendação honesta: se você está pensando em modelo de memória, provavelmente deveria estar usando uma abstração de nível mais alto: uma fila concorrente da biblioteca padrão, um ator, um canal.
O checklist de revisão de código
Este é o que mais pega bug na prática:
→ Esse estado é compartilhado? Se é, está protegido?
→ Todos os caminhos que o tocam estão protegidos, inclusive o de erro?
→ A ordem de aquisição dos locks é a mesma em todo lugar?
→ Existe operação de I/O dentro da seção crítica? Se existe, você está segurando um lock enquanto espera a rede, o que quase sempre está errado.
→ O que acontece se essa operação rodar duas vezes? Se a resposta for ruim, falta idempotência.
→ Tem timeout em toda espera?
→ O recurso é fechado no caminho de erro também?
→ Existe um teste que roda isso com concorrência de verdade?
A ferramenta que muda tudo
Detector de corrida. -race no Go, ThreadSanitizer em C e C++, ferramentas equivalentes em Java e Rust.
Eles instrumentam o programa e detectam acessos concorrentes não sincronizados mesmo quando o bug não se manifesta naquela execução. Acham em segundos o que você levaria semanas para reproduzir.
Se você fizer só uma coisa depois de ler isto: rode o detector de corrida na suíte de testes do seu projeto. Se aparecer alguma coisa, você acabou de encontrar um bug que estava esperando o momento certo.
Leia depois disto
- EngenhariaPasso 15Por que o seu serviço piora com o tempoO serviço sobe bem e piora ao longo de dias. Três causas explicam quase todos os casos, e as três têm sinais distintos.Ler artigo
- EngenhariaPasso 14Event loop ou thread por requisição: como escolherA escolha entre os dois modelos não é de gosto nem de moda. É determinada pelo perfil da sua carga, e tem uma conta que decide.Ler artigo
- EngenhariaPasso 12Qual estrutura de dados para qual problemaUm guia de decisão por problema, e não por estrutura. Você tem uma necessidade; qual estrutura responde, e a que custo.Ler artigo