All tutorials Mighty Professional
Build a Game Engine · Memory & concurrency

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.

Time~35 min LevelSenior PrereqsThe Memory Model (atomics, acquire/release) and Job Systems (the thread pool that needs this). StackC++ & Rust
◂ Build a Game Engine Phase 1 · Memory & concurrency Next · 3D Math for Games ▸
What you'll be able to do by the end

01Why lock-free

Two concrete cases pull an engine toward lock-free queues, and one honest reason to think twice.

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).

Acquire/release does not "make it atomic"

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.

The hierarchy (Herlihy & Shavit)

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.

Two misreadings of the word

"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].

SPSC bounded ring (single owner per index)
#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:

The circle is the ring's 8 slots; green slots hold published values the consumer hasn't read yet, and the text beside it shows both indices. Drag the producer rate above the consumer rate and watch in flight climb to 8/8. Speed the producer past the consumer and the ring fills, so push starts returning false: backpressure, not a bug. Speed the consumer up and it spins on an empty ring. Each index is written by exactly one side, which is why no CAS and no lock appear anywhere.

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.

It is not, strictly, lock-free, and the sequence number is a generation counter

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:

Tags fix the comparison, not the memory

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:

The boxes are the stack, top first, and the line under them says which thread just acted. Step through once with the tag off, then tick the box and step again. Without tags, A's CAS expects X, finds X (the recycled address), and succeeds, leaving the stack top pointing at freed memory. With the versioned pointer on, the tag has moved and the CAS fails, which fixes the comparison, not the lifetime; step once more and A retries against the new top and succeeds. You'd still need hazard pointers or epochs to free X safely.

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 x86, which hides this

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:

Each trial runs the writer and the reader once; the counters show trials and stale reads, and the line below them shows when each store reached memory and when the reader read. Try all four combinations of ordering and hardware. With acquire/release the reader always sees 42; the stale-read counter stays at zero on either hardware. Switch to relaxed and pick x86: still zero, because TSO drains stores in program order (the demo models the hardware only; a real compiler could still reorder). Switch to ARM and the counter climbs, by about 3 trials in 10 in this model: the data write leaves the store buffer after the flag. (ARM can also reorder the reader's two loads; the demo models only the writer's side.) The per-trial timeline shows the order each event reached memory; drain times are randomized and illustrative, not measured silicon. That is why "it works on my machine" is not evidence here.

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:

"Lock-free means no waiting"It means system-wide progress; a single thread can still starve. Ruling that out is what wait-free adds.
Works on x86, breaks on ARMA missing acquire/release; TSO hides it, weak memory reveals it. Test on ARM.
CAS on a recycled valueABA: the bits matched but the object changed. Tag the pointer and reclaim safely.
Tag but still crashTags fix the comparison, not lifetime; you still need hazard pointers or epochs.
Slow SPSC ringhead and tail share a cache line, the block caches move as one unit: false sharing, where the line bounces between the two cores. Pad each to its own line.
CAS where you didn't need itSPSC has one owner per index, so it needs none. Save CAS for true MPMC.
Hand-rolling itCorrectness bugs here survive testing; ship moodycamel/crossbeam unless you can prove the need.

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.

  1. Jeff Preshing. "An Introduction to Lock-Free Programming." preshing.com. Lock-free as system-wide progress; the "lock" means lock-up, not mutex.
  2. 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.
  3. Jeff Preshing. "Acquire and Release Semantics." preshing.com. The synchronizes-with edge a release store forms with an acquire load that reads it.
  4. cppreference. std::memory_order. en.cppreference.com. The exact relaxed / acquire / release / seq_cst semantics.
  5. Dmitry Vyukov. "Bounded MPMC queue." 1024cores. The per-cell sequence-number design, the orderings, and the "not lockfree in the official meaning" caveat.
  6. 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.
  7. 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).
  8. The Rustonomicon. "Atomics." doc.rust-lang.org. Acquire/release pairing in Rust, "often totally free" on strongly-ordered platforms.
  9. crossbeam (Rust). github.com/crossbeam-rs/crossbeam. Epoch-based reclamation, ArrayQueue/SegQueue, and the Chase-Lev crossbeam-deque.
  10. Cameron Desrochers. moodycamel ConcurrentQueue and ReaderWriterQueue. github.com/cameron314/readerwriterqueue. The lock-free MPMC queue and the wait-free SPSC ReaderWriterQueue.
  11. 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.
  12. 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.
  13. David Chase and Yossi Lev. "Dynamic Circular Work-Stealing Deque." SPAA 2005. doi.org; PDF (archived). The work-stealing deque behind job systems.
  14. 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.
  15. Boost.Lockfree documentation. boost.org. Describes spsc_queue as "a wait-free single-producer/single-consumer queue".

See also