MÓDULO 04 · CONCEITO 10 DE 14

Memory models e ordering

Por que threads veem memória diferente. Lamport, happens-before, acquire/release, sequential consistency, memory barriers — os bugs mais difíceis de reproduzir nascem aqui.

Tempo de leitura ~22 min Pré-requisito Conceitos 08 e 09 Próximo Padrões: worker pool, fan-out/fan-in, pipeline

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.

armadilha em produção

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:

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:

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:

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:

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.

C# — Volatile.Write/Read
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>.

Python — GIL serializa, mas operações compostas precisam de lock
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.

Go — sync/atomic.Pointer ou sync.Once
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:

Em código:

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.

heurística do sênior

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:

  1. Use mutex ou channels para a maior parte do código concorrente. Eles encapsulam memory ordering corretamente.
  2. Para counters e flags simples, use atomic / Volatile / volatile. Default seq_cst está certo.
  3. Quando profilar mostrar contention, considere relaxar para acquire/release — mas só com benchmarks que provem ganho real e revisão de código competente.
  4. Teste em hardware diverso (x86 + ARM). Bug que aparece em ARM mas não em x86 é quase sempre memory model.
  5. Se está escrevendo lock-free, presuma que vai errar. Use bibliotecas testadas em vez de implementar.

Como praticar

  1. 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 x depois lê y; outra thread escreve em y depois lê x; em SC nunca ambas leem 0). Em x86, raro; em ARM, frequente. Compare execuções.
  2. 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.
  3. 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

  1. livro The Art of Multiprocessor Programming — Maurice Herlihy & Nir Shavit (2ª ed., 2020). Cap. 3 (Concurrent Objects) e Cap. 7 (Spin Locks and Contention) tratam memory ordering com formalismo. Aplicável a qualquer linguagem.
  2. livro C++ Concurrency in Action — Anthony Williams (2ª ed., 2019). Cap. 5 é o melhor tratamento de memory ordering em livro. Aplica-se conceitualmente a Java, Rust, Go também.
  3. livro Java Concurrency in Practice — Brian Goetz et al. (2006). Cap. 16 cobre Java Memory Model após JSR-133. Doug Lea é coautor; é a fonte autoritativa para Java.
  4. livro A Primer on Memory Consistency and Cache Coherence — Sorin, Hill, Wood (2ª ed., 2020). Tratado acadêmico mas legível sobre memory consistency models de hardware. Para quem quer ir até o fundo.
  5. paper How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs — Leslie Lamport (IEEE TC, 1979). dl.acm.org/doi/10.1109/TC.1979.1675439 — O paper que define sequential consistency. Cinco páginas, fundadoras.
  6. paper Time, Clocks, and the Ordering of Events in a Distributed System — Leslie Lamport (CACM, 1978). microsoft.com/en-us/research/publication/time-clocks-ordering-events-distributed-system — Define happens-before formalmente. Premiado Turing.
  7. paper JSR-133: Java Memory Model and Thread Specification — Pugh, Manson, Lea et al. (2004). jcp.org/en/jsr/detail?id=133 — A revisão de Java Memory Model que consertou DCL e formalizou volatile. Documento técnico denso, formativo.
  8. artigo Preshing on Programming — série sobre Lock-Free — Jeff Preshing. preshing.com — Série excepcional de posts sobre acquire/release, memory barriers, lock-free patterns. Didático sem perder rigor. Comece pela tag "lock-free".
  9. artigo Updating the Go Memory Model — Russ Cox (2022). research.swtch.com/gomm — Cox documenta a revisão do Go memory model. Texto longo, didático, com decisões de design explicadas.
  10. docs The Go Memory Model. go.dev/ref/mem — Especificação oficial. Curta e precisa. Ler mais de uma vez ao longo da carreira.
  11. docs cppreference — std::memory_order. en.cppreference.com/w/cpp/atomic/memory_order — Referência da API C++ com exemplos. A documentação mais clara sobre os cinco modos.
  12. vídeo atomic<> Weapons: The C++ Memory Model and Modern Hardware — Herb Sutter (CppCon 2014). YouTube. Duas palestras de uma hora cada. Sutter explica memory ordering com profundidade rara em palestra. Vale assistir mais de uma vez.