Skip to content
Work Practice Products Coding interviews Machine learning coding Quant developer HFT course Blog Method Why me About Enquire Book a call

Quant developer HFT course · Module 4: Concurrency

Atomics and memory ordering

Lesson · 13 min

The question

The producer writes a quote into a slot and then sets ready to true. The consumer spins until ready is true and then reads the slot. Which memory orders do you need, and why?

What an atomic gives you

std::atomic<T> guarantees two things about the variable itself: every access is indivisible, so no thread sees half a write, and accesses from different threads do not form a data race. For T up to 8 bytes (16 on some platforms), atomics compile to ordinary instructions or locked instructions with no lock object; is_lock_free() tells you.

What an atomic says about other memory, the quote in the slot, depends on the memory order you choose. That is the part interviews are about.

The publication problem

Quote slot;
std::atomic<bool> ready{false};

// producer                              // consumer
slot = q;                                while (!ready.load(/* ? */)) {}
ready.store(true, /* ? */);              use(slot);

The danger: either the compiler or the processor could make the store to ready visible before the store to slot, or let the consumer's read of slot happen before its read of ready. The consumer would then see ready == true and read an old or half-written quote.

Release and acquire

The fix is a matching pair:

slot = q;
ready.store(true, std::memory_order_release);   // nothing before this may move after it

while (!ready.load(std::memory_order_acquire)) {}   // nothing after this may move before it
use(slot);
sequenced beforesynchronises withsequenced beforeslot = quoteready.store(true, release)ready.load(acquire) == trueread slot: sees quoteproducer threadconsumer thread
Publication with release and acquire. The release store cannot be reordered before the write of the slot; the acquire load cannot be reordered after the read. Seeing the flag guarantees seeing the data.

A release store guarantees that every read and write the thread made before it becomes visible no later than the store itself. An acquire load guarantees that every read and write after it happens after the load. When an acquire load reads the value written by a release store, the two synchronise: everything the producer did before the store is visible to the consumer after the load. That is exactly the publication guarantee, and it is all most lock-free code needs.

The four orderings in practice

OrderGuaranteesUse for
relaxedatomicity only, no ordering with other memorycounters and statistics that publish nothing
acquire (loads)later accesses stay after itreading a flag or index that publishes data
release (stores)earlier accesses stay before itwriting a flag or index that publishes data
acq_rel (read-modify-writes)botha fetch_add or exchange that both consumes and publishes
seq_cst (the default)acquire and release, plus one total order of all seq_cst operations that every thread agrees onwhen you need that global order; when in doubt

(memory_order_consume exists but compilers treat it as acquire; ignore it.)

seq_cst is what you get if you write ready = true or ready.load() without arguments. It is the easiest to reason about and the strongest. The extra guarantee matters in patterns like Dekker's mutual exclusion, where each thread stores to its own flag and then loads the other's: with release and acquire only, both threads can read the other's flag as false.

What it costs on real hardware

On x86, ordinary loads already behave as acquire and ordinary stores as release (the hardware model is called total store order). So acquire loads, release stores and relaxed accesses all compile to plain mov instructions, and the orderings only constrain the compiler. The one expensive case is a seq_cst store, which compiles to xchg or mov plus mfence to stop it being reordered with a later load. Read-modify-writes like fetch_add are lock-prefixed instructions at any ordering.

On ARM and POWER, the hardware reorders much more freely, so acquire and release compile to special load and store instructions or barriers, and the difference between relaxed and stronger orders is real.

That has a practical consequence: a race with the wrong ordering may never show up on an x86 development machine and fail on ARM. Get the ordering right by reasoning, not by testing on x86.

Compare and exchange

Most lock-free algorithms need to update a value only if it has not changed since they read it:

std::atomic<std::int64_t> best_bid{0};

void offer(std::int64_t px) {
    std::int64_t cur = best_bid.load(std::memory_order_relaxed);
    while (px > cur &&
           !best_bid.compare_exchange_weak(cur, px, std::memory_order_release, std::memory_order_relaxed)) {
        // on failure, cur has been reloaded with the current value; the loop re-tests px > cur
    }
}

compare_exchange_weak may fail spuriously even if the value matched, which lets it compile to a cheaper instruction pair on ARM; it belongs in a loop. compare_exchange_strong fails only on a real mismatch, for single attempts. Under heavy contention, many threads retrying a CAS on one cache line scale badly, which is a reason to avoid shared hot atomics altogether (Module 5).

Answering the question

The producer's store to ready must be a release, so the write of the slot cannot become visible after it. The consumer's load must be an acquire, so the read of the slot cannot happen before it. When the acquire reads the release's value, everything written before the release is visible after the acquire. On x86 both are plain moves and only stop compiler reordering; on ARM they emit the needed barriers. seq_cst would also be correct, just stronger than needed.

Check yourself

Question 1 of 3

A thread increments a statistics counter that no other logic depends on. Which order is sufficient?

Question 2 of 3

The producer writes data and then stores a flag with relaxed; the consumer loads the flag with acquire. Is the data guaranteed visible?

Question 3 of 3

What does an acquire load compile to on x86-64?

In the interview

Say your own answer out loud first, then open the one we would give.

Explain acquire and release.

A release store makes all the thread's earlier memory operations visible no later than the store; an acquire load keeps all later operations after it. When an acquire load reads the value written by a release store, everything before the release is visible after the acquire. It is the standard way to publish data from one thread to another through a flag or an index.

When is relaxed ordering correct?

When the atomic variable does not publish any other data: counters, statistics, an id generator. The operation is still indivisible; it just imposes no ordering on surrounding memory accesses.

What does seq_cst add over acquire-release?

A single total order of all seq_cst operations that every thread observes consistently. It matters in patterns where threads write one variable and read another, such as Dekker-style flags; with only acquire and release both threads can miss each other's writes. On x86 it costs a fence on stores.

Why can lock-free code that passes tests on x86 fail on ARM?

x86 gives loads acquire and stores release semantics in hardware, so a missing ordering only matters if the compiler reorders. ARM reorders much more, so the same missing ordering shows up as real bugs. Memory orders have to be justified by reasoning, not by test results on x86.