Introdução: Quatro linhas de código que desafiam a intuição ── O teste de litmus Store Buffering (SB)
Observe o seguinte programa C concorrente de apenas quatro instruções. Duas variáveis inteiras compartilhadas x e y são inicializadas com 0. Duas threads rodando em dois núcleos de CPU distintos executam cada uma uma escrita (Store) seguida de uma leitura (Load):
| |
Quais valores finais de (r1, r2) são possíveis quando ambas as threads terminam?
Pelo senso comum:
- Se a Thread 1 terminar antes da Thread 2, obtemos
r1 = 0, r2 = 1. - Se a Thread 2 terminar antes da Thread 1, obtemos
r1 = 1, r2 = 0. - Se ambas executarem a primeira linha (
x = 1ey = 1) antes da segunda, obtemosr1 = 1, r2 = 1.
Em qualquer intercalação concebível, pelo menos uma escrita de 1 deve ocorrer antes da leitura da outra thread; logo, o resultado r1 == 0 && r2 == 0 parece matematicamente impossível. No entanto, se você executar esse código em um processador moderno Intel/AMD x86-64, em um chip Apple Silicon (ARMv8) ou com um compilador C/C++/Rust otimizador (-O2), o resultado “impossível” r1 == 0 && r2 == 0 ocorre milhares de vezes por segundo! Isso não é um defeito de hardware, mas o resultado direto da reordenação de instruções sob a regra “As-If” de thread única.
Capítulo 1: A ilusão da Consistência Sequencial (Sequential Consistency)
Em 1979, o vencedor do Prêmio Turing Leslie Lamport formalizou o modelo mental intuitivo dos programadores como Consistência Sequencial (Sequential Consistency, SC): o resultado de qualquer execução é o mesmo que se as operações de todos os processadores fossem executadas em uma única ordem total global ($\prec_m$), e as operações de cada processador aparecessem nessa sequência na ordem do programa (Program Order, $\prec_p$) especificada pelo código-fonte.
Sob Consistência Sequencial, provamos em três linhas por que r1 == 0 && r2 == 0 é impossível:
Para que $r_1 = 0$, a leitura $L_1$ deve preceder a escrita $S_2$ ($L_1 \prec_m S_2$). Da mesma forma, $r_2 = 0$ exige que $L_2 \prec_m S_1$. Combinando isso com a ordem do programa $S_1 \prec_p L_1$ e $S_2 \prec_p L_2$, surge um ciclo temporal contraditório $S_1 \prec L_1 \prec S_2 \prec L_2 \prec S_1$. Contudo, como x e y são endereços distintos sem dependência de dados dentro de uma thread isolada, o hardware e o compilador quebram essa ordem global para ocultar a latência da memória.
Capítulo 2: Modelos de memória em hardware ── Store Buffers, protocolo MESI e filas de invalidação
2.1 Coerência de cache MESI e reordenação Store-Load via Store Buffer
Em uma CPU moderna de 4 GHz, acessar um registrador leva $0,25\text{ ns}$ (1 ciclo) e o cache L1 leva $\sim 1\text{ ns}$, mas sincronizar caches entre núcleos ou acessar a DRAM demora de $50\text{ a }100\text{ ns}$ (até 300 ciclos). Os núcleos mantêm a coerência das linhas de cache por meio do protocolo MESI (Modified, Exclusive, Shared, Invalid). Quando o Núcleo 0 quer modificar uma variável x no estado Shared, ele precisa transmitir uma mensagem Invalidate (Read-For-Ownership, RFO) aos outros núcleos e aguardar a confirmação (Ack).
Para evitar travar o núcleo por dezenas de ciclos a cada escrita, todos os processadores modernos (incluindo x86-64!) colocam uma fila FIFO privada chamada Store Buffer (buffer de escrita) entre a unidade de execução e o cache L1:
flowchart TD
subgraph Core0 ["Núcleo CPU 0 (Thread 1: x = 1; r1 = y;)"]
Exec0["Unidade de execução (Core 0)"]
SB0["Store Buffer privado do Núcleo 0<br/>[Pendente: x = 1] (Invisível para o Núcleo 1!)"]
L1_0["Cache de dados L1 (Core 0)<br/>y = 0 (Shared)"]
Exec0 -- "1. Grava x = 1 no Store Buffer e avança imediatamente" --> SB0
Exec0 -- "2. Lê imediatamente r1 = y do L1 (r1 = 0!)" --> L1_0
SB0 -. "3. Descarrega x = 1 no L1 apenas após o MESI Ack" .-> L1_0
end
subgraph Core1 ["Núcleo CPU 1 (Thread 2: y = 1; r2 = x;)"]
Exec1["Unidade de execução (Core 1)"]
SB1["Store Buffer privado do Núcleo 1<br/>[Pendente: y = 1] (Invisível para o Núcleo 0!)"]
L1_1["Cache de dados L1 (Core 1)<br/>x = 0 (Shared)"]
Exec1 -- "1. Grava y = 1 no Store Buffer e avança imediatamente" --> SB1
Exec1 -- "2. Lê imediatamente r2 = x do L1 (r2 = 0!)" --> L1_1
SB1 -. "3. Descarrega y = 1 no L1 apenas após o MESI Ack" .-> L1_1
end
L1_0 <== "Barramento de coerência MESI (Invalidate / RFO)" ==> L1_1
O Núcleo 0 coloca x = 1 em seu Store Buffer privado (invisível para o Núcleo 1) e lê imediatamente r1 = y (0) de seu cache L1. Simultaneamente, o Núcleo 1 coloca y = 1 em seu Store Buffer e lê r2 = x (0) de seu cache L1. Assim, a leitura (Load) ultrapassa a escrita anterior (Store) na visibilidade global: é a reordenação Store-Load! (Se o Núcleo 0 reler sua própria variável x, o Store Buffer Forwarding entrega diretamente x = 1 de seu buffer local, preservando a semântica de thread única).
2.2 Modelo forte x86-TSO vs. Arquiteturas fracamente ordenadas (ARMv8, RISC-V, PowerPC)
O modelo x86-TSO (Total Store Order) da Intel e AMD usa um Store Buffer FIFO estrito: nunca reordena Load-Load, Load-Store nem Store-Store, permitindo apenas a reordenação Store -> Load. Já ARMv8-A (AArch64 / Apple Silicon), RISC-V (RVWMO) e IBM PowerPC adotam modelos de memória fracamente ordenados (Weakly Ordered): graças à fusão de escritas não FIFO, leituras especulativas fora de ordem e filas de invalidação (Invalidate Queues), todas as quatro combinações (Load-Load, Load-Store, Store-Store, Store-Load) podem ser reordenadas pelo hardware!
| Modelo de memória em hardware | Load -> Load | Load -> Store | Store -> Store | Store -> Load | Atomicidade multicópia (MCA) |
|---|---|---|---|---|---|
| Consistência Sequencial (SC) | Não | Não | Não | Não | MCA completa |
| x86 / x64 (TSO) | Não | Não | Não | Sim (Store Buffer) | Other-MCA |
| ARMv8-A (AArch64) | Sim | Sim | Sim | Sim | Other-MCA |
| RISC-V (RVWMO) | Sim | Sim | Sim | Sim | Other-MCA |
| IBM PowerPC | Sim | Sim | Sim | Sim | Não-MCA |
Capítulo 3: Reordenação do compilador e o teorema DRF-SC ── Por que corridas de dados são Comportamento Indefinido (UB)
Mesmo em uma CPU x86, a passagem de mensagem sem sincronização data = 42; ready = 1; falha porque o compilador otimizador (gcc, clang, rustc) reordena livremente todos os quatro tipos de acessos à memória. Pior ainda: por meio de promoção a registrador (Register Promotion) e movimentação de código invariante de loop (LICM), o compilador transforma while (!ready) em uma única leitura seguida de um loop infinito! Além disso, volatile em C/C++ serve apenas para registradores MMIO e não emite barreiras de memória de CPU.
Em 1990, Sarita Adve e Mark Hill provaram o teorema DRF-SC (Data-Race-Free Sequential Consistency):
- Corrida de dados (Data Race): Duas threads acessam concorrentemente o mesmo endereço de memória, pelo menos um acesso é de escrita, pelo menos um é não atômico e não há relação Happens-Before ($\xrightarrow{hb}$) entre eles.
- O contrato DRF-SC: Se um programa é livre de corridas de dados (DRF), o compilador e o hardware garantem que toda execução se comportará exatamente com Consistência Sequencial (SC). Se houver uma única corrida de dados em C/C++ ou Rust
unsafe, todo o programa entra em Comportamento Indefinido (Undefined Behavior, UB).
Capítulo 4: A relação Happens-Before ($\xrightarrow{hb}$) e a sincronização Acquire-Release
No modelo formal de C++11 / C11 / Rust, quando a Thread 1 executa uma escrita atômica Release $W_{\text{rel}}(x, v)$ e a Thread 2 executa uma leitura atômica Acquire $R_{\text{acq}}(x, v)$ que lê esse valor ($W_{\text{rel}} \xrightarrow{rf} R_{\text{acq}}$), estabelece-se uma ponte entre threads chamada Synchronizes-With ($\xrightarrow{sw}$). O fecho transitivo da ordem interna da thread (Sequenced-Before, $\xrightarrow{sb}$) e $\xrightarrow{sw}$ define a relação Happens-Before ($\xrightarrow{hb}$):
$$W_{\text{rel}}(x, v) \xrightarrow{rf} R_{\text{acq}}(x, v) \implies W_{\text{rel}}(x, v) \xrightarrow{sw} R_{\text{acq}}(x, v)$$$$\xrightarrow{hb} \;\; \triangleq \;\; \left( \xrightarrow{sb} \;\cup\; \xrightarrow{sw} \right)^+$$Com memory_order_release e memory_order_acquire, a passagem de mensagens torna-se matematicamente segura:
| |
flowchart TD
subgraph Thread1 ["Thread Produtora (Core 0)"]
W_data["(1) data = 42 (Escrita não atômica)"]
W_flag["(2) flag.store(1, release) (Escrita atômica Release)"]
W_data -- "Sequenced-Before (sb)<br/>[A barreira Release impede que (1) desça abaixo de (2)!]" --> W_flag
end
subgraph Thread2 ["Thread Consumidora (Core 1)"]
R_flag["(3) flag.load(acquire) == 1 (Leitura atômica Acquire)"]
R_data["(4) assert(data == 42) (Leitura não atômica)"]
R_flag -- "Sequenced-Before (sb)<br/>[A barreira Acquire impede que (4) suba acima de (3)!]" --> R_data
end
W_flag == "Reads-From (rf) ⟹ Synchronizes-With (sw) estabelecido!" ==> R_flag
W_data -. "Por transitividade: Happens-Before (hb) garantido!" .-> R_data
Prova formal:
$$W(\text{data}=42) \xrightarrow{sb} W_{\text{rel}}(\text{flag}=1) \xrightarrow{sw} R_{\text{acq}}(\text{flag}=1) \xrightarrow{sb} R(\text{data})$$$$\implies W(\text{data}=42) \xrightarrow{hb} R(\text{data}) \implies R(\text{data}) = 42$$Capítulo 5: Espectro de ordenações de memória e instruções de CPU (MFENCE, DMB, LDAR/STLR)
C++ e Rust oferecem três níveis principais de ordenação:
Relaxed: Garante apenas atomicidade e ordem de modificação ($\xrightarrow{mo}$) de uma variável isolada, sem sincronização $\xrightarrow{sw}$ (ideal para contadores simples).Acquire/Release/AcqRel: Barreiras unidirecionais (semântica Roach Motel) que criam elos $\xrightarrow{sw}$, mas NÃO impedem a reordenaçãoStore -> Loadquando umstore(release)precede umload(acquire).SeqCst(padrão): Impõe adicionalmente uma única ordem total global $\xrightarrow{S}$ sobre todas as operaçõesSeqCste proíbe a reordenaçãoStore -> Load(essencial para evitarr1 == 0 && r2 == 0no testeSB).
| Operação atômica (C++ / Rust) | Instruções x86-64 (TSO) | Instruções ARMv8-A (AArch64) | Instruções RISC-V (RVWMO) |
|---|---|---|---|
load(relaxed) | mov eax, [rdi] | ldr w0, [x0] | lw a0, 0(a0) |
load(acquire) | mov eax, [rdi] (Custo zero em hardware!) | ldar w0, [x0] (Load-Acquire) | lw a0, 0(a0) + fence r, rw |
store(release, v) | mov [rdi], esi (Custo zero em hardware!) | stlr w1, [x0] (Store-Release) | fence rw, w + sw a1, 0(a0) |
store(seq_cst, v) | xchg [rdi], esi (ou mov + mfence) | stlr w1, [x0] (ordenado com ldar) | fence rw, rw + sw + fence rw, rw |
Barreira fence(seq_cst) | mfence (ou lock or [rsp], 0) | dmb ish (Data Memory Barrier) | fence rw, rw |
No x86-64, Acquire e Release compilam para simples instruções mov sem custo de hardware, enquanto store(seq_cst) emite xchg (prefixo LOCK implícito) ou mfence para drenar o Store Buffer. No ARMv8-A, as instruções dedicadas LDAR e STLR implementam Acquire-Release e SeqCst com eficiência cirúrgica sem exigir uma barreira global DMB ISH.
