Pular para o conteúdo
Voltar para o acervo
Engenharia7 min de leituraPasso 13 de 22

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.

  1. Mutexum por vezResolve noventa por cento dos casos. É por onde se começa.
  2. Semáforono máximo NLimita recurso: no máximo dez chamadas simultâneas àquela API.
  3. Lock de leitura e escritaleitura dominaVários leitores, ou um escritor sozinho. Se a escrita for frequente, o custo extra não compensa.
  4. Operações atômicassem bloquearCompare-and-swap: leia, calcule, tente trocar. Sob muita disputa, todo mundo repete trabalho.
  5. Spinlockquase sempre erroSó quando a espera é de nanossegundos, como dentro de um kernel. Em aplicação, não.
Use o mais simples que resolve. Lock-free só com medição na mã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

Fala comigo

Dúvida sobre o artigo? Me chama no WhatsApp

Sem formulário e sem lista de e-mail. Se você discorda de alguma coisa que eu escrevi, ou quer contar como resolveu aí, a conversa é direta comigo.

Abrir conversa