001 Notes
Mutex Internals: From Cache Lines to Futex Wait Queues
0. The Whole Hierarchy
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
cache coherence + atomic RMW + ordering
|
+-> atomic<T>
|
+-> spinlock
|
+-> mutex fast path
|
+-> futex slow path
|
+-> condition-variable blocking
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
| Concept | Meaning | Example failure when missing |
|---|---|---|
| Atomicity | other cores cannot observe a half-finished operation | two threads corrupt a counter update |
| Cache coherence | cores agree on write order for one memory location | one core keeps using an old value of the same cache line forever |
| Memory ordering | operations on multiple locations become visible in the intended order | ready == true is visible before data is ready |
| Blocking | a waiter stops running and lets the scheduler run something else | a thread burns CPU for a long wait |
| Fairness | waiters make progress in a predictable order | a late waiter repeatedly steals a lock |
| Wakeup sequencing | wait and wake cannot race into a lost signal | a thread goes to sleep after the only wakeup happened |
2. Cache Coherence Versus Memory Ordering
For one memory location, all cores agree on the order of writes.
For multiple memory locations, when do writes by one core become visible
to another core, and in what order?
std::atomic<int> x;
x.fetch_add(1);
data = 123;
ready.store(true, std::memory_order_release);
// another thread
if (ready.load(std::memory_order_acquire)) {
use(data);
}
make earlier writes visible before publishing ready
after observing ready, do not move later reads before the acquire
3. Atomic Operations At Hardware Level
atomic_fetch_add(&x, 1);
old = x
new = old + 1
x = new
return old
| Primitive | Shape |
|---|---|
| CAS | compare-and-swap |
| XCHG | exchange |
| XADD | exchange-and-add |
| AMO | atomic memory operation |
| LR/SC | load-reserved / store-conditional |
4. x86 LOCK Prefix
LOCK Prefixlock cmpxchg [mem], reg
lock xadd [mem], reg
lock add [mem], reg
xchg [mem], reg ; xchg with memory is implicitly locked
LOCK = freeze the entire memory bus
LOCK = obtain exclusive ownership of the cache line,
perform the read-modify-write atomically,
use the coherence protocol to serialize conflicting access.
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.
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
LR/SC:
load-reserved
store-conditional
AMO:
atomic fetch-and-op memory instructions
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
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.
6. Memory Ordering: Atomicity Is Not Enough
data = 42;
flag.store(1, std::memory_order_relaxed);
if (flag.load(std::memory_order_relaxed)) {
printf("%d\n", data);
}
lock.acquire(); // acquire ordering
critical_section();
lock.release(); // release ordering
| Ordering | Meaning |
|---|---|
memory_order_relaxed | atomicity for that object, minimal ordering |
memory_order_acquire | later reads/writes cannot move before the acquire |
memory_order_release | earlier reads/writes cannot move after the release |
memory_order_acq_rel | acquire and release on one read-modify-write |
memory_order_seq_cst | acquire/release plus one global order for seq-cst atomics |
lock acquisition:
acquire
lock release:
release
7. Spinlocks
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);
}
};
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
7.2 Why The Naive Version Is Bad
while (locked.exchange(1) == 1) {}
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.
7.3 Test-And-Test-And-Set
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;
}
}
}
lock held:
many cores read the shared cache line
unlock:
owner writes 0
waiters observe the change
one waiter wins CAS
7.4 Why pause Exists
pause Existspause
while (lock_is_held) {
pause();
}
7.5 Ticket Locks And Queued Locks
Core 2 waits.
Core 7 arrives later.
Core 7 can win the race when the lock opens.
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);
}
};
each waiter has its own node
each core spins on its own local flag
unlocker hands ownership to the next node
8. Mutexes
spinlock:
wait by burning CPU
mutex:
try fast atomic acquire
if contended, block in the kernel
scheduler runs someone else
8.1 Fast Path Versus Slow Path
| Situation | Typical path | CPU behavior | Kernel involved |
|---|---|---|---|
| uncontended mutex | atomic CAS succeeds | a few userspace instructions | no |
| brief contention | adaptive spin or retry | CPU spins briefly | maybe no |
| real contention | futex wait / kernel queue | task sleeps | yes |
| unlock with no waiters | release store or exchange | userspace fast path | no |
| unlock with waiters | futex wake | wake one or more sleepers | yes |
uncontended:
stay in userspace
contended:
ask kernel to park and wake waiters
8.2 Futex-Style Mutex State
struct Mutex {
std::atomic<int> state{0};
// 0 = unlocked
// 1 = locked, no known waiters
// 2 = locked, maybe waiters
};
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);
}
no syscall
no scheduler
one atomic compare-and-swap
8.3 Futex 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);
}
}
void unlock(Mutex* m) {
int old = m->state.exchange(0, std::memory_order_release);
if (old == 2) {
futex_wake(&m->state, 1);
}
}
futex_wait(address, expected)
kernel checks:
*address == expected?
if yes:
put this thread to sleep atomically
if no:
return immediately
Thread B checks lock is held.
Thread A unlocks and wakes.
Thread B goes to sleep after the wake.
Thread B may sleep forever.
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.
9. Kernel Mutex Internals
| Path | What happens |
|---|---|
| Fast path | atomic cmpxchg owner from NULL to current task |
| Mid path | optimistic spinning while the owner is running |
| Slow path | enqueue waiter and sleep |
try owner cmpxchg
|
+-> success: lock acquired
|
+-> fail:
if owner is running and conditions allow:
optimistic spin
else:
enqueue waiter
set task state
schedule away
short critical section:
spinning may avoid context-switch overhead
long critical section:
sleeping lets another runnable task use the CPU
10. Condition Variables
std::mutex m;
std::condition_variable cv;
bool ready = false;
std::unique_lock<std::mutex> lock(m);
while (!ready) {
cv.wait(lock);
}
if (!ready) {
cv.wait(lock);
}
11. What cond_wait Must Do
cond_wait Must Do1. 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.
release mutex + sleep
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);
}
void cond_signal(CondVar* cv) {
cv->seq.fetch_add(1, std::memory_order_release);
futex_wake(&cv->seq, 1);
}
void cond_broadcast(CondVar* cv) {
cv->seq.fetch_add(1, std::memory_order_release);
futex_wake(&cv->seq, INT_MAX);
}
12. Why Condition Variables Require A Mutex
// Thread A
if (!ready) {
cond_wait(cv);
}
// Thread B
ready = true;
cond_signal(cv);
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.
// Thread A
lock(m);
while (!ready) {
cond_wait(cv, m);
}
unlock(m);
// Thread B
lock(m);
ready = true;
unlock(m);
cond_signal(cv);
ready
who is sleeping until ready might become true
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
13. Two-Core Timeline
std::atomic<int> lock = 0;
while (lock.exchange(1, std::memory_order_acquire) == 1) {}
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
Core 1 keeps doing RMW writes.
Those writes keep forcing exclusive ownership.
The cache line keeps moving between cores.
Performance collapses under contention.
while (lock.load(std::memory_order_relaxed) == 1) {
pause();
}
if (cas(lock, 0, 1, std::memory_order_acquire)) {
enter_critical_section();
}
Core 1 mostly reads while lock is held.
Multiple cores can hold shared copies.
Only when lock appears free do they race with CAS.
14. Minimal Pseudo-Implementations
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);
}
};
15. One-Page Cheat Sheet
| Primitive | Wait style | Built from | Hardware dependency | Kernel needed |
|---|---|---|---|---|
atomic<T> | no wait | CPU atomic ops + compiler memory model | cache coherence, RMW, fences | no |
| spinlock | busy wait | atomic exchange/CAS | atomic RMW, acquire/release | no |
| mutex | sleep if contended | atomic fast path + futex/kernel queue | atomic RMW, barriers | only on contention |
| condition variable | sleep until signaled | mutex + waiter sequence + futex | atomics for sequencing | usually yes when blocking |
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
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.
Comments