Em setembro de 1979, Leslie Lamport publicou na IEEE Transactions on Computers um artigo de cinco páginas com título técnico: How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs. O texto, hoje considerado seminal, estabelecia uma definição precisa do que significa um sistema multiprocessador "fazer a coisa certa": cada processador parece executar as instruções na ordem em que foram escritas, e o resultado global parece ser alguma intercalação válida dessas execuções individuais. Lamport chamou essa propriedade de sequential consistency (consistência sequencial). É a propriedade que intuitivamente esperamos de qualquer computador concorrente — e que, descobriu-se rapidamente, é tão cara de implementar em hardware moderno que praticamente nenhuma CPU em uso comercial a oferece por padrão.
Esse é o ponto onde a engenharia de concorrência colide com a física. Para tornar processadores rápidos, fabricantes adicionaram caches hierárquicas, store buffers, execução fora de ordem, prefetching especulativo. Compiladores, do seu lado, reordenam instruções para melhor uso de pipeline e caches. O resultado conjunto é que o que você escreve no código não corresponde literalmente ao que executa. Em programa single-thread, a ilusão é mantida: o compilador e a CPU garantem que o efeito final visível àquela thread é o mesmo como se as instruções rodassem em ordem. Em programa multi-thread, a ilusão quebra. Outras threads podem ver as escritas em ordem diferente da que aconteceram. Loads e stores aparentemente independentes, da perspectiva de outra thread, podem aparecer reordenados.
Memory models são o contrato formal entre programadores e o sistema (hardware + compilador + linguagem) sobre o que se pode e o que não se pode assumir. Cada hardware (x86, ARM, POWER) tem um memory model. Cada linguagem moderna define seu próprio (Java desde 1995, refinado em 2004; C++11; Go atualizado em 2022; Rust desde sempre) que abstrai o hardware. Sem internalizar memory model, lock-free é como dirigir vendado: você pode ter sorte, mas eventualmente bate.
Este conceito é denso e tem reputação merecida de ser onde programadores experientes se sentem inseguros. A boa notícia: você não precisa do memory model formal para escrever a maior parte do código concorrente. Mutex e channels (vistos nos conceitos 06 e 08) garantem que o memory model seja honrado para você. Mas se você escreve estruturas lock-free, otimiza hot paths, ou debugga "bug que aparece em ARM mas não em x86", esse conceito é não-negociável.
Por que reordenamento existe
Programa single-thread tem uma semântica simples: instruções executam em ordem do código. Memory models partem de uma observação surpreendente — essa simplicidade é uma ilusão sustentada por compilador e hardware, e em multi-thread, a ilusão pode quebrar visivelmente.
Reordenamento pelo compilador
O compilador moderno (LLVM, GCC, Roslyn, Go gc, JIT da JVM) faz transformações que mudam ordem de instruções para melhorar performance: vetorização, register allocation, dead code elimination, common subexpression elimination, scheduling. Considere:
// Você escreve:
int x = 0, y = 0;
x = 1;
y = 2;
// Compilador pode emitir (em assembly):
y = 2;
x = 1;
Para a thread que executa, isso é equivalente — nenhuma instrução depende de outra, e o efeito final é o mesmo. Mas para outra thread observando essas variáveis, a ordem em que elas mudam pode ser diferente da ordem do código fonte. Em single-thread, ninguém nota. Em multi-thread, vira bug invisível em revisão de código.
Reordenamento pela CPU
CPUs modernas (qualquer x86, ARM, POWER de 2010 em diante) fazem execução out-of-order. Instruções entram em ordem do código, mas são despachadas para unidades de execução assim que suas dependências estão resolvidas. Se uma instrução está esperando uma leitura de cache miss, instruções subsequentes podem executar antes. Para o programa, o efeito final visível na mesma thread é preservado. Para outras threads, novamente, ordens diferentes podem aparecer.
Caches e store buffers
Cada core moderno tem cache L1 privada (típica 64 KB) e store buffer — um buffer pequeno que segura escritas pendentes antes de propagar para cache compartilhada. Quando core A escreve em uma variável, a escrita primeiro vai para o store buffer de A, depois é propagada para cache compartilhada (via protocolo de coerência MESI), depois visível para core B. O delay típico é dezenas de nanossegundos. Em escala humana é instantâneo; em escala de CPU, é uma janela onde threads veem estados diferentes.
Sequential consistency — o ideal de Lamport
Lamport definiu em 1979: um sistema é sequencialmente consistente se "o resultado de qualquer execução é o mesmo como se as operações de todos os processadores fossem executadas em alguma ordem sequencial, e as operações de cada processador individual aparecem nessa sequência na ordem especificada por seu programa".
Em outras palavras: existe alguma intercalação total das instruções de todas as threads, consistente com a ordem de programa de cada uma, que explica o resultado observado. SC é forte: nenhuma reordering visível, nenhum truque. É o modelo que naturalmente esperamos quando pensamos em multi-thread.
Custo: SC força barreiras de memória implícitas em quase toda
operação. Hardware moderno é muito mais rápido sem essas barreiras;
por isso, x86 oferece quase SC mas não exatamente, e ARM
oferece um modelo bem mais relaxado. Linguagens modernas oferecem SC
como opção (default para std::atomic em C++,
volatile em Java, sync/atomic em Go) com a
ressalva de que você pode escolher modelos mais relaxados (e mais
rápidos) quando entender as consequências.
Modelos de hardware — x86 vs ARM
Cada arquitetura define seu próprio memory model. Vale conhecer pelo menos os dois mais relevantes em 2026.
x86 / x86-64 — TSO (Total Store Order)
O modelo de Intel e AMD é razoavelmente forte. Stores nunca
são reordenados em relação a outros stores (se thread A
escreve x = 1; y = 2;, todo observador vê x=1 antes de
y=2, ou ainda x=0 e y=0). Loads não são reordenados em
relação a outros loads. Mas loads podem ser
reordenados antes de stores que aparecem antes deles no
código — o famoso bug do "store buffer forwarding". É a razão pela
qual padrões como Dekker's algorithm para mutual exclusion não
funcionam corretamente em x86 sem barreiras explícitas.
Para muitos algoritmos, TSO é "forte o suficiente" e código simples funciona. É uma armadilha porque programadores que só testam em x86 podem assumir TSO implicitamente, e o código falha quando portado para ARM.
ARM — modelo fraco
ARM (ARMv7, ARMv8) tem um modelo notavelmente mais relaxado. Quase qualquer reordering é permitido entre operações de memória, exceto dependências de dados óbvias. Stores podem passar stores. Loads podem passar loads. Para programador vindo de x86, parece caos.
Em troca, ARM oferece instruções explícitas de barreira: DMB
(Data Memory Barrier) e variantes. Operações atômicas em ARMv8 (LDAR,
STLR — load-acquire, store-release) carregam ordering em sua própria
semântica.
Em 2026, com cloud em ARM virando dominante (AWS Graviton, Apple M-series em desenvolvimento, Ampere Altra) e mobile sendo essencialmente todo ARM, código que assume x86 está em risco crescente. Bug que "funciona no laptop" e falha em produção AWS Graviton é cenário cada vez mais comum.
Singleton com double-checked locking sem volatile
funciona em x86 99% do tempo (TSO impede a maior parte do
reordering problemático), e falha de forma sutil em ARM. Antes de
2026, isso era curiosidade acadêmica. Hoje, com Graviton em AWS
sendo padrão para muitas workloads de custo-otimização, é bug que
aparece em produção. Sempre use volatile
(Java/C#) ou std::atomic (C++) ou
sync/atomic (Go) ao escrever DCL — não confie em
TSO.
Happens-before — a relação fundadora
Lamport, em outro paper de 1978 (Time, Clocks, and the Ordering of Events in a Distributed System), introduziu a relação happens-before. É a base de todo memory model moderno. Definição:
- Se A e B estão na mesma thread e A vem antes de B no código, então A happens-before B.
- Se A é uma operação de "release" (lock unlock, atomic store with release semantics, channel send) e B é uma operação de "acquire" correspondente (lock lock, atomic load with acquire, channel receive), e A acontece antes de B em tempo real, então A happens-before B.
- Happens-before é transitiva: se A hb B e B hb C, então A hb C.
A propriedade crucial: se A happens-before B, então B vê todas as escritas que aconteceram antes de A na ordem do programa. É a única garantia de visibilidade que o memory model dá para multi-thread. Sem relação happens-before estabelecida, você não tem garantia de ver o que outras threads escreveram.
Concretamente: se você quer que thread B veja um valor escrito por
thread A, precisa estabelecer uma relação happens-before entre A e
B. Mecanismos típicos: lock + unlock; channel send + receive;
atomic store-release + atomic load-acquire; WaitGroup.Done
+ WaitGroup.Wait; conclusão de thread + observação da
conclusão (thread.join). Sem nenhum desses, B pode
simplesmente nunca ver as escritas de A.
// Pseudocódigo Java — sem happens-before, há race
class Servico {
private boolean pronto = false;
private Configuracao config;
void inicializar() {
config = carregar(); // (1)
pronto = true; // (2)
}
void usar() {
if (pronto) {
config.processar(); // pode falhar com NPE!
}
}
}
Sem sincronização, thread B vê pronto = true mas pode
ver config = null ainda. As duas escritas em A foram
reordenadas, ou propagadas em ordem diferente. Solução em Java:
marcar pronto como volatile. Volatile em
Java pós-JSR-133 estabelece happens-before entre write e read de
sua mesma variável, e por transitividade, B vê todas as escritas de
A que aconteceram antes da escrita em pronto.
Acquire-release — o vocabulário moderno
C++11 popularizou um vocabulário que organiza memory ordering em cinco modos. Em 2026, esse vocabulário é o padrão de facto em qualquer documentação de memory model — Java, C#, Go, Rust referem-se aos mesmos conceitos com pequenas variações.
Sequentially consistent (seq_cst) — a opção segura
Todas as operações marcadas como seq_cst formam uma ordem total
vista igualmente por todas as threads. É o modo mais forte e o
default em quase tudo: std::atomic em C++,
volatile em Java/C#,
atomic.LoadInt64/StoreInt64 em Go (Go só oferece
seq_cst como design — ver mais abaixo). Performance um pouco
menor; correção mais fácil de raciocinar.
Acquire
Um load com semântica de acquire impede que qualquer leitura ou escrita posterior na ordem de programa seja reordenada para antes dele. Pense em "uma vez que vi este valor, tudo que eu escrever ou ler depois acontece depois dele do ponto de vista do mundo externo". É a operação típica para "verificar se algo está pronto".
Release
Um store com semântica de release impede que qualquer leitura ou escrita anterior na ordem de programa seja reordenada para depois dele. "Tudo que eu já fiz antes deste store é visível para quem fizer acquire em uma operação subsequente neste local de memória". É a operação típica para "publicar que algo está pronto".
Acquire-release (acq_rel)
Para operações que fazem load+store atômico (CAS, fetch-add com retorno), a operação combina acquire (no load) e release (no store). É o que CAS oferece quando você precisa publicar e consumir simultaneamente.
Relaxed
Sem garantia de ordering em relação a outras operações. A operação é atômica (não vai dar tearing), mas pode ser livremente reordenada pelo compilador e CPU. Útil para counters monotônicos onde você só quer atomicidade — métricas que não dependem de outras escritas são um caso clássico.
// C++ — publish-subscribe com acquire-release
std::atomic<bool> pronto{false};
Configuracao config; // não atomic — protegido pelo padrão
// Thread A — produtor
void inicializar() {
config.carregar(); // (1) write não-atomic
pronto.store(true, std::memory_order_release); // (2) release
}
// Thread B — consumidor
void usar() {
if (pronto.load(std::memory_order_acquire)) { // (3) acquire
config.processar(); // (4) read não-atomic
}
}
// Garantia: (3) sincroniza com (2). Como (1) hb (2) por ordem de programa,
// e (2) sincroniza com (3), e (3) hb (4) por ordem de programa,
// tem-se (1) hb (4) por transitividade. (4) vê os efeitos de (1).
Esse é o padrão "publish/subscribe lock-free" canônico, e pode ser até 2-3x mais rápido que o equivalente com mutex em hot paths (ARMv8 LDAR/STLR são instruções de uma palavra).
Java Memory Model — JSR-133
Java foi a primeira linguagem mainstream com memory model formal (1995, refinado dramaticamente em 2004 com JSR-133, projetado por Bill Pugh, Doug Lea, Jeremy Manson e equipe). Antes de JSR-133, o modelo de Java tinha furos sutis que tornavam o famoso double-checked locking quebrado mesmo com volatile. Pós-2004, volatile ganhou semântica robusta:
-
Write em
volatileé release. Read emvolatileé acquire. Write hb subsequent read of same variable. -
synchronizedentry é acquire; exit é release. Estabelece happens-before entre threads que entram e saem do mesmo monitor. -
finalfields ganharam tratamento especial: depois que um construtor termina, o valor de campos final é visível para todas as threads sem sincronização adicional. -
Thread.start()hb início de execução da thread.Thread.join()retorna depois de tudo que a thread fez ter acontecido.
Para a maior parte do código Java, isso é tudo o que você precisa
saber. Pacote java.util.concurrent (Doug Lea) usa essas
garantias para oferecer estruturas thread-safe sem você precisar
pensar em ordering.
Go Memory Model — simplicidade deliberada
Russ Cox publicou em 2022 uma revisão significativa do Go memory model, depois de mais de uma década de comportamento implementado mas pouco documentado. A escolha de design de Go é simplicidade: oferecer apenas um modo de ordering (sequentially consistent) para operações atômicas, evitando que programadores tenham que decidir entre acquire/release/relaxed.
O Go memory model garante happens-before via:
-
sync.Mutex.Lock/Unlock— unlock hb subsequent lock. - Channel send hb correspondente receive.
-
sync.Once.Do— primeira chamada hb retornos subsequentes. -
sync.WaitGroup.Donehb retorno deWait. -
sync/atomicoperations são todas sequentially consistent — load, store, CAS, add. Estabelecem hb entre si quando operam no mesmo endereço.
Isso é menos flexível que C++ ou Rust, e custa um pouco em
performance em casos extremos. Em troca, é muito mais difícil
escrever bug de memory ordering em Go. Conhecer apenas
sync/atomic com semântica seq_cst é suficiente para
99% dos casos.
C# e .NET — Volatile e Interlocked
.NET tem memory model documentado desde .NET Framework, refinado ao longo dos anos. As primitivas:
-
volatilekeyword: read tem semântica de acquire, write de release. Garantia mais forte que C++volatile(que não é para concorrência). -
Volatile.Read/Volatile.Write: alternativa explícita (recomendada em código novo) que torna intenção clara. -
Interlocked.*: operações atômicas, com semântica seq_cst (similar a Go). -
lock/Monitor: estabelecem happens-before entre threads que entram e saem do mesmo lock. -
Thread.MemoryBarrier(): barreira de memória full explícita. Raramente necessária em código de aplicação.
Microsoft Stephen Toub e equipe da BCL têm uma série de blog posts excelentes sobre como o JIT lida com volatile e como otimizações podem ser surpreendentes. Para quem programa C# performance-critical, vale leitura.
O mesmo padrão nas três linguagens
Para concretizar acquire/release na prática, considere o padrão "publish initialization": uma flag indica que um objeto está pronto; threads que veem a flag podem usar o objeto com segurança. Sem memory ordering correto, pode haver use-before-init.
using System.Threading;
public class ServicoLazy {
private Configuracao? _config;
private int _pronto = 0; // 0 = não, 1 = sim
public void Inicializar() {
_config = Configuracao.Carregar();
// Volatile.Write tem semântica de release —
// garante que a escrita em _config seja visível
// antes desta escrita em _pronto.
Volatile.Write(ref _pronto, 1);
}
public Configuracao? Obter() {
// Volatile.Read tem semântica de acquire —
// garante que leituras subsequentes vejam
// os efeitos de escritas anteriores ao
// Volatile.Write correspondente.
if (Volatile.Read(ref _pronto) == 1) {
return _config;
}
return null;
}
}
// Para singleton lazy-init, use Lazy<T> — internamente
// usa Volatile + Interlocked corretamente.
Volatile.Read/Write são as primitivas
modernas idiomáticas. volatile keyword no campo
tem o mesmo efeito mas marca o campo inteiro. Para inicialização
lazy com semântica correta sem você precisar pensar, use
Lazy<T>.
import threading
class ServicoLazy:
def __init__(self):
self._config = None
self._lock = threading.Lock()
def inicializar(self):
with self._lock:
self._config = Configuracao.carregar()
def obter(self):
# Sob GIL, atribuições atômicas de referência são seguras.
# Mas operações compostas (verificar e usar) precisam de lock.
with self._lock:
return self._config
# Para inicialização única com semântica correta:
from functools import cache # thread-safe em CPython
@cache
def get_servico():
return Configuracao.carregar()
Em CPython com GIL, operações simples de bytecode são atômicas, mas operações compostas (check-then-act) ainda precisam de lock. O memory model de Python é menos formal que Java/Go — a documentação oficial é vaga sobre garantias precisas. Em Python sem GIL (3.13+ experimental), o modelo está sendo formalizado.
package main
import (
"sync"
"sync/atomic"
)
type ServicoLazy struct {
config atomic.Pointer[Configuracao]
}
func (s *ServicoLazy) Inicializar() {
cfg := CarregarConfiguracao()
// atomic.Pointer.Store em Go é seq_cst.
// Estabelece happens-before com qualquer Load subsequente.
s.config.Store(cfg)
}
func (s *ServicoLazy) Obter() *Configuracao {
return s.config.Load()
}
// Idiomático para singleton lazy:
var (
instancia *Configuracao
once sync.Once
)
func Servico() *Configuracao {
once.Do(func() {
instancia = CarregarConfiguracao()
})
return instancia
}
atomic.Pointer[T] (Go 1.19+) é a primitiva
moderna idiomática para essa classe de problema. Como Go só
oferece seq_cst, você não precisa pensar em acquire/release —
o ordering correto é automático. sync.Once é a
forma mais simples para singleton.
Memory barriers e fences
Em raros casos, você precisa de barreiras explícitas — instruções que forçam ordering. Hardware oferece:
-
x86:
MFENCE(full barrier),LFENCE(load barrier),SFENCE(store barrier). Em prática,LOCKprefix em qualquer instrução atômica também é full barrier. -
ARM:
DMB(data memory barrier) com variantesDMB ISH,DMB ST, etc.
Em código:
- C++:
std::atomic_thread_fence(memory_order). - C#:
Thread.MemoryBarrier(). - Java:
VarHandle.fullFence()(Java 9+). - Go: não há fence direto exposto; use
sync/atomicque internamente faz o que precisa.
Você raramente vai precisar disso. Se está cogitando, primeiro pergunte-se se uma operação atômica com semântica acquire/release apropriada não resolve. Em quase todos os casos, sim.
Bugs reais que memory model resolveu
Memory model não é discussão acadêmica. Bugs reais, em sistemas reais, foram causados por entendimento incompleto. Alguns casos históricos:
Double-checked locking pré-JSR-133
O padrão DCL para singleton lazy foi proposto em livros de Java nos
anos 1990 e dado como correto. Antes de JSR-133, ele não era
thread-safe em Java mesmo com volatile em
instance. Bill Pugh, em "Double-checked Locking is
Broken" Declaration (2001), documentou exaustivamente os
problemas. JSR-133 (2004) consertou ao redefinir volatile
com semântica forte. Em 2026, DCL com volatile funciona — mas a
lição é: memory model importa.
Spectre e Meltdown (2018)
Vulnerabilidades que exploravam execução especulativa em CPUs Intel para vazar dados de áreas protegidas. A causa raiz: CPUs especulam reads que não deveriam ter feito baseando-se em ordering implícito que não era garantido pelo modelo. Mitigações custaram performance significativa em produção. Lição: memory model em hardware tem furos que viram ataques.
Bug do gettimeofday em FreeBSD
Por anos, gettimeofday em FreeBSD podia retornar
valores não-monotônicos sob alta concorrência por causa de
ordering insuficiente em estruturas de dados internas
acessadas lock-free. Bug raro, dificílimo de reproduzir, exposto
apenas com instrumentação. A correção (2014-ish) envolveu memory
barriers explícitas.
Se você está escrevendo código concorrente sem usar
volatile, lock, ou primitivas atômicas, e tem
múltiplas threads trocando dados, há uma probabilidade enorme
de você ter bug de memory model — mesmo que ele "funcione no
seu laptop". A regra simplificadora: sempre use
primitivas que estabeleçam happens-before entre threads.
Confiar em "ordem do código" é o erro mais consistente em
concorrência.
Quando ir mais fundo — e quando parar
Memory model rewards depth. Hardware moderno é um sistema complexo com cache hierarchy, coherence protocols (MESI, MOESI, MESIF), out-of-order execution, store buffers, write combining. Você pode passar carreira inteira aprendendo. Para a maioria dos problemas, você não precisa de profundidade extrema — só precisa de hábitos corretos:
- Use mutex ou channels para a maior parte do código concorrente. Eles encapsulam memory ordering corretamente.
-
Para counters e flags simples, use
atomic/Volatile/volatile. Default seq_cst está certo. - Quando profilar mostrar contention, considere relaxar para acquire/release — mas só com benchmarks que provem ganho real e revisão de código competente.
- Teste em hardware diverso (x86 + ARM). Bug que aparece em ARM mas não em x86 é quase sempre memory model.
- Se está escrevendo lock-free, presuma que vai errar. Use bibliotecas testadas em vez de implementar.
Como praticar
-
Reproduza um reordering em ARM. Se você tem
acesso a Mac M-series, AWS Graviton, ou Raspberry Pi 4+, escreva
o programa Java/C++ canônico que demonstra
store buffer forwarding (uma thread escreve em
xdepois lêy; outra thread escreve emydepois lêx; em SC nunca ambas leem 0). Em x86, raro; em ARM, frequente. Compare execuções. -
Decompile código com volatile vs sem. Em C# ou
Java, escreva um método que escreve em campo regular e em campo
volatile. Use ILSpy/javap para olhar o bytecode/IL. Veja onde aparecem barreiras de memória. Compare. - Implemente publish-subscribe lock-free. Em C++ ou Go, escreva uma classe que permite produtor publicar um objeto complexo (com vários campos) atomicamente, e consumidor lê atomicamente. Use atomic.Pointer (Go) ou std::atomic (C++) com memory ordering apropriado. Faça benchmark contra mutex equivalente — em hot path, lock-free deve ser mensuravelmente mais rápido.
Referências para aprofundar
- livro The Art of Multiprocessor Programming — Maurice Herlihy & Nir Shavit (2ª ed., 2020).
- livro C++ Concurrency in Action — Anthony Williams (2ª ed., 2019).
- livro Java Concurrency in Practice — Brian Goetz et al. (2006).
- livro A Primer on Memory Consistency and Cache Coherence — Sorin, Hill, Wood (2ª ed., 2020).
- paper How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs — Leslie Lamport (IEEE TC, 1979).
- paper Time, Clocks, and the Ordering of Events in a Distributed System — Leslie Lamport (CACM, 1978).
- paper JSR-133: Java Memory Model and Thread Specification — Pugh, Manson, Lea et al. (2004).
- artigo Preshing on Programming — série sobre Lock-Free — Jeff Preshing.
- artigo Updating the Go Memory Model — Russ Cox (2022).
- docs The Go Memory Model.
- docs cppreference — std::memory_order.
- vídeo atomic<> Weapons: The C++ Memory Model and Modern Hardware — Herb Sutter (CppCon 2014).