001 Notes

Mutex Internals: From Cache Lines to Futex Wait Queues

0. The Whole Hierarchy

Hardware does not implement pthread_mutex_lock() or std::condition_variable directly.

Hardware gives lower-level tools:

  • atomic loads and stores;
  • atomic read-modify-write operations;
  • cache coherence;
  • memory ordering controls;
  • fences;
  • interrupts and context-switch support.

Libraries and kernels build synchronization primitives on top.

The stack looks like this:

application code
  |
  | std::atomic / pthread_mutex / pthread_cond / Rust Atomic / parking_lot
  v
libc / language runtime
  |
  | fast path: userspace atomic instructions
  | slow path: syscall into kernel
  v
kernel
  |
  | futex wait queues, scheduler, task states, wakeups
  v
CPU hardware
  |
  | cache coherence, atomic RMW instructions, fences, load/store ordering
  v
cache line / memory system

The dependency graph:

cache coherence + atomic RMW + ordering
  |
  +-> atomic<T>
  |
  +-> spinlock
  |
  +-> mutex fast path
          |
          +-> futex slow path
                  |
                  +-> condition-variable blocking

The important split:

atomic:
make one memory operation indivisible

spinlock:
wait by retrying in userspace

mutex:
try in userspace, sleep in the kernel if contended

condition variable:
wait for a predicate transition while coordinating with a mutex

1. Concepts People Mix Up

Several different ideas get collapsed into the word “thread-safe.” They are not the same thing.

ConceptMeaningExample failure when missing
Atomicityother cores cannot observe a half-finished operationtwo threads corrupt a counter update
Cache coherencecores agree on write order for one memory locationone core keeps using an old value of the same cache line forever
Memory orderingoperations on multiple locations become visible in the intended orderready == true is visible before data is ready
Blockinga waiter stops running and lets the scheduler run something elsea thread burns CPU for a long wait
Fairnesswaiters make progress in a predictable ordera late waiter repeatedly steals a lock
Wakeup sequencingwait and wake cannot race into a lost signala thread goes to sleep after the only wakeup happened

Most real synchronization bugs involve more than one row.

2. Cache Coherence Versus Memory Ordering

There are two separate hardware problems.

Cache coherence:

For one memory location, all cores agree on the order of writes.

Memory consistency / ordering:

For multiple memory locations, when do writes by one core become visible
to another core, and in what order?

Cache coherence makes this operation behave as one shared-object update:

std::atomic<int> x;
x.fetch_add(1);

Memory ordering makes this pattern meaningful:

data = 123;
ready.store(true, std::memory_order_release);

// another thread
if (ready.load(std::memory_order_acquire)) {
    use(data);
}

The release store says:

make earlier writes visible before publishing ready

The acquire load says:

after observing ready, do not move later reads before the acquire

Atomicity alone does not provide that full message-passing contract.

3. Atomic Operations At Hardware Level

An atomic operation is indivisible with respect to other cores observing the same memory location.

For example:

atomic_fetch_add(&x, 1);

Logically means:

old = x
new = old + 1
x = new
return old

The hardware and memory model must make that sequence behave like a single operation with respect to conflicting operations from other cores.

Ordinary aligned machine-word loads and stores may already be naturally atomic on many CPUs. Atomic read-modify-write needs stronger machinery:

PrimitiveShape
CAScompare-and-swap
XCHGexchange
XADDexchange-and-add
AMOatomic memory operation
LR/SCload-reserved / store-conditional

4. x86 LOCK Prefix

On x86, atomic read-modify-write operations are commonly implemented with the LOCK prefix:

lock cmpxchg [mem], reg
lock xadd    [mem], reg
lock add     [mem], reg
xchg         [mem], reg     ; xchg with memory is implicitly locked

Old mental model:

LOCK = freeze the entire memory bus

Modern cached-memory mental model:

LOCK = obtain exclusive ownership of the cache line,
       perform the read-modify-write atomically,
       use the coherence protocol to serialize conflicting access.

Rough flow for lock cmpxchg(&x):

1. Core 0 issues Read-For-Ownership for the cache line containing x.
2. Other cores' copies of that line are invalidated or downgraded.
3. Core 0 obtains the line in an exclusive/modified state.
4. Core 0 performs compare + store as one atomic RMW.
5. Other cores observe either the old value or the new value, never a half-update.

Important point:

The update does not need to be flushed to DRAM immediately.
Visibility is handled through cache coherence, not by making DRAM the first observer.

5. LR/SC And AMO Style

Many RISC architectures provide load-reserved / store-conditional, atomic memory operations, or both.

RISC-V’s atomic extension includes:

LR/SC:
  load-reserved
  store-conditional

AMO:
  atomic fetch-and-op memory instructions

CAS-like loop using LR/SC:

cas:
    lr.w    t0, (a0)        # load old value and reserve address
    bne     t0, a1, fail    # if old != expected, fail
    sc.w    t1, a2, (a0)    # try store desired
    bnez    t1, cas         # retry if reservation lost
    li      a0, 0           # success
    ret

fail:
    li      a0, 1
    ret

Hardware maintains a reservation. If another core writes to the reservation set between lr.w and sc.w, the store-conditional fails and does not write memory.

Comparison:

CAS style:
  Is memory still equal to expected?
  If yes, write desired.

LR/SC style:
  Did nobody write to this reserved location since my load-reserved?
  If yes, write desired.

RISC-V AMO instructions can also encode acquire and release semantics, such as acquire on lock acquisition or release on unlock.

6. Memory Ordering: Atomicity Is Not Enough

This code is atomic but still wrong as a publication protocol:

data = 42;
flag.store(1, std::memory_order_relaxed);

Another thread:

if (flag.load(std::memory_order_relaxed)) {
    printf("%d\n", data);
}

The flag itself is atomic. But relaxed ordering does not guarantee that data = 42 becomes visible before flag = 1.

The usual lock pattern is:

lock.acquire();   // acquire ordering
critical_section();
lock.release();   // release ordering

C++ exposes common ordering modes:

OrderingMeaning
memory_order_relaxedatomicity for that object, minimal ordering
memory_order_acquirelater reads/writes cannot move before the acquire
memory_order_releaseearlier reads/writes cannot move after the release
memory_order_acq_relacquire and release on one read-modify-write
memory_order_seq_cstacquire/release plus one global order for seq-cst atomics

For locks:

lock acquisition:
  acquire

lock release:
  release

That is the minimum shape that makes critical-section memory effects usable by the next owner.

7. Spinlocks

A spinlock waits by burning CPU until it can atomically own a flag.

7.1 Naive Spinlock

struct SpinLock {
    std::atomic<int> locked{0};

    void lock() {
        while (locked.exchange(1, std::memory_order_acquire) == 1) {
            // spin
        }
    }

    void unlock() {
        locked.store(0, std::memory_order_release);
    }
};

Hardware behavior:

Thread A:
  exchange 0 -> 1 succeeds
  enters critical section

Thread B:
  exchange 1 -> 1 fails
  keeps looping

Thread A:
  release store 0
  Thread B eventually sees 0 and succeeds

On x86, the exchange or CAS compiles to a locked read-modify-write. On RISC-V, acquisition could use an acquire AMO such as amoswap.w.aq, and release could use release semantics.

7.2 Why The Naive Version Is Bad

The naive loop:

while (locked.exchange(1) == 1) {}

does a write-like atomic RMW on every failed attempt.

Under contention:

Core 0 holds lock.
Core 1 exchange-fails and writes the same value.
Core 2 exchange-fails and writes the same value.
Core 3 exchange-fails and writes the same value.

Each failed RMW wants exclusive ownership of the cache line. The lock cache line moves between cores even though no useful progress is being made.

7.3 Test-And-Test-And-Set

Better shape:

void lock() {
    for (;;) {
        while (locked.load(std::memory_order_relaxed) == 1) {
            cpu_relax();
        }

        int expected = 0;
        if (locked.compare_exchange_strong(
                expected,
                1,
                std::memory_order_acquire,
                std::memory_order_relaxed)) {
            return;
        }
    }
}

Now waiters mostly do ordinary loads while the lock is held:

lock held:
  many cores read the shared cache line

unlock:
  owner writes 0
  waiters observe the change
  one waiter wins CAS

That reduces cache-line ownership traffic.

7.4 Why pause Exists

On x86, spin loops often include:

pause

Conceptually:

while (lock_is_held) {
    pause();
}

It tells the CPU that the loop is a spin-wait. It can reduce pipeline penalties, power use, and damage to a sibling SMT thread.

7.5 Ticket Locks And Queued Locks

Simple spinlocks are not fair:

Core 2 waits.
Core 7 arrives later.
Core 7 can win the race when the lock opens.

Ticket lock:

struct TicketLock {
    std::atomic<uint32_t> next{0};
    std::atomic<uint32_t> serving{0};

    void lock() {
        uint32_t my = next.fetch_add(1, std::memory_order_relaxed);
        while (serving.load(std::memory_order_acquire) != my) {
            cpu_relax();
        }
    }

    void unlock() {
        serving.store(serving.load(std::memory_order_relaxed) + 1,
                      std::memory_order_release);
    }
};

Ticket locks are fair, but all waiters spin on the same serving cache line. On large systems, that shared spinning can hurt.

MCS-style lock:

each waiter has its own node
each core spins on its own local flag
unlocker hands ownership to the next node

Linux queued spinlocks use MCS-like ideas while compressing state into a small lock word. The main reason is practical: kernel locks need to be compact and still behave decently under contention.

8. Mutexes

A mutex is not just a spinlock. A mutex can sleep.

spinlock:
  wait by burning CPU

mutex:
  try fast atomic acquire
  if contended, block in the kernel
  scheduler runs someone else

Linux classifies mutexes as sleeping locks and raw spinlocks as spinning locks.

8.1 Fast Path Versus Slow Path

SituationTypical pathCPU behaviorKernel involved
uncontended mutexatomic CAS succeedsa few userspace instructionsno
brief contentionadaptive spin or retryCPU spins brieflymaybe no
real contentionfutex wait / kernel queuetask sleepsyes
unlock with no waitersrelease store or exchangeuserspace fast pathno
unlock with waitersfutex wakewake one or more sleepersyes

The purpose of futex-backed mutexes is not “kernel lock for every mutex operation.” It is the opposite:

uncontended:
  stay in userspace

contended:
  ask kernel to park and wake waiters

8.2 Futex-Style Mutex State

Educational state machine:

struct Mutex {
    std::atomic<int> state{0};
    // 0 = unlocked
    // 1 = locked, no known waiters
    // 2 = locked, maybe waiters
};

Fast path:

void lock(Mutex* m) {
    int expected = 0;

    if (m->state.compare_exchange_strong(
            expected,
            1,
            std::memory_order_acquire,
            std::memory_order_relaxed)) {
        return;
    }

    lock_slow(m);
}

If uncontended:

no syscall
no scheduler
one atomic compare-and-swap

That is the main futex design point.

8.3 Futex Slow Path

Simplified lock slow path:

void lock_slow(Mutex* m) {
    for (;;) {
        int expected = 0;
        if (m->state.compare_exchange_strong(
                expected,
                2,
                std::memory_order_acquire,
                std::memory_order_relaxed)) {
            return;
        }

        futex_wait(&m->state, 2);
    }
}

Unlock:

void unlock(Mutex* m) {
    int old = m->state.exchange(0, std::memory_order_release);

    if (old == 2) {
        futex_wake(&m->state, 1);
    }
}

The key operation:

futex_wait(address, expected)

means:

kernel checks:
  *address == expected?

if yes:
  put this thread to sleep atomically

if no:
  return immediately

That compare-and-block behavior prevents a lost wakeup.

Broken sequence without atomic compare-and-block:

Thread B checks lock is held.
Thread A unlocks and wakes.
Thread B goes to sleep after the wake.
Thread B may sleep forever.

With futex wait:

Thread B asks kernel: sleep only if state is still 2.
If Thread A already changed state to 0, the kernel refuses to sleep B.

That atomic sleep transition is the essential kernel service behind futex-based mutexes.

9. Kernel Mutex Internals

Linux kernel mutexes are hybrid locks.

The kernel mutex design has three broad acquisition paths:

PathWhat happens
Fast pathatomic cmpxchg owner from NULL to current task
Mid pathoptimistic spinning while the owner is running
Slow pathenqueue waiter and sleep

Conceptual shape:

try owner cmpxchg
  |
  +-> success: lock acquired
  |
  +-> fail:
        if owner is running and conditions allow:
          optimistic spin
        else:
          enqueue waiter
          set task state
          schedule away

This is why a mutex can be better than a spinlock for long waits.

short critical section:
  spinning may avoid context-switch overhead

long critical section:
  sleeping lets another runnable task use the CPU

10. Condition Variables

A condition variable is not a lock.

It is a wait queue for a predicate.

Correct usage:

std::mutex m;
std::condition_variable cv;
bool ready = false;

std::unique_lock<std::mutex> lock(m);

while (!ready) {
    cv.wait(lock);
}

Incorrect usage:

if (!ready) {
    cv.wait(lock);
}

The loop matters because:

  • wakeups may be spurious;
  • another thread may consume the condition before this thread reacquires the mutex;
  • the wait returning does not by itself prove the predicate is true.

POSIX explicitly requires callers to re-evaluate the predicate after return from pthread_cond_wait() or pthread_cond_timedwait().

11. What cond_wait Must Do

cond_wait(cv, mutex) must behave like this:

1. Caller currently holds mutex.
2. Register caller as a waiter on cv.
3. Atomically release mutex and block.
4. Wake because of signal, broadcast, timeout, cancellation, or spurious wake.
5. Reacquire mutex before returning.
6. Caller rechecks predicate.

The hard part is:

release mutex + sleep

That transition must not lose wakeups.

Simplified condition-variable idea:

void cond_wait(CondVar* cv, Mutex* m) {
    uint64_t seq = cv->seq.load(std::memory_order_relaxed);

    mutex_unlock(m);

    futex_wait(&cv->seq, seq);

    mutex_lock(m);
}

Signal:

void cond_signal(CondVar* cv) {
    cv->seq.fetch_add(1, std::memory_order_release);
    futex_wake(&cv->seq, 1);
}

Broadcast:

void cond_broadcast(CondVar* cv) {
    cv->seq.fetch_add(1, std::memory_order_release);
    futex_wake(&cv->seq, INT_MAX);
}

This is only the teaching shape. Real libc condition variables handle:

  • timeouts;
  • cancellation;
  • process-shared condition variables;
  • broadcast;
  • waiter sequencing;
  • destruction safety;
  • 32-bit futex words;
  • larger logical waiter sequences;
  • mutex reacquisition rules.

glibc’s implementation is substantially more complicated because it uses waiter sequences and groups to handle wakeup ordering and broadcast cases.

12. Why Condition Variables Require A Mutex

Bad version:

// Thread A
if (!ready) {
    cond_wait(cv);
}

// Thread B
ready = true;
cond_signal(cv);

Lost wakeup race:

Thread A checks ready == false.
Thread B sets ready = true and signals.
Thread A starts waiting.
No one signals again.
Thread A can sleep forever.

Correct version:

// Thread A
lock(m);
while (!ready) {
    cond_wait(cv, m);
}
unlock(m);

// Thread B
lock(m);
ready = true;
unlock(m);
cond_signal(cv);

The mutex protects the predicate:

ready

The condition variable coordinates the wait queue:

who is sleeping until ready might become true

They solve different parts of the problem.

Timeline view:

Thread A                      Thread B
--------                      --------
lock(m)
ready == false
register waiter
release m and sleep
                              lock(m)
                              ready = true
                              unlock(m)
                              signal(cv)
wake
reacquire m
recheck ready
continue

The key is that the predicate check, waiter registration, mutex release, and blocking sequence are coordinated so that the signal cannot disappear between “I should sleep” and “I am asleep.”

13. Two-Core Timeline

Suppose:

std::atomic<int> lock = 0;

Two cores run:

while (lock.exchange(1, std::memory_order_acquire) == 1) {}

Hardware-level timeline:

Initial:
  lock cache line is shared or invalid in both cores.

Core 0:
  wants exchange
  requests exclusive ownership of cache line
  invalidates other shared copies
  performs atomic RMW
  lock becomes 1
  enters critical section

Core 1:
  wants exchange
  requests exclusive ownership
  sees lock = 1
  writes 1 again because exchange is RMW
  fails logically
  retries

The problem:

Core 1 keeps doing RMW writes.
Those writes keep forcing exclusive ownership.
The cache line keeps moving between cores.
Performance collapses under contention.

With test-and-test-and-set:

while (lock.load(std::memory_order_relaxed) == 1) {
    pause();
}

if (cas(lock, 0, 1, std::memory_order_acquire)) {
    enter_critical_section();
}

Timeline:

Core 1 mostly reads while lock is held.
Multiple cores can hold shared copies.
Only when lock appears free do they race with CAS.

That is much friendlier to the cache-coherence protocol.

14. Minimal Pseudo-Implementations

These are educational models, not production implementations. Real locks handle ABI constraints, owner tracking, robust mutexes, priority inheritance, adaptive spinning, cancellation, timeouts, process-shared memory, scheduler policy, and many platform-specific details.

14.1 CAS-Based Spinlock

class SpinLock {
    std::atomic<int> state{0};

public:
    void lock() {
        for (;;) {
            while (state.load(std::memory_order_relaxed) != 0) {
                cpu_relax();
            }

            int expected = 0;
            if (state.compare_exchange_strong(
                    expected,
                    1,
                    std::memory_order_acquire,
                    std::memory_order_relaxed)) {
                return;
            }
        }
    }

    void unlock() {
        state.store(0, std::memory_order_release);
    }
};

14.2 Futex-Style Mutex

class Mutex {
    std::atomic<int> state{0};
    // 0 = unlocked
    // 1 = locked, no waiters
    // 2 = locked, possible waiters

public:
    void lock() {
        int expected = 0;

        if (state.compare_exchange_strong(
                expected,
                1,
                std::memory_order_acquire,
                std::memory_order_relaxed)) {
            return;
        }

        for (;;) {
            expected = 0;
            if (state.compare_exchange_strong(
                    expected,
                    2,
                    std::memory_order_acquire,
                    std::memory_order_relaxed)) {
                return;
            }

            state.store(2, std::memory_order_relaxed);
            futex_wait(&state, 2);
        }
    }

    void unlock() {
        int old = state.exchange(0, std::memory_order_release);

        if (old == 2) {
            futex_wake(&state, 1);
        }
    }
};

14.3 Condition Variable Teaching Shape

class CondVar {
    std::atomic<uint32_t> seq{0};

public:
    void wait(Mutex& m) {
        uint32_t old = seq.load(std::memory_order_relaxed);

        m.unlock();

        futex_wait(&seq, old);

        m.lock();
    }

    void signal() {
        seq.fetch_add(1, std::memory_order_release);
        futex_wake(&seq, 1);
    }

    void broadcast() {
        seq.fetch_add(1, std::memory_order_release);
        futex_wake(&seq, INT_MAX);
    }
};

Again, this is the concept only. A real condition variable has to coordinate waiter registration, mutex release, blocking, wake confirmation, cancellation, timeout, and mutex reacquisition with much more care.

15. One-Page Cheat Sheet

PrimitiveWait styleBuilt fromHardware dependencyKernel needed
atomic<T>no waitCPU atomic ops + compiler memory modelcache coherence, RMW, fencesno
spinlockbusy waitatomic exchange/CASatomic RMW, acquire/releaseno
mutexsleep if contendedatomic fast path + futex/kernel queueatomic RMW, barriersonly on contention
condition variablesleep until signaledmutex + waiter sequence + futexatomics for sequencingusually yes when blocking

Mental model:

atomic:
  make this one memory operation indivisible

spinlock:
  keep trying atomically until I own the flag

mutex:
  try atomically; if I fail, ask the kernel to park me

condition variable:
  I hold the mutex, my predicate is false,
  so register me as a waiter, release mutex, sleep,
  wake later, reacquire mutex, and recheck

At the lowest level:

atomic RMW works because cache coherence serializes ownership of a cache line.

mutex works because futex makes "check value and sleep" atomic.

condition variable works because waiter registration, mutex release,
sleep, wakeup, and mutex reacquisition are sequenced around the predicate.

16. References

Comments