Condições de corrida, mutexes, semáforos e monitores

Sincronizar é preservar uma regra compartilhada
Duas threads podem estar corretas quando observadas separadamente e ainda produzir um estado impossível quando suas etapas se intercalam. O problema não nasce apenas de “executar ao mesmo tempo”: ele aparece quando operações concorrentes acessam estado compartilhado sem um protocolo capaz de preservar sua invariante.
Considere um contador de vagas. A regra é simples: após duas reservas válidas, 5 deve se tornar 3. Porém, a expressão vagas = vagas - 1 pode envolver leitura, cálculo e escrita. Se duas threads leem 5 antes de qualquer uma gravar, ambas calculam 4 e ambas escrevem 4. Uma reserva foi aceita, mas desapareceu do contador.
Na aula de threads, concorrência e paralelismo, vimos por que threads do mesmo processo podem alcançar a mesma memória. Na aula de escalonamento de CPU, vimos que a ordem de execução muda por preempções, bloqueios e prioridades. Agora o foco é construir um resultado correto apesar dessas ordens possíveis.
Uma condição de corrida depende da ordem dos eventos
Existe uma condição de corrida quando o resultado correto depende de qual operação concorrente ocorre primeiro ou de como etapas se intercalam, sem que o programa imponha a ordenação necessária. O erro pode aparecer uma vez em milhares de execuções porque carga, cache, interrupções e escalonamento alteram o momento relativo das threads.
Uma data race é uma categoria mais específica: acessos conflitantes à mesma posição de memória, concorrentes e sem sincronização suficiente, com pelo menos uma escrita. A definição exata depende do modelo de memória da linguagem. Nem toda condição de corrida lógica exige uma data race.
Por exemplo, duas operações atômicas podem fazer individualmente “consultar saldo” e “retirar”, mas ainda permitir que duas threads aprovem a mesma última unidade entre a consulta e a retirada. Cada acesso isolado é atômico; a decisão composta continua vulnerável a uma corrida do tipo check-then-act.
Testar várias vezes e não observar erro não demonstra ausência de corrida. O conjunto de interleavings cresce rapidamente, e testes comuns visitam apenas uma fração dele.
Região crítica é definida pela invariante
Uma região crítica é o trecho que acessa estado compartilhado e precisa obedecer a um protocolo de sincronização. Ela não é necessariamente uma única linha nem todo o método. Seu limite deve abranger as etapas que, juntas, preservam a invariante.
No contador de vagas, proteger somente a escrita é insuficiente: as duas threads ainda podem ler o mesmo valor. A região correta inclui verificar disponibilidade, escolher a nova situação e confirmar a alteração como uma decisão indivisível para os demais participantes.
Uma solução de exclusão mútua busca três propriedades conceituais:
- exclusão: participantes incompatíveis não entram juntos na região protegida;
- progresso: se a região está livre, algum participante elegível pode avançar;
- espera limitada ou justiça definida: uma thread não deveria ser adiada indefinidamente quando o contrato promete progresso.
APIs reais oferecem garantias diferentes de justiça e ordem. Não presuma FIFO apenas porque existe uma fila interna. A especificação do mecanismo e a política de escalonamento determinam quem avança.
Mutex oferece propriedade exclusiva
Mutex vem de mutual exclusion. Quando uma thread o adquire, torna-se proprietária; as demais que tentam adquirir o mesmo mutex precisam esperar até a liberação. POSIX descreve no máximo um proprietário por mutex e associa aquisição e liberação à sincronização de memória.
Um padrão genérico é:
adquirir(mutex)
try
verificar a invariante
alterar todo o estado relacionado
finally
liberar(mutex)
O bloco finally, ou construção equivalente da linguagem, importa porque retorno antecipado e exceção não podem abandonar o mutex adquirido. Também é essencial usar o mesmo objeto de sincronização para todos os caminhos que acessam a mesma invariante. Dois mutexes diferentes não se enxergam, ainda que os nomes sejam parecidos.
O mutex protege estado, não apenas código. Dois métodos que alteram o mesmo conjunto de dados precisam compartilhar o protocolo. Em sentido oposto, usar um único mutex global para estados independentes cria contenção desnecessária.
Exclusão mútua também organiza visibilidade
Impedir entradas simultâneas é apenas parte do contrato. Processadores e compiladores possuem caches, buffers e regras de reordenação. Sem relações de sincronização definidas pelo modelo de memória, uma leitura em outra thread pode não observar as escritas na ordem intuitiva.
Operações corretas de aquisição e liberação estabelecem relações de ordenação e visibilidade previstas pela plataforma. Em termos informais, escritas concluídas antes da liberação tornam-se observáveis para quem adquire corretamente o mesmo mecanismo depois. A formulação exata é da linguagem ou do padrão; não invente sincronização com atrasos, variáveis comuns ou suposições sobre o hardware.
Isto diferencia:
- atomicidade: uma operação ou transição aparece como indivisível segundo um contrato;
- visibilidade: uma thread observa efeitos produzidos por outra;
- ordenação: existem limites para como ações podem ser percebidas entre threads;
- exclusão mútua: somente um proprietário entra em determinada região por vez.
Um mutex normalmente combina essas propriedades para o estado protegido. Uma operação atômica pode ser melhor para um contador simples, mas não transforma automaticamente uma transição com vários campos em operação atômica.
Granularidade decide contenção e complexidade
Um lock grosso cobre uma área ampla. É mais simples de raciocinar e reduz combinações de aquisição, porém serializa trabalhos que talvez fossem independentes. Um lock fino protege partes menores e pode ampliar paralelismo, mas aumenta o número de estados, ordens de aquisição e oportunidades de erro.
Mantenha a região crítica curta, mas correta. Evite dentro dela operações de duração imprevisível, como rede, disco, callback externo ou espera por usuário. Enquanto o proprietário aguarda, todos os concorrentes daquele mutex também ficam impedidos de avançar.
Reduzir o escopo não significa mover verificações essenciais para fora. Um padrão seguro costuma preparar dados privados antes, adquirir o mutex, validar novamente o estado compartilhado, aplicar a menor transição completa e liberar.
Semáforo representa permissões disponíveis
Um semáforo contador mantém um valor não negativo que representa permissões. Uma operação de espera adquire uma permissão e decrementa o contador; se o valor está em zero, o solicitante espera. A liberação devolve a permissão e incrementa o contador.
Com valor inicial 3, até três operações podem usar simultaneamente um conjunto de três recursos equivalentes: três conexões, três slots de processamento ou três equipamentos. O semáforo controla capacidade, não escolhe necessariamente qual objeto concreto foi entregue.
Um semáforo iniciado com 1 pode limitar a entrada a um participante e, por isso, lembrar um mutex. Os contratos não são idênticos:
- mutex possui propriedade e normalmente deve ser liberado pela thread que o adquiriu;
- semáforo representa contagem e pode permitir que uma atividade diferente devolva a permissão, conforme a API;
- mutex comunica intenção de proteger uma invariante exclusiva;
- semáforo comunica quantidade disponível ou sinalização.
Cada aquisição precisa corresponder a uma liberação válida. Liberar duas vezes infla artificialmente a capacidade; esquecer de liberar esgota permissões. Use finally, descarte estruturado ou outro mecanismo garantido pela linguagem.
Semáforo não substitui fila de trabalho quando também é necessário transportar dados, cancelar itens, encerrar produtores ou aplicar backpressure. Primitivas mais altas podem expressar melhor o protocolo completo.
Monitor reúne estado, exclusão e condições
Um monitor é uma abstração que encapsula estado compartilhado e operações que o manipulam sob exclusão mútua, geralmente acompanhadas de uma ou mais variáveis de condição. Em vez de espalhar lock por chamadores, o próprio componente controla como sua invariante pode ser consultada e alterada.
Linguagens usam o termo de formas relacionadas, mas não idênticas. Em Java, todo objeto possui um monitor associado e construções synchronized realizam aquisição e liberação. No .NET, Monitor fornece entrada, saída, espera e sinalização associadas a um objeto. O conceito acadêmico é mais amplo que qualquer classe específica.
Considere um buffer limitado. O monitor mantém coleção, capacidade e operações:
inserirsó prossegue enquanto houver espaço;retirarsó prossegue enquanto houver item;- ambas modificam a coleção sob o mesmo mutex;
- após alterar o estado, notificam quem pode ter se tornado elegível.
O valor está no encapsulamento: nenhum chamador deveria alterar diretamente a coleção e contornar as condições.
Variável de condição permite esperar por um predicado
Mutex responde “quem pode entrar agora”. Uma variável de condição ajuda a esperar até que um predicado sobre o estado protegido possa ser verdadeiro, como buffer não está vazio.
Esperar sem liberar o mutex causaria um impasse imediato: o consumidor segura o lock enquanto aguarda item, mas o produtor precisa do mesmo lock para inserir. A operação de espera, portanto, libera o mutex e bloqueia de forma atômica em relação ao protocolo; ao retornar, readquire o mutex antes de continuar.
O padrão correto usa um laço:
adquirir(mutex)
try
while predicado é falso
esperar(condição, mutex)
aplicar transição protegida
finally
liberar(mutex)
O while é necessário por pelo menos três motivos:
- mais de uma thread pode acordar e outra consumir o recurso primeiro;
- o estado pode mudar novamente antes de esta thread readquirir o mutex;
- especificações como POSIX permitem wakeups espúrios, em que a espera retorna sem uma notificação que torne o predicado verdadeiro.
Sinalizar uma thread pode bastar quando somente uma unidade de trabalho ficou disponível. Notificar todas pode ser necessário quando várias condições mudaram ou quando não se sabe qual espera é elegível, mas provoca mais despertares e disputa. A escolha depende da semântica da API e do predicado.
Notificação não armazena necessariamente um evento
Variável de condição representa espera por estado, não uma caixa de mensagens. Se ninguém espera no momento do sinal, a notificação pode não ficar registrada para um futuro participante. O estado protegido precisa carregar a verdade durável: item presente, fila encerrada, vaga disponível ou versão alterada.
Esse detalhe evita o erro de tratar signal como “crédito”. Quando a aplicação precisa acumular ocorrências, um semáforo, contador protegido, canal ou fila pode representar melhor os eventos.
Também evite verificar o predicado sem o mutex e só depois começar a esperar. Entre essas ações, outra thread pode alterar o estado e sinalizar; o sinal se perde e o primeiro participante dorme apesar de a condição já estar satisfeita. O protocolo combinado de mutex e condição fecha essa janela.
Operações atômicas resolvem transições estreitas
Primitivas atômicas como incremento, troca e compare-and-swap podem proteger estados pequenos sem um mutex explícito. Elas são fundamentais na implementação de estruturas concorrentes, mas exigem um modelo preciso de memória e progresso.
Trocar contador++ por incremento atômico resolve aquela atualização isolada. Não resolve automaticamente regras como “decrementar somente se houver vaga e também registrar o identificador da reserva”. Se dois campos precisam concordar, a invariante continua composta.
Código lock-free não significa “sem espera”, “mais rápido em toda carga” nem “fácil de manter”. Antes de escolhê-lo, meça contenção e verifique se uma biblioteca consolidada já oferece a estrutura necessária.
Inversão de prioridade nasce de uma dependência
Uma thread de alta prioridade pode bloquear em um mutex mantido por uma thread de baixa prioridade. Se trabalhos intermediários continuam preemptando a proprietária, a thread mais importante espera indiretamente pela menos prioritária. Isso é inversão de prioridade.
Algumas plataformas oferecem protocolos como herança de prioridade ou teto de prioridade. Eles podem elevar temporariamente a proprietária ou restringir aquisições segundo regras de tempo real. Não são correção automática para qualquer lock: exigem suporte, configuração e análise do caminho crítico.
A relação com escalonamento e prioridades é direta. Porém, inversão de prioridade não é o mesmo que starvation, e ambos não são sinônimos de deadlock. A próxima aula separará esses modos de ausência de progresso.
Reduzir compartilhamento pode ser a melhor sincronização
Nem todo problema pede mais locks. O desenho pode diminuir o estado mutável comum:
- manter dados imutáveis depois da construção;
- transferir propriedade em vez de compartilhar;
- particionar estado por chave ou trabalhador;
- usar filas ou canais para comunicar mensagens;
- produzir resultado local e combinar somente no final;
- usar estruturas concorrentes oferecidas pela plataforma.
Essas estratégias não eliminam toda coordenação: filas possuem limites e fechamento, partições podem precisar de consolidação e mensagens podem chegar duplicadas. Ainda assim, contratos de propriedade costumam ser mais fáceis de testar que acesso irrestrito à mesma memória.
Não confunda este nível com concorrência e isolamento em bancos de dados. Um mutex coordena threads sobre memória de um processo. Uma transação coordena mudanças persistentes observadas por sessões, processos e máquinas, com garantias próprias de recuperação e isolamento.
Detectores encontram execuções, não provam ausência
Ferramentas dinâmicas instrumentam acessos e procuram conflitos durante a execução. No ecossistema Go, por exemplo, testes podem ser executados com:
go test -race ./...
O detector só observa caminhos realmente executados. Cobertura incompleta, carga pouco realista ou arquitetura não suportada deixam interleavings sem exercício. O resultado “nenhuma corrida encontrada” significa que nenhuma foi detectada naquela execução, não que o programa está formalmente livre delas.
Combine ferramentas com revisão:
- liste o estado mutável compartilhado;
- escreva a invariante em linguagem simples;
- identifique todos os caminhos de leitura e escrita;
- marque qual mecanismo cria a ordenação necessária;
- verifique liberação sob erro, cancelamento e timeout;
- teste carga, falhas e encerramento;
- meça contenção antes de refinar granularidade.
Logs podem alterar temporização e esconder o defeito. Esse efeito, conhecido informalmente como Heisenbug, é mais um motivo para registrar eventos estruturados e usar detectores especializados.
Erros comuns
- “Uma linha de código é sempre atômica.” A expressão pode envolver várias operações de memória e regras do runtime.
- “Se não executa em paralelo, não há corrida.” Preempção e interleaving em uma CPU já são suficientes.
- “
volatilesubstitui mutex.” Visibilidade de um campo não torna uma transição composta mutuamente exclusiva. - “Proteger só a escrita resolve.” Leitura, decisão e escrita podem formar uma única região crítica.
- “Qualquer lock serve.” Participantes precisam usar o mesmo protocolo para a mesma invariante.
- “Semáforo binário e mutex são idênticos.” Propriedade e intenção contratual diferem.
- “Sinal recebido torna o predicado verdadeiro.” A thread precisa readquirir o mutex e testar novamente.
- “Dormir alguns milissegundos ordena threads.” Atraso não cria relação de sincronização garantida.
- “Lock mais fino é sempre melhor.” Granularidade menor aumenta combinações e risco de ordem inconsistente.
- “Detector sem alertas prova correção.” Ferramenta dinâmica depende dos caminhos executados.
- “Mutex de aplicação substitui transação.” Memória compartilhada e persistência distribuída pertencem a camadas diferentes.
O que você deve guardar
Condição de corrida surge quando o resultado depende de uma ordem concorrente que o programa não controla. Data race é um caso específico de acessos conflitantes sem sincronização adequada. A unidade de proteção deve nascer da invariante, não da aparência de uma linha.
Mutex expressa propriedade exclusiva e organiza visibilidade do estado protegido. Semáforo representa permissões e limita capacidade simultânea. Monitor encapsula estado e operações, combinando exclusão com condições. Variáveis de condição liberam o mutex durante a espera, readquirem antes do retorno e exigem reavaliar o predicado em laço.
Regiões críticas longas aumentam contenção; fragmentá-las sem preservar a transição quebra correção. Operações atômicas ajudam em estados estreitos, mas não resolvem invariantes compostas por si mesmas. Sempre que possível, reduza compartilhamento mutável e expresse propriedade com estruturas de nível mais alto.
Na próxima aula, veja as diferenças entre deadlock, starvation e livelock e como ordem de aquisição, espera circular e políticas de progresso alteram o comportamento do sistema.
Referências
- The Open Group — POSIX.1-2024: Thread Mutexes and Synchronization. Acesso em 4 set. 2026.
- The Open Group — POSIX.1-2024: Memory Synchronization and Semaphores. Acesso em 4 set. 2026.
- The Open Group — POSIX.1-2024:
pthread_mutex_lock. Acesso em 4 set. 2026. - The Open Group — POSIX.1-2024: Condition Wait. Acesso em 4 set. 2026.
- The Open Group — POSIX.1-2024:
sem_init. Acesso em 4 set. 2026. - Oracle — Java Language Specification: Threads and Locks. Acesso em 4 set. 2026.
- Microsoft Learn — Monitor Class. Acesso em 4 set. 2026.
- Microsoft Learn — Semaphore and SemaphoreSlim. Acesso em 4 set. 2026.
- Go Documentation — The Go Memory Model. Acesso em 4 set. 2026.
- Go Documentation — Data Race Detector. Acesso em 4 set. 2026.
- Linux Kernel Documentation — Locking. Acesso em 4 set. 2026.
- ACM, IEEE-CS e AAAI — CS2023: Operating Systems. Acesso em 4 set. 2026.
