Lock-free Queues & Memory Ordering
Your job system's workers all pull from one shared queue, and the audio callback has to receive play commands from the game thread without ever taking a lock. Both want a queue that never blocks: the job pool so workers don't wait their turn on a mutex, the audio callback because a lock can make it miss its deadline and the sound glitches. Lock-free queues deliver that, and they are also where concurrency bugs go to hide. This page builds the single-producer, single-consumer ring that needs no compare-and-swap, walks through Vyukov's multi-producer queue, and shows the two traps (ABA, and weak memory ordering) that make hand-rolled lock-free code dangerous, so you can pick the right queue, or the right library.
- Say what obstruction-free, lock-free and wait-free each guarantee, and why a CAS retry loop is lock-free but not wait-free.
- Write a single-producer, single-consumer ring that uses one acquire/release pair per direction and no CAS.
- Explain how Vyukov's MPMC queue uses per-cell sequence numbers, and why it isn't strictly lock-free.
- Recognize the ABA problem and choose between tagged pointers, hazard pointers and epochs.
- Explain why ordering bugs hide on x86 PCs and consoles and show up on Arm, and know when to ship a library instead.
01Why lock-free
Two concrete cases pull an engine toward lock-free queues, and one honest reason to think twice.
- The job pool. Every worker pulls tasks from a shared queue. Put a mutex on it and the workers serialize on that one lock exactly when you wanted them parallel (the scaling problem from the Job Systems tutorial).
- The audio thread. The audio callback runs under a hard deadline. If it takes a mutex and a lower-priority thread holds it, priority inversion (the high-priority callback stuck waiting on a lock a low-priority thread holds) can stall the callback past its deadline and the sound glitches. The control thread hands parameters to the audio thread through a lock-free ring, a fixed array used as a circular queue, instead[11].
- The counterpoint. Lock-free is genuinely hard to get right and harder to test. Often a mutex plus a ring buffer is fast enough. Reach for lock-free when you can prove the lock is the bottleneck, and prefer it for progress and tail-latency, not raw throughput.
Worked example
Say the audio device runs at 48 kHz with a 256-sample buffer. The callback has to fill each buffer before the device needs it: 256 / 48,000 = 5.3 ms, every 5.3 ms, for as long as the game runs.
If the callback blocks on a mutex while the thread holding it waits to be scheduled, that wait comes out of the same 5.3 ms. Miss it once and the device plays a gap. A lock-free ring removes the wait: pop either finds a command or returns at once.
02The memory model in one screen
A producer writes a slot, then bumps an index; the consumer sees the new index but reads the slot's old contents. Whether that can happen is decided by the memory ordering on the index. The Memory Model tutorial builds this in full; here is the working subset. Two ideas, kept separate: atomicity (an operation happens all-or-nothing) and (which writes another thread is guaranteed to see, and when).
- relaxed: atomic, but no ordering. Fine for a counter nobody synchronizes on.
- acquire / release: a release store (a store tagged
memory_order_release) synchronizes-with, that is, forms an ordering edge with, an acquire load (taggedmemory_order_acquire) that reads its value, so everything the writer did before the release becomes visible to the reader after the acquire[3][4]. This is the pair lock-free queues are built on. - seq_cst: a single total order over all seq_cst operations, on top of acquire/release. The safe default, the most expensive.
Atomicity and ordering are independent; even relaxed is atomic. And the acquire only synchronizes if it actually reads the value the release wrote, or a value that atomic read-modify-writes appended to that store's release sequence. Reading a stale earlier value forms no happens-before edge.
Worked example
The writer runs data = 42; then flag.store(1, release). The reader runs flag.load(acquire).
If the load returns 1, it read the release store's value, the edge exists, and a following read of data is guaranteed to see 42. If it returns 0, it read an older value: no edge, no guarantee, and the reader must not touch data yet. Make either side relaxed and even the 1 guarantees nothing about data.
03CAS and the progress taxonomy
Two workers try to take the next job at the same instant. With no lock, something has to decide which one gets it and tell the other to try again. (CAS) is the universal building block: atomically write a new value only if the location still holds the expected one. Loops of CAS are how multiple threads agree without a lock. But "lock-free" is a precise progress guarantee, and three levels get conflated.
- Obstruction-free: a thread makes progress if it runs alone (others paused). Allows livelock, threads endlessly undoing each other's work, under contention (several threads hitting the same location at once).
- : some thread always makes progress system-wide. No livelock; an individual thread can still starve.
- Wait-free: every thread finishes in a bounded number of its own steps, so no thread can starve. For multi-writer structures that guarantee is usually expensive; the SPSC ring in §4 gets it cheaply because each index has one writer.
Source: Herlihy & Shavit, The Art of Multiprocessor Programming[2].
Worked example
A shared job counter holds 5, and threads A and B both want to bump it. Both read 5 and both try CAS(expected 5, new 6). A's lands first: the counter is 6. B's CAS finds 6, not 5, so it fails and hands back the current value. B retries with CAS(expected 6, new 7) and succeeds: 7, no increment lost.
With a plain load and store instead, both would write 6 and one increment would vanish. The retry loop is lock-free: some thread's CAS always succeeds. It isn't wait-free: if other threads keep winning, B can keep retrying.
"Lock-free means no waiting" is false: it promises the system progresses, not your thread, which can lose a CAS forever. The "lock" is not a mutex (a lock only one thread can hold at a time): it refers to the program locking up. A mutex algorithm isn't lock-free because, as Preshing puts it, once a thread takes the mutex "your worst enemy could simply never schedule that thread again" and everyone stalls[1]. Scope every claim to the operation and the variant: Vyukov's MPMC (next) and moodycamel's queue are commonly called lock-free, yet neither is wait-free.
04SPSC: reach for this first
The audio thread's command queue has exactly one writer, the game thread, and one reader, the callback. Single-producer, single-consumer (SPSC: one thread pushes, one thread pops) is the queue to reach for first, and it needs no CAS at all. The reason: each index has exactly one writer. The producer owns the write index, the consumer owns the read index, so no two threads ever contend to modify the same atomic.
Correctness comes from one acquire/release pair per direction. The producer writes the slot, then does a release store of the write index to publish it; the consumer does an acquire load of the write index before reading the slot. Each side reads its own index with relaxed, because nothing else writes it. Boost documents its spsc_queue as "a wait-free single-producer/single-consumer queue"[15], and moodycamel's reader-writer queue makes the same claim: wait-free, no CAS loop, not counting the allocation when enqueue grows the queue[10].
#include <atomic>
#include <cstddef>
#include <type_traits>
template <typename T, size_t Capacity>
class SpscRing {
static_assert(Capacity > 0 && (Capacity & (Capacity - 1)) == 0,
"the & (Capacity - 1) mask needs a power-of-two Capacity");
static_assert(std::is_trivially_copyable_v<T>,
"slots stay live between reuses; pop copies out and never destroys");
// 64 is the x86 line size; Apple Silicon lines are 128 bytes, so pad to 128 when shipping there.
alignas(64) std::atomic<size_t> writeIndex{0}; // producer writes, consumer reads
alignas(64) std::atomic<size_t> readIndex{0}; // own cache line each: no false sharing
alignas(64) T storage[Capacity];
public:
bool push(const T& value) { // producer thread only
size_t writePos = writeIndex.load(std::memory_order_relaxed); // we own it
size_t readPos = readIndex.load(std::memory_order_acquire); // pairs with consumer release
if (writePos - readPos == Capacity) return false; // full
storage[writePos & (Capacity - 1)] = value;
writeIndex.store(writePos + 1, std::memory_order_release); // publish
return true;
}
bool pop(T& out) { // consumer thread only
size_t readPos = readIndex.load(std::memory_order_relaxed); // we own it
size_t writePos = writeIndex.load(std::memory_order_acquire); // pairs with producer release
if (writePos == readPos) return false; // empty
out = storage[readPos & (Capacity - 1)];
readIndex.store(readPos + 1, std::memory_order_release); // free the slot
return true;
}
};
use std::cell::UnsafeCell;
use std::sync::atomic::{AtomicUsize, Ordering};
// CAPACITY is a power of two; one writer per index means no CAS.
pub struct SpscRing<T: Copy, const CAPACITY: usize> {
write_index: AtomicUsize, // producer writes, consumer reads
read_index: AtomicUsize, // production: pad each onto its own cache line (crossbeam's CachePadded)
storage: [UnsafeCell<T>; CAPACITY], // UnsafeCell: slots are written through &self
}
// Sound to share across threads because the unsafe blocks below never touch
// the same slot concurrently; the contract is one producer thread, one consumer.
unsafe impl<T: Copy + Send, const CAPACITY: usize> Sync for SpscRing<T, CAPACITY> {}
impl<T: Copy, const CAPACITY: usize> SpscRing<T, CAPACITY> {
pub fn push(&self, value: T) -> Result<(), T> { // producer only
const { assert!(CAPACITY.is_power_of_two()) } // the & (CAPACITY - 1) mask depends on it
let write_pos = self.write_index.load(Ordering::Relaxed); // we own it
let read_pos = self.read_index.load(Ordering::Acquire); // pairs with consumer release
if write_pos - read_pos == CAPACITY { return Err(value); } // full
// Sound: this slot is outside the live range [read_pos, write_pos),
// and only this producer thread ever writes slots.
unsafe { *self.storage[write_pos & (CAPACITY - 1)].get() = value; }
self.write_index.store(write_pos + 1, Ordering::Release); // publish
Ok(())
}
pub fn pop(&self) -> Option<T> { // consumer only
let read_pos = self.read_index.load(Ordering::Relaxed); // we own it
let write_pos = self.write_index.load(Ordering::Acquire); // pairs with producer release
if write_pos == read_pos { return None; } // empty
// Sound: the acquire above saw the producer's release, so the slot's
// write happened-before this read, and the producer will not reuse
// the slot until read_index moves past it.
let value = unsafe { *self.storage[read_pos & (CAPACITY - 1)].get() };
self.read_index.store(read_pos + 1, Ordering::Release); // free the slot
Some(value)
}
}
Worked example
An 8-slot ring after a while: writeIndex = 44, readIndex = 36. The indices never wrap; only the slot number does, through & (Capacity - 1), which is & 7 here.
push computes 44 − 36 = 8 = Capacity, so the ring is full and it returns false. The slot it would have written, 44 & 7 = 4, is the one the consumer reads next (36 & 7 = 4). Once pop stores readIndex = 37, the next push sees 7 in flight and writes slot 4.
The widget runs it. The producer and consumer indices chase around the ring; no lock is ever held, and neither index is ever touched by two threads:
05Bounded MPMC: Vyukov
A job queue has every worker pushing and popping, so no index has a single owner. Many producers and many consumers can't each own an index, so you need an atomic read-modify-write: read, compute and write back as one indivisible step, as CAS does. Dmitry Vyukov's bounded MPMC (multi-producer, multi-consumer) queue, built on CAS, is a common choice, and the Job Systems tutorial uses it[5].
It's a fixed array of cells, each holding a payload plus an atomic sequence number, with two position counters. To enqueue: load the enqueue position, index the cell, and compare its sequence to the position. If they're equal the cell is free, so CAS the position forward and, on success, write the payload and release-store the cell's sequence to mark it ready. Dequeue mirrors this. The cell sequence is loaded with acquire and stored with release; the position counters use relaxed CAS.
Worked example
Four cells, each sequence starting at its own index: 0, 1, 2, 3.
Enqueue at position 0: cell 0's sequence is 0, equal to the position, so the cell is free. The producer CASes the position from 0 to 1, writes the payload and stores sequence = position + 1 = 1, "ready".
Dequeue at position 0: it wants sequence = position + 1 = 1, finds it, CASes its position to 1, reads the payload and stores sequence = position + 4 = 4. That's the enqueue position that maps to cell 0 on the next lap, so the producer at position 4 finds the cell free, and a stale producer still holding position 0 sees 4, not 0, so it reloads the position and tries again.
Vyukov says so himself: it is "not lockfree in the official meaning, just implemented by means of atomic RMW operations w/o mutexes"[5]. A producer that claims a slot and then stalls blocks consumers waiting on that slot. And the per-cell sequence isn't a full/empty flag; it's a lap/generation counter. Positions and sequences only ever increase (a 64-bit counter won't wrap in practice), so a stale thread's CAS or sequence check fails instead of matching a recycled value: no separate ABA tag is needed.
06The ABA problem
A lock-free stack can pass its tests for weeks before a crash dump shows its top pointing into freed memory. is the classic lock-free trap behind that. A CAS reads value A; another thread changes the location A → B → A (often by freeing a node and the allocator handing the same address back); the original CAS then succeeds because the bits match, even though the "A" is now a different object or freed memory: a false-positive CAS.
Three families of fix, and the trap inside the first one:
- Tagged / versioned pointers: pack a counter into spare pointer bits (or use double-width CAS, a CAS over two adjacent words such as x86's
CMPXCHG16B) so A-then-A still differs by the tag. - Hazard pointers: readers publish what they're dereferencing; a writer won't free anything a hazard slot names.
- Epoch-based reclamation: retire freed nodes into epoch buckets and free only after every thread has moved on. Rust's
crossbeam-epochships this[9].
A versioned pointer stops the CAS from matching a recycled value, but if your thread still holds a raw pointer to a node another thread freed, dereferencing it is undefined behavior (the language promises nothing about what happens) no matter what the tag says. Memory lifetime needs hazard pointers or epochs. The Memory Model tutorial walks the Treiber-stack race that produces ABA in detail; a Treiber stack is the classic lock-free stack that pushes and pops by CAS on its top pointer.
Worked example
Nodes X, Y and Z sit at addresses 0x1000, 0x2000 and 0x3000, with top = 0x1000. Thread A reads top = 0x1000 and next = 0x2000, then is paused. Thread B pops X and Y, frees both, and pushes a new node; the allocator hands it 0x1000 again.
A resumes with CAS(top, expected 0x1000, new 0x2000). Top holds 0x1000, so the CAS succeeds and top now points at Y's freed memory.
With a tag bumped on every successful CAS, A read (0x1000, tag 0), and B's pop, pop and push left (0x1000, tag 3). A's CAS expects tag 0, finds 3 and fails. A 16-bit tag would repeat only after 65,536 more updates.
Step through it. Thread A reads the top, pauses; thread B pops two nodes, frees them, and pushes a new node at the recycled address; A resumes and its CAS wrongly succeeds. Turn on tags: the CAS fails, and one more step runs the retry:
07x86 vs ARM: the latent bug
A queue runs for months on PC and PS5, then corrupts data on the first Switch build. The most dangerous thing about lock-free code is that a wrong version often passes every test on your desktop. x86's memory model (its rules for how one thread's memory operations can appear reordered to another) is TSO, total store order, and it's strong: in hardware, a plain load is already an acquire and a plain store already a release[7][8]. So relaxed where you needed acquire/release usually goes unpunished, but it is not correct: the hardware supplies the ordering, and the compiler is still free to reorder your other loads and stores around a relaxed atomic.
ARM and POWER are weak, meaning the hardware may reorder independent loads and stores unless told not to: those reorderings really happen. The Cambridge lowering table makes it concrete: an acquire/release on x86 compiles to a plain MOV, while on AArch64, the 64-bit Arm instruction set, it needs LDAR/STLR instructions[6]. So a missing acquire/release can stay invisible on your dev box and corrupt state on a Switch, a phone, or Apple Silicon: the bug was always there, and the weaker hardware exposes it.
Need a refresher on why memory operations get reordered?
Two layers reorder, and both must be controlled. The compiler reorders, fuses, and hoists loads and stores while optimizing; as far as it knows, single-threaded results are unchanged. Then the CPU reorders at runtime: a store sits in a store buffer before reaching cache, and a later load to a different address can complete first. Neither layer can tell that another thread is watching.
This is invisible in single-threaded code, which is why it stays hidden until two threads communicate through shared memory. An acquire/release pair (or a fence, an instruction that forbids reordering across it) is the instruction to both layers: do not move these operations across this point. On x86-TSO the hardware already gives that ordering on plain loads and stores, so the constraint is mostly a compiler barrier; on weakly-ordered AArch64 it lowers to real ordering instructions (LDAR/STLR).
The PS5 and Xbox Series are AMD Zen 2, the same x86-TSO model as a PC. A race that only appears there is timing exposing a bug that exists on x86 too; only genuinely weak targets (ARM) change the model itself. Test lock-free code on ARM hardware, or you are not testing the ordering.
Run the message-passing test: writer sets data = 42 then sets a flag; reader waits on the flag then reads data. Toggle the ordering and the hardware model:
08When not to go lock-free
You've profiled a queue, the lock shows up, and you're about to write your own lock-free version. The strongest practical advice in this tutorial: most of the time, don't hand-roll it. Lock-free correctness is subtle on weak hardware, hard to test because races hide, and easy to get wrong in reclamation.
The expert consensus is to use a proven implementation. The C++ Core Guidelines say not to use lock-free programming "unless you absolutely have to"; the alternative CP.100 lists is lock-free structures "implemented by others as part of some library"[12]. In practice: in C++ reach for moodycamel::ConcurrentQueue (MPMC) or ReaderWriterQueue (SPSC, wait-free)[10]; in Rust reach for crossbeam's ArrayQueue (bounded MPMC), SegQueue (unbounded), and crossbeam-deque (the Chase-Lev work-stealing deque, whose owner pushes and pops one end while idle threads steal from the other[13], weak-memory-correct, used under Rayon)[9][14]. The code in this tutorial is for understanding; ship the library.
09Pitfalls
A lock-free queue that passes its tests can still starve a thread, corrupt data on Arm, or run slower than the mutex it replaced. The usual causes:
10What's next
Next in the series: 3D Math for Games. The concurrency foundation is done, and the series moves to the math every system after it uses: vectors, matrices and quaternions, then IEEE-754 floating point, then the runtime spine (the game loop, the platform window, input) before the renderer.
This page fills the last gap in that foundation, alongside the Memory Model, Allocators, and Job Systems. The Audio tutorial reuses this SPSC ring to hand play commands to the audio callback. The full path is on the series hub.
- Jeff Preshing. "An Introduction to Lock-Free Programming." preshing.com. Lock-free as system-wide progress; the "lock" means lock-up, not mutex.
- Maurice Herlihy and Nir Shavit. The Art of Multiprocessor Programming. Morgan Kaufmann. The obstruction-free / lock-free / wait-free progress hierarchy and the ABA problem.
- Jeff Preshing. "Acquire and Release Semantics." preshing.com. The synchronizes-with edge a release store forms with an acquire load that reads it.
- cppreference. std::memory_order. en.cppreference.com. The exact relaxed / acquire / release / seq_cst semantics.
- Dmitry Vyukov. "Bounded MPMC queue." 1024cores. The per-cell sequence-number design, the orderings, and the "not lockfree in the official meaning" caveat.
- Cambridge Computer Lab. "C/C++11 mappings to processors." cl.cam.ac.uk. The instruction lowering per ordering per ISA: x86 MOV vs AArch64 LDAR/STLR.
- Peter Sewell et al. "x86-TSO: A Rigorous and Usable Programmer's Model for x86 Multiprocessors." cl.cam.ac.uk. The formal x86 store-buffer model (loads acquire, stores release, for free).
- The Rustonomicon. "Atomics." doc.rust-lang.org. Acquire/release pairing in Rust, "often totally free" on strongly-ordered platforms.
- crossbeam (Rust). github.com/crossbeam-rs/crossbeam. Epoch-based reclamation,
ArrayQueue/SegQueue, and the Chase-Levcrossbeam-deque. - Cameron Desrochers. moodycamel
ConcurrentQueueandReaderWriterQueue. github.com/cameron314/readerwriterqueue. The lock-free MPMC queue and the wait-free SPSC ReaderWriterQueue. - Ross Bencina. "Real-time audio programming 101: time waits for nothing." rossbencina.com. Why the audio callback must not lock, and the lock-free ring pattern.
- C++ Core Guidelines, Concurrency (CP.100–CP.101). isocpp.github.io. CP.100: don't use lock-free programming unless you absolutely have to; its listed alternative is a library implementation.
- David Chase and Yossi Lev. "Dynamic Circular Work-Stealing Deque." SPAA 2005. doi.org; PDF (archived). The work-stealing deque behind job systems.
- Nhat Minh Lê et al. "Correct and Efficient Work-Stealing for Weak Memory Models." PPoPP 2013. fzn.fr. The acquire/release fixes that make Chase-Lev correct on ARM/POWER.
- Boost.Lockfree documentation. boost.org. Describes
spsc_queueas "a wait-free single-producer/single-consumer queue".