Skip to content

Memória compartilhada e sincronização

O modelo mais antigo de concorrência é também o mais direto de descrever: você tem várias threads, elas compartilham a mesma memória, e coordenam-se lendo e escrevendo variáveis comuns. É direto de descrever e traiçoeiro de acertar, e a distância entre essas duas coisas é o assunto deste documento. Quase todos os outros modelos de concorrência existem, em alguma medida, para fugir da dor que se cataloga aqui. Entender a dor é entender a motivação deles.

O ponto de partida é a lição do nível de sistema: threads de um mesmo processo enxergam a mesma memória. Isso torna a comunicação entre elas trivial, uma escreve, a outra lê, sem cópia, sem protocolo. O problema é que o escalonador do kernel pode interromper qualquer thread a qualquer instante, inclusive no meio de uma operação que você imaginava indivisível. É dessa interrupção arbitrária que nascem os bugs.

A race condition, e por que x = x + 1 não é atômico

Section titled “A race condition, e por que x = x + 1 não é atômico”

O bug fundamental da memória compartilhada é a race condition: quando o resultado de um programa depende da ordem imprevisível em que as threads são escalonadas. O exemplo canônico é enganosamente simples. Duas threads incrementam um mesmo contador, cada uma mil vezes. No fim, você espera dois mil. Frequentemente, obtém menos.

A razão é que x = x + 1, que parece uma operação única, na verdade se decompõe em três passos no nível da máquina: ler o valor de x da memória para um registrador, somar um ao registrador, escrever o registrador de volta em x. Esses três passos não são atômicos. O kernel pode interromper a thread entre quaisquer dois deles. Imagine que x vale 41. A thread A lê 41. Antes de A escrever, o kernel a interrompe e roda a thread B, que também lê 41, soma, e escreve 42. Agora A retoma, ainda com o 41 que leu, soma, e escreve 42. Dois incrementos aconteceram, mas x avançou de 41 para 42, não para 43. Um incremento se perdeu.

O que torna isso especialmente perverso é que a race condition é intermitente. Ela só se manifesta quando a interrupção cai na janela exata entre a leitura e a escrita, e essa janela é minúscula. O programa passa nos testes, roda meses em produção, e um dia, sob carga alta, corrompe um saldo. Race conditions são a categoria de bug mais difícil de reproduzir justamente porque dependem de um timing que você não controla e mal consegue observar.

A ferramenta clássica para conter races é o mutex, abreviação de mutual exclusion. Um mutex é uma trava que só uma thread pode segurar por vez. Antes de tocar na memória compartilhada, a thread adquire a trava; ao terminar, libera. Se uma segunda thread tenta adquirir uma trava já ocupada, ela bloqueia, o kernel a põe para dormir, até que a primeira libere. O trecho de código entre adquirir e liberar é a seção crítica, e o mutex garante que apenas uma thread esteja dentro dela a cada instante.

Com um mutex protegendo o contador, os três passos de x = x + 1 deixam de poder ser entrelaçados: a thread B não consegue nem ler x enquanto A segura a trava, então o incremento perdido não acontece. A operação composta virou, na prática, atômica.

Mutex resolve a race, mas cobra dois preços. O primeiro é desempenho: uma seção crítica é, por definição, sequencial. Enquanto uma thread a ocupa, todas as outras que precisam dela esperam. Se você protege demais, serializa o programa e desperdiça os núcleos que tanto trabalho deu para conseguir usar, o que reencontra a Lei de Amdahl por outro caminho. O segundo preço é a disciplina: o mutex não protege nada por conta própria. Ele só funciona se todo código que acessa aquela memória adquirir a trava certa. Basta um caminho esquecer, e a race volta, agora ainda mais rara e mais difícil de achar. O mutex é uma convenção que o compilador, nas linguagens tradicionais, não obriga você a seguir.

Assim que você tem mais de uma trava, surge um novo modo de falha, e é pior que a race porque não corrompe dados: congela o programa inteiro. É o deadlock, o abraço mortal. A thread A segura a trava 1 e quer a trava 2; a thread B segura a trava 2 e quer a trava 1. Nenhuma das duas solta o que tem antes de conseguir o que falta, e as duas esperam para sempre. O programa não quebra, não erra, apenas para.

O deadlock é bem compreendido teoricamente. Ele só acontece quando quatro condições valem simultaneamente, as condições de Coffman: exclusão mútua (as travas são exclusivas), posse e espera (uma thread segura uma trava enquanto espera outra), não-preempção (uma trava não pode ser tomada à força de quem a segura) e espera circular (existe um ciclo de threads, cada uma esperando a próxima). Quebrar qualquer uma das quatro elimina o deadlock. A quebra mais usada na prática mira a espera circular: se todas as threads sempre adquirem as travas na mesma ordem global, por exemplo sempre a trava 1 antes da trava 2, o ciclo se torna impossível. É uma disciplina simples de enunciar e notoriamente difícil de manter num sistema grande, onde as travas se multiplicam e a ordem correta deixa de ser óbvia.

O deadlock tem primos mais sutis. No livelock, as threads não travam, mas ficam reagindo umas às outras num vaivém que nunca progride, como duas pessoas que se desviam para o mesmo lado repetidamente num corredor. Na inanição (starvation), uma thread nunca consegue a trava porque outras, mais gulosas ou mais sortudas no escalonamento, sempre passam na frente. Todos são falhas de liveness, a propriedade de que o programa continua fazendo progresso, e são notoriamente mais difíceis de testar do que falhas de correção, porque não produzem um resultado errado que você possa comparar com o esperado; produzem ausência de resultado.

Para casos simples, existe um caminho que dispensa o mutex: as operações atômicas. Os processadores modernos oferecem instruções que executam certas operações sobre a memória de forma indivisível por hardware, sem que o kernel possa interromper no meio. Um incremento atômico faz os três passos de x = x + 1 como uma coisa só, e resolve aquele exemplo inicial sem trava nenhuma.

A instrução mais importante dessa família é o compare-and-swap, o CAS. Ele recebe um endereço, um valor esperado e um valor novo, e faz, atomicamente: “se o que está neste endereço é igual ao valor esperado, troque pelo novo e me diga que deu certo; senão, não mexa e me diga que falhou”. Com CAS você constrói algoritmos ditos lock-free: em vez de travar, a thread lê o valor atual, calcula o novo, e tenta trocar com CAS; se alguém mudou o valor no meio-tempo, o CAS falha e ela tenta de novo, num laço. Estruturas de dados lock-free podem escalar melhor que as baseadas em mutex porque não põem threads para dormir, mas são notoriamente difíceis de acertar, cheias de casos-limite sutis como o problema ABA, e a recomendação sã é usar as implementações prontas da biblioteca padrão em vez de escrever a sua.

O modelo de memória: o inimigo invisível

Section titled “O modelo de memória: o inimigo invisível”

Há uma camada de dificuldade abaixo até das races, e ela pega de surpresa quem só pensou em escalonamento. Você imagina que, se a thread A escreve numa variável e depois numa flag, a thread B, ao ver a flag mudada, verá também a variável escrita antes. Num mundo de memória compartilhada ingênua, sim. No hardware real, não necessariamente.

Duas coisas conspiram contra essa intuição. Primeiro, cada núcleo tem suas próprias caches, e uma escrita feita por um núcleo pode demorar a ficar visível para outro, porque vive na cache do primeiro antes de propagar. Segundo, tanto o compilador quanto o processador reordenam operações de memória para ganhar desempenho, contanto que o resultado pareça correto para aquela thread isolada. Do ponto de vista de uma única thread, a reordenação é invisível; do ponto de vista de outra thread observando, a ordem em que as escritas aparecem pode ser diferente da ordem em que foram escritas no código.

É por isso que toda linguagem séria define um modelo de memória: o contrato que diz quais garantias de ordenação e visibilidade você tem, e o que precisa fazer para obtê-las. As operações atômicas, além de indivisíveis, funcionam como barreiras que forçam a propagação e proíbem certas reordenações. Em Java isso aparece na palavra-chave volatile e no capítulo do modelo de memória da especificação; em C++ e Rust, nos parâmetros de ordenação de memória (acquire, release, seq_cst) que você passa às operações atômicas. O ponto a reter, mesmo sem descer aos detalhes, é que “a outra thread vê o que eu escrevi” não é um dado gratuito da memória compartilhada. É uma garantia que custa e que você precisa pedir explicitamente. Ignorar isso produz bugs que desafiam a lógica, em que um valor “impossível” aparece porque duas escritas chegaram fora de ordem a um observador.

Sobre mutex e atomics, as bibliotecas constroem ferramentas mais expressivas. O semáforo generaliza o mutex para permitir até N threads simultâneas em vez de uma só, útil para limitar acesso a um recurso com capacidade fixa, como um pool de conexões. A variável de condição resolve a espera eficiente por um evento: em vez de uma thread ficar checando em laço se uma condição já é verdadeira, queimando CPU, ela dorme numa variável de condição e é acordada quando outra thread sinaliza que algo mudou, o padrão clássico de produtor e consumidor. O RWLock, ou lock de leitura e escrita, distingue leitores de escritores: permite muitos leitores simultâneos, já que ler não corrompe, mas exige exclusividade para escrever, um ganho real quando as leituras são muito mais frequentes que as escritas.

Todas são conveniências sobre a mesma base, e todas herdam a mesma fragilidade fundamental: dependem de que o programador as use certo, em todos os lugares, sem esquecer nenhum. É essa fragilidade estrutural, e não a falta de ferramentas, que motiva a busca por modelos onde o erro seja impossível por construção.

O modelo de threads e locks aparece de formas notavelmente diferentes conforme quanta ajuda a linguagem decide dar. Em C e C++, você tem as primitivas cruas, os pthreads, os mutex, os atomics, e nenhuma rede de proteção: nada no compilador impede você de acessar memória compartilhada sem a trava, e o resultado é comportamento indefinido que pode ser qualquer coisa. É poderoso e perigoso na mesma medida, e a segurança fica inteiramente por conta da disciplina de quem escreve.

Java oferece o mesmo modelo com mais ergonomia e uma rede parcial. A palavra-chave synchronized embute a aquisição e liberação do lock no escopo de um bloco, o que elimina a classe de bug de esquecer de liberar; o pacote java.util.concurrent traz estruturas concorrentes prontas e bem testadas; e o modelo de memória de Java foi um dos primeiros a ser especificado com rigor. Ainda assim, o compilador não impede você de compartilhar um objeto mutável entre threads sem sincronização. A rede é mais alta, mas tem furos.

A virada realmente interessante é Rust, porque ela move a garantia do tempo de execução para o tempo de compilação. O sistema de posse (ownership) e os traits Send e Sync codificam, no tipo de cada valor, se ele pode ser movido para outra thread e se pode ser compartilhado entre threads com segurança. Uma tentativa de compartilhar dados mutáveis entre threads sem a sincronização adequada simplesmente não compila. O resultado é o lema “fearless concurrency”: não é que Rust elimine a necessidade de mutex e atomics, ela oferece as mesmas primitivas, mas ela torna impossível esquecer de usá-los, porque o verificador de empréstimos recusa o código que os dispensaria indevidamente. A race condition de dados, aquela do contador perdido, deixa de ser um bug que você caça em produção e vira um erro de compilação que você conserta antes de rodar. É a demonstração mais clara de que a fragilidade do modelo de memória compartilhada nunca foi inerente ao problema, e sim à ausência de uma linguagem disposta a verificá-lo.

Ainda assim, mesmo com Rust, a memória compartilhada carrega uma complexidade essencial. É por buscar eliminá-la pela raiz, e não apenas verificá-la, que existem os modelos que trocam compartilhamento por troca de mensagens: se nada é compartilhado, não há o que travar.

Conceitos: race condition, seção crítica, exclusão mútua, atomicidade, deadlock, livelock, inanição, liveness, lock-free, modelo de memória, reordenação, visibilidade Primitivas: mutex, semáforo, variável de condição, RWLock, compare-and-swap, operação atômica, barreira de memória Condições de Coffman: exclusão mútua, posse e espera, não-preempção, espera circular Linguagens: C, C++, pthreads, Java, synchronized, volatile, Rust, Send/Sync, ownership, fearless concurrency