Lock-Free Queues Done Right
Acquire, release, and the bugs that only show up on real hardware.
Foundation · Concurrency, Memory & the Core Engine
Why this week
In week 1 you wrote an SPSC ring with Release on the publishing store and Acquire on the observing load, and it worked. This week is about knowing why it works, precisely enough to design the next queue without guessing, and about making it fast once it is correct.
Two failure modes drive everything. The first is ordering bugs: code that passes every test on your x86 laptop and corrupts data on an ARM server, because x86 hides most reorderings that the Rust memory model permits. The only defence is to reason in terms of the model — happens-before — rather than in terms of what one CPU happens to do. The second is cache-line traffic: a correct lock-free queue can still be slow if every push and pop drags the other side's index across the interconnect. Last week taught you what a line transfer costs. This week you remove most of them.
You will build a happens-before analyzer for message-passing traces, then a bounded multi-producer queue with per-slot sequence numbers. In the repo, the SPSC ring gets cached indices and batching, and the engine gets the MPSC queue it will use to collect orders from several gateway threads (week 13).
Day 1 — Happens-before, precisely
The memory model, not the machine
Sequenced-before, synchronizes-with, data races
'Memory Ordering' is the chapter this whole week rests on. Pay attention to the happens-before diagrams, to why Relaxed still gives a total modification order per variable, to release/acquire, and to the short section on fences. Re-read the 'common misconceptions' list at the end.
Rust adopts the C++20 memory model for atomics. Note which orderings are legal on loads, on stores and on read-modify-writes: an Acquire store or a Release load panics.
The model in four sentences:
- Within one thread, each operation is sequenced before the next.
- A
Releasestore (or stronger) synchronizes with anAcquireload (or stronger) that reads the value it wrote — or a later value in its release sequence, which, since C++20, only read-modify-writes extend. - Happens-before is the transitive closure of the two: if A is sequenced before a release store that synchronizes with an acquire load that is sequenced before B, then A happens before B.
- Two accesses to the same non-atomic location, at least one a write, from different threads, not ordered by happens-before, are a data race — undefined behaviour in Rust, regardless of what the hardware would do.
The message-passing (MP) litmus test is the pattern behind every queue you will write:
// Thread 0 // Thread 1
DATA = 42; if READY.load(Acquire) {
READY.store(true, Release); assert_eq!(DATA, 42); // guaranteed
}The guarantee comes from rule 2 and 3: the write to DATA is sequenced before the release store, which synchronizes with the acquire load that saw true, which is sequenced before the read. Weaken either side to Relaxed and the edge disappears; the read is now a data race even though "the flag was set".
data = flag = 0data.store(1, Relaxed); flag.store(1, Relaxed);
let r1 = flag.load(Relaxed); let r2 = data.load(Relaxed);
r1 == 1 && r2 == 0 happen?Nothing orders the two stores or the two loads, so the reader can see the flag before the data. x86 never shows it (TSO keeps stores in order and loads in order), which is exactly why this bug hides until you run on ARM.
A colleague makes the publishing store Release but leaves the flag load Relaxed, arguing the release is enough because it 'flushes' the data. What is wrong with that argument?
Day 2 — Build a happens-before checker
Vector clocks for one execution
Drill: message passing and its broken variants
The best way to make the four rules stick is to implement them. Tools like ThreadSanitizer and loom track happens-before with vector clocks: each thread keeps a counter per thread, recording how much of every other thread's history it is guaranteed to have seen.
- Each event ticks its own thread's entry.
- A release store remembers the storing thread's clock at that moment.
- An acquire load that reads from such a store takes the element-wise maximum of that remembered clock and its own.
- Event A (on thread
t) happens before event B exactly when B's clock has caught up with A's tick:clock_B[t] >= clock_A[t].
The drill feeds you executions as lists of events with explicit reads-from edges (which store each load observed), including the classic broken variants: Relaxed everywhere, Release paired with a Relaxed load, an acquire that read the initial value, a chain through a relay thread, and a reader that saw a later Relaxed store rather than the Release one.
Message-passing litmus with Acquire/Release
/// Orderings that are legal on a store (Relaxed, Release, SeqCst) or a load
/// (Relaxed, Acquire, SeqCst). The harness never builds an illegal pair.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Mo {
Relaxed,
Release,
Acquire,
SeqCst,
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Op {
/// Non-atomic write to plain memory location `loc`.
Write(usize),
/// Non-atomic read of plain memory location `loc`.
Read(usize),
/// Atomic store to atomic variable `var`.
Store(usize, Mo),
/// Atomic load of `var` that observed the value written by the event at
/// index `rf` in the trace (`None` = it saw the initial value).
Load(usize, Mo, Option<usize>),
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Ev {
pub thread: usize,
pub op: Op,
}
/// Happens-before for one execution. `events` are listed in an order that is
/// consistent with each thread's program order and with reads-from (a load
/// always comes after the store it read).
pub struct Trace {
// Suggested: one vector clock per event, plus each event's thread.
threads: Vec<usize>,
stamps: Vec<Vec<u32>>,
}
impl Trace {
/// Walk the events once, maintaining a vector clock per thread:
/// - every event ticks its own thread's entry;
/// - a Release/SeqCst store remembers its thread's clock;
/// - an Acquire/SeqCst load that read from such a store joins
/// (element-wise max) that remembered clock into its thread's clock.
/// Only the store a load actually read from counts — there are no
/// read-modify-writes here, so there are no release sequences to follow.
pub fn analyze(events: &[Ev]) -> Trace {
todo!()
}
/// Does event `a` happen-before event `b`?
pub fn happens_before(&self, a: usize, b: usize) -> bool {
todo!()
}
}
/// Every pair `(i, j)`, `i < j`, of plain accesses (Write/Read) to the same
/// location, from different threads, at least one a Write, where neither
/// happens-before the other. Sorted by `(i, j)`.
pub fn data_races(events: &[Ev]) -> Vec<(usize, usize)> {
todo!()
}
Thread 0 writes DATA, then stores FLAG = 1 with Release, then stores FLAG = 2 with Relaxed. Thread 1 loads FLAG with Acquire, sees 2, then reads DATA. Is that read ordered after the write?
Day 3 — What the hardware actually reorders
x86-TSO, ARM and SeqCst
Why your tests pass and your code is still wrong
There are two reorderers between your source and memory: the compiler and the CPU. The memory model constrains both. What each CPU does on its own:
- x86-64 (TSO): loads are not reordered with other loads, stores are not reordered with other stores, and a store is not reordered with an earlier load. The one relaxation is that a load may complete before an earlier store to a different address, because stores sit in a store buffer before they reach L1. Consequence: on x86,
Acquireloads andReleasestores compile to plainmovs. OnlySeqCststores cost something (xchg, ormovplusmfence). - ARMv8 / AArch64: almost any reordering of independent accesses is allowed.
Acquireloads becomeldar,Releasestoresstlr(ARMv8.3 adds the cheaperldaprfor Acquire);Relaxedbecomes plainldr/str. Graviton servers and Apple Silicon laptops will show you bugs x86 never will.
So an x86 test suite cannot catch a missing Acquire — but the compiler still can, because Relaxed lets it move your data read above the flag check on any architecture. "It works on x86" is never evidence.
The store buffer is also why SeqCst exists. In the store-buffering (SB) test, each thread stores to its own flag and then loads the other's. With Release/Acquire (and on real x86 hardware, with plain moves) both threads can read 0: each load completes while the thread's own store is still in its store buffer. Only SeqCst on all four operations forbids that outcome. Dekker-style mutual exclusion and some shutdown handshakes need this; queues almost never do.
x = y = 0x.store(1, Relaxed); let r1 = y.load(Relaxed);
y.store(1, Relaxed); let r2 = x.load(Relaxed);
r1 == 0 && r2 == 0 happen?Each store can sit in its core’s store buffer while the following load reads memory. This is the one reordering x86-TSO itself performs (store→load).
In the store-buffering test both threads store 1 to their own flag with Release and then load the other flag with Acquire. Can both loads return 0? What changes with SeqCst?
Jon builds a spinlock-based mutex, shows what goes wrong with Relaxed, then works through Acquire/Release and an example where only SeqCst gives the intuitive answer, testing with loom. Watch how he reasons about which values a load may see; that is the skill.
Day 4 — Making the SPSC ring fast
Cached indices and batching
Fewer line transfers per item
Starts from the textbook SPSC ring and removes cache-line traffic step by step: padding the indices, then caching the remote index. Note the measured throughput at each step and which change mattered most.
Your week 1 ring reads the other side's index on every operation: try_push loads tail, try_pop loads head. After week 4 those indices sit on separate lines, but every load of the remote index still has to fetch a line the other core keeps modifying. That is one coherence miss per item, tens of nanoseconds each.
Cached indices. The producer keeps a private copy of the consumer's tail, and only reloads the shared one when the cached value says the ring is full. The consumer does the same with head. When the ring is neither nearly full nor nearly empty, most operations touch only core-local data:
pub fn try_push(&mut self, v: T) -> Result<(), T> {
let head = self.head_local; // only we write head
if head - self.tail_cache == CAP {
self.tail_cache = self.shared.tail.load(Acquire); // pay for the miss rarely
if head - self.tail_cache == CAP {
return Err(v);
}
}
unsafe { self.shared.slot(head).write(v) };
self.head_local = head + 1;
self.shared.head.store(head + 1, Release);
Ok(())
}Note the &mut self: the producer handle now owns state, which is fine because there is exactly one producer, and the type system already says so.
Batching. Publishing head after every item means the consumer's next read of head misses for every item. Write N items, then publish once; read N items, then publish tail once. One line transfer now carries N items. The cost is latency for the first item in a batch, so batch what is already available rather than waiting to fill a batch: drain everything the consumer can see, process it, publish. This is the "smart batching" idea from the Mechanical Sympathy blog and the core of how the LMAX Disruptor amortizes its sequence reads.
Switch on cached indices, then batching, and compare the index-line transfers per item in the table; then move the two rates apart until the ring sits full (or empty) and watch one side go back to reloading the remote index on nearly every operation.
The technical paper is linked from the project site. Read the sections on the cost of locks and CAS, cache lines and false sharing, and batching effects. The design (single writer per sequence, pre-allocated ring, consumers that catch up in batches) is the template for your event bus.
Batching raises throughput but can add latency to the first item of each batch. For the engine's outbound event queue, what is your rule for when to publish: every item, every N items, or 'whatever is ready'? What happens to p99 latency under each rule when the market bursts?
Day 5 — Many producers: compare_exchange and Vyukov's queue
Per-slot sequence numbers
Drill: a bounded MPSC queue
The original description of the algorithm you implement today, in about 60 lines of C++. Note what each sequence-number comparison means, the cost (one CAS per enqueue and per dequeue in the MPMC version), and ask yourself what happens if a producer stalls halfway through an enqueue.
With several producers, head is no longer owned by one thread. Producers must claim a position, and two producers must never claim the same one. That claim is a compare-and-swap on the shared tail: read pos, try to swing tail from pos to pos + 1; if another producer got there first, the CAS fails and returns the current value to retry with.
Claiming is not publishing. Between a producer's successful CAS and its write of the value, the consumer must not read that slot. Vyukov's answer is a sequence number per slot:
seq == pos— the slot is empty and ready for the producer that claims positionpos.seq == pos + 1— the slot is full with the value pushed atpos(published withRelease).- After the consumer reads it, it sets
seq = pos + capacity: empty, ready for the next lap.
Details that matter:
- No ABA. Positions are monotonically increasing
usizes; a 64-bit counter at a billion operations a second wraps after roughly 580 years. compare_exchange_weakin a loop. It may fail spuriously (on LL/SC machines such as ARM without LSE atomics) but compiles to a cheaper instruction sequence there; on x86 both versions are alock cmpxchg.- Relaxed CAS on the tail is enough. The CAS only arbitrates ownership of a position. The data is published by the slot's
Releasesequence store and observed by the consumer'sAcquireload. - Capacity of at least 2. With one slot, "full at position p" and "empty for position p + 1" are the same sequence number.
- It is not strictly lock-free. If a producer is preempted between claiming position
pand publishing it, the consumer sees slotpas empty and cannot get past it, even if later positions are full. That is fine for an exchange where producers are pinned threads, and worth saying out loud in an interview.
Bounded MPSC queue with compare_exchange
use std::cell::UnsafeCell;
use std::mem::MaybeUninit;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
struct Slot<T> {
/// Vyukov sequence number. For position p (slot p & mask):
/// seq == p → empty: a producer may claim position p
/// seq == p + 1 → full: holds the value pushed at position p
seq: AtomicUsize,
value: UnsafeCell<MaybeUninit<T>>,
}
struct Queue<T> {
slots: Box<[Slot<T>]>,
mask: usize,
/// Next position a producer will claim (contended by all producers).
tail: AtomicUsize,
/// Next position the consumer reads (only the consumer touches it).
head: AtomicUsize,
}
unsafe impl<T: Send> Send for Queue<T> {}
unsafe impl<T: Send> Sync for Queue<T> {}
/// Many producers: `Producer` is `Clone`.
pub struct Producer<T> {
q: Arc<Queue<T>>,
}
/// One consumer: `Consumer` is not `Clone`, and `pop` takes `&mut self`.
pub struct Consumer<T> {
q: Arc<Queue<T>>,
}
impl<T> Clone for Producer<T> {
fn clone(&self) -> Self {
Producer { q: Arc::clone(&self.q) }
}
}
/// `cap` must be a power of two and at least 2 (with one slot, "full at
/// position p" and "empty at position p + 1" are the same sequence number).
pub fn mpsc<T: Send>(cap: usize) -> (Producer<T>, Consumer<T>) {
assert!(cap >= 2 && cap.is_power_of_two(), "capacity must be a power of two ≥ 2");
let slots = (0..cap)
.map(|i| Slot { seq: AtomicUsize::new(i), value: UnsafeCell::new(MaybeUninit::uninit()) })
.collect();
let q = Arc::new(Queue { slots, mask: cap - 1, tail: AtomicUsize::new(0), head: AtomicUsize::new(0) });
(Producer { q: Arc::clone(&q) }, Consumer { q })
}
impl<T: Send> Producer<T> {
/// Push `value`, or hand it back if the queue is full.
pub fn push(&self, value: T) -> Result<(), T> {
// TODO (Vyukov). Loop:
// pos = tail (Relaxed)
// seq = slots[pos & mask].seq (Acquire)
// diff = seq as isize - pos as isize
// diff == 0 → claim pos with tail.compare_exchange_weak(pos, pos + 1, ..):
// on success write the value, then publish seq = pos + 1 (Release)
// on failure someone else claimed pos — retry
// diff < 0 → the slot still holds last lap's value: full, Err(value)
// diff > 0 → another producer already took pos: reload tail, retry
todo!()
}
}
impl<T: Send> Consumer<T> {
/// Pop the oldest published value.
pub fn pop(&mut self) -> Option<T> {
// TODO: pos = head; if slots[pos & mask].seq (Acquire) == pos + 1,
// move the value out, free the slot for the next lap
// (seq = pos + cap, Release) and advance head. Otherwise None.
todo!()
}
}
impl<T> Drop for Queue<T> {
fn drop(&mut self) {
// Exclusive access (no other handle is alive): drop whatever is still
// published so values are not leaked.
let cap = self.mask + 1;
let mut pos = *self.head.get_mut();
loop {
let slot = &mut self.slots[pos & self.mask];
if *slot.seq.get_mut() != pos + 1 {
break;
}
unsafe { slot.value.get_mut().assume_init_drop() };
*slot.seq.get_mut() = pos + cap;
pos += 1;
}
}
}
In pop, why must the store that frees a slot (seq = pos + cap) be Release, and which load does it pair with?
An uncontended Mutex lock/unlock costs a couple of atomic operations, on the order of 10–25 ns. So what does a lock-free queue actually buy a trading system?
Day 6 — Testing code you cannot test by running it
loom, Miri and other machines
Exhaustive interleavings beat lucky runs
loom runs a test under many interleavings (exhaustively, up to an optional preemption bound) against a model of the C11 memory model, including some weak-memory behaviours of Relaxed/Acquire/Release. Read what it does not model: knowing which behaviours it never explores tells you what a green loom run cannot prove.
A lock-free queue that passes a million-iteration stress test on x86 has told you very little. Three tools give you real coverage:
- loom swaps
std::sync::atomicandUnsafeCellfor instrumented versions behind acfg(loom)and explores interleavings exhaustively. Keep loom tests tiny (two or three threads, two or three operations each); the state space explodes fast. - Miri (
cargo +nightly miri test) interprets your code, detects data races and many kinds of undefined behaviour inunsafe, and emulates some weak-memory effects. - A weakly ordered machine. Run the stress tests on AArch64 (a Graviton instance or an Apple Silicon laptop) as part of CI. It will catch ordering mistakes x86 hides.
Write the loom test plan for your MPSC queue: which two or three small scenarios (number of producers, capacity, operations) exercise the full/empty boundary, wrap-around and the claim-then-publish window? What does each assert?
Capstone
The repo work extends crates/sync again.
Cached indices for the SPSC ring. The producer handle (spsc::Producer) keeps its own copy of head and a cached copy of tail; the consumer handle (spsc::Consumer) keeps its own tail and a cached copy of head. Each side re-reads the shared index only when its cached copy says full (producer) or empty (consumer). In the repo both handles have taken &mut self since week 1, so they can hold this private state without any change to the API: the type system already records that each side has one owner. Keep the week 4 padding: shared head and shared tail on separate lines.
Batch push and pop. The week 5 tests fix a slice-based API: push_slice(&mut self, items: &[T]) -> usize and pop_into(&mut self, out: &mut [T]) -> usize (both for T: Copy, returning how many moved; a copy is at most two memcpys around the wrap point), plus pop_batch(&mut self, out: &mut Vec<T>, max: usize) -> usize, which works for any T. A closure-based shape (drain(&mut self, f: impl FnMut(T)), which reads everything visible, calls f, then publishes tail once) is a good alternative in your own code. Either way, publish once per batch, never once per item, and never wait for a batch to fill.
Bounded MPSC queue. The Vyukov design from the drill, as mpsc::channel(capacity) (rounded up to a power of two, minimum 2): a Clone Producer and a single Consumer whose try_pop takes &mut self. Three additions the drill skipped:
- Two ways to push.
try_pushclaims withcompare_exchangeand hands the value back when the queue is full, like the drill'spush. The repo'spushclaims with onefetch_add, which never fails, then waits (spinning briefly, then yielding) until its slot is free; it returnsErronly if the consumer has been dropped, so a producer never waits for space that will never come. - Padding. Put the contended
enqueuecounter and the consumer'sdequeuecounter on their own lines. - The slots. Adjacent slots share lines, so a producer publishing slot
i + 1invalidates the line the consumer is reading slotifrom. Padding every slot to a line removes that at a large memory cost; leaving it is often fine because the consumer trails the producers by more than a line. The reference leaves them unpadded. Measure both and keep the numbers.
How to think about the targets: the batched SPSC target is more than ten times the week 1 target, which is only reachable when most operations touch no remote line — if you are far below it, count the remote loads per item in your hot path. (The bench pushes slices of 256 u64s through a 4,096-slot ring.) The MPSC target is lower because every claim is a read-modify-write on one shared line, and with 4 producers that line is the bottleneck. The bench's producers call push: a fetch_add costs one line round trip per claim however many producers contend, while a compare_exchange loop loses most attempts under the same contention and pays a round trip for every retry (the reference measures push at about 1.7× try_push with four producers). When the queue is full, push spins briefly and then yields, so a waiting producer doesn't keep the consumer off a shared core. Check what moved, too: the tests require four producers' items to arrive exactly once and in per-producer order (w05_mpsc_four_producers_exactly_once_in_per_producer_order), and the MPSC bench asserts the sum of everything popped. A fast queue that drops one message in a billion is a broken exchange.
crates/syncFaster SPSC and a bounded MPSC queue
SPSC with cached indices and batching; a Vyukov-style MPSC that loses nothing under 4 producers.
cargo xtask test --week 5 # tests named w05_*
cargo xtask bench --week 5 # p50 / p99 / p99.9 on your machine
cargo xtask report --upload # update this page and your lab- All week 5 tests pass w05_*
- SPSC ring with batching, two threads w05.spsc_batched_throughputtarget ≥ 700.0M ops/s
- MPSC, 4 producers → 1 consumer w05.mpsc_throughputtarget ≥ 8.0M ops/s
Feeds into
- Week 13 routes orders from per-connection gateway tasks to the single engine thread over this MPSC queue, and execution reports back over SPSC rings.
- Week 30 chooses how consumers wait on these queues — busy-polling versus spin-then-park — and pins producers and consumers to cores.
- Week 34 applies the same happens-before reasoning to seqlocks and epoch-based reclamation, where readers never block the writer at all.