FForge
← Labsconcurrency-05Concurrency●●●○

The store-buffer litmus test — see x86 reorder with your own eyes

concurrency/05_store_buffer_litmus.cppneeds: tools/bench.h, two P-cores2 steps
0/2 steps
what this lab teaches

The one reordering x86 permits, made visible, and the one memory order that forbids it. Two threads: Thread 1: x = 1; r1 = y; Thread 2: y = 1; r2 = x; Under sequential consistency, at least one thread must see the other's store: r1 == r2 == 0 is impossible. On real hardware each store sits in its core's STORE BUFFER for a while, and the following load reads the other variable from cache before that store is globally visible. Both loads can return 0. This is the "StoreLoad" reordering that TSO allows, and it is why memory_order_seq_cst exists. One million trials, three memory orders.

evidence to leave with
relaxed 8.8%, acq/rel 24.6%, seq_cst 0% of 1M trials show r1==r2==0; seq_cst stores are xchg
first, get the code
run this
$ git clone https://github.com/adit4443ya/debug_labs.git && cd debug_labs
then build it
run this
$ cd concurrency && ./build.sh
produces bin/05_store_buffer, bin/05_store_buffer.s
walk it, one step at a time
run this
$ ./bin/05_store_buffer 1000000
what it printed on the machine that wrote this lab
relaxed                    r1==r2==0 in 87632 of 1000000 trials
acquire/release            r1==r2==0 in 246171 of 1000000 trials
seq_cst                    r1==r2==0 in 0 of 1000000 trials
run this
$ grep -E '^\s+(xchgl|movl\s+\$1,)' bin/05_store_buffer.s | sort | uniq -c
what it printed on the machine that wrote this lab
      7  xchgl %ecx, _ZL1x.0(%rip)      <- seq_cst stores: xchg = store + full fence
      7  xchgl %ecx, _ZL1y.0(%rip)

The relaxed and acquire/release versions store with a plain `movl $1` (lab 03: on x86 a release store IS a plain store); only seq_cst uses xchg, which drains the store buffer before the next load can execute.

how to read it

- relaxed: 8.8% of trials saw both zeros. acquire/release: 24.6%. Both are "impossible" under SC and both happen constantly, because on x86 relaxed and acq/rel compile to the same plain mov: neither prevents a store from being buffered past a later load. (The different rates are timing artifacts of the two runs; the point is both are far from 0.) - seq_cst: 0 in a million. The xchg makes the store globally visible before the load issues, so at least one thread must observe the other's store. This is the Dekker / Peterson pattern: two flags, each thread sets its own then checks the other's. Mutual exclusion built on that pattern REQUIRES seq_cst (or an explicit full fence between the store and the load); acquire/release is not enough, no matter how it reads. - Everything else you write (producer/consumer, publish-then-flag, reference counts, queues) needs only acquire/release, because those patterns order a store BEFORE a load in the OTHER thread through the same variable, which is what acq/rel promises. The store-buffer pattern needs a store in each thread to be ordered before a load of a DIFFERENT variable in the same thread: only a full fence does that. THE RULE OF THUMB YOU CAN DEFEND IN AN INTERVIEW: "Does my correctness depend on two threads agreeing on the order of stores to two DIFFERENT variables?" If yes: seq_cst (or a seq_cst fence between the store and the load). If no: acquire on the load that consumes, release on the store that publishes. And on AArch64 the answer is the same and the instructions are the same (ldar/stlr are RCsc), so this test gives 0 there for acq/rel too; the relaxed row would still show reordering, and more of it.

the rules

S1. x86 reorders exactly one thing: a store followed by a load of a different address. Everything you cannot see on x86 you WILL see on Arm; everything you can see on x86 you can see everywhere. S2. seq_cst on x86 costs an xchg per store (~20-40 cycles); use it where S1 matters, acq/rel elsewhere. S3. Write litmus tests for your own lock-free code: a million trials across two pinned cores finds in seconds what code review misses.

take it further

1. Pin both threads to SMT siblings (2 and 3): the rate drops (shared store buffer? no: shared L1 and different timing) but does not reach 0. 2. Replace the seq_cst stores with relaxed stores plus atomic_thread_fence(seq_cst) between the store and the load: 0 again (`lock orl` from lab 03). 3. Cross-compile the litmus for aarch64 (tools/a64.sh) and run it under qemu: user-mode qemu executes the threads with a single-core model, so you will NOT see reordering there. That is a lesson about emulators, not about Arm. ============================================================

read the source · concurrency/05_store_buffer_litmus.cpp
#include "../tools/bench.h" #include <atomic> #include <thread> /* Thread 1: x = 1; r1 = y; Thread 2: y = 1; r2 = x; * Sequential consistency forbids r1 == r2 == 0. TSO (x86) allows it: each store sits in the * core's store buffer while the load reads the other variable from cache (StoreLoad reorder). */ static std::atomic<int> x{0}, y{0}; static std::atomic<long> round_{0}, done1{0}, done2{0}; /* main announces a round; each thread reports its own completion */ static int r1, r2; template <std::memory_order MO> static void t1(long trials) { for (long t = 1; t <= trials; t++) { while (round_.load(std::memory_order_acquire) != t) {} x.store(1, MO); r1 = y.load(MO); done1.store(t, std::memory_order_release); } } template <std::memory_order MO> static void t2(long trials) { for (long t = 1; t <= trials; t++) { while (round_.load(std::memory_order_acquire) != t) {} y.store(1, MO); r2 = x.load(MO); done2.store(t, std::memory_order_release); } } template <std::memory_order MO> static long run(const char *name, long trials) { long both_zero = 0; x = 0; y = 0; round_ = 0; done1 = 0; done2 = 0; std::thread a([&] { bench_pin(2); t1<MO>(trials); }), b([&] { bench_pin(4); t2<MO>(trials); }); for (long t = 1; t <= trials; t++) { x.store(0); y.store(0); round_.store(t, std::memory_order_release); /* start trial t */ while (done1.load(std::memory_order_acquire) != t || done2.load(std::memory_order_acquire) != t) {} if (r1 == 0 && r2 == 0) both_zero++; } a.join(); b.join(); printf("%-26s r1==r2==0 in %ld of %ld trials\n", name, both_zero, trials); return both_zero; } int main(int argc, char **argv) { long trials = argc > 1 ? atol(argv[1]) : 1000000; run<std::memory_order_relaxed>("relaxed", trials); run<std::memory_order_acq_rel>("acquire/release", trials); /* store=release, load=acquire */ run<std::memory_order_seq_cst>("seq_cst", trials); return 0; }
◇
Finish the lab: write the evidence note

A lab is done when you can say what it showed on your machine — not when you have read it.