Kernel Locks and MCS
You have already built a lock from an
atomic
read-modify-write: spin on a compare-and-swap until the word flips from free to held. That
lock is correct. It is also, on a real 64-core server, a performance catastrophe. This lesson is
about the gap between a lock that works and a lock that scales — the gap the Linux kernel
spent two decades closing, and the reason your kernel's mutex is a small state machine and not a one-line
spin loop.
We climb a ladder of four designs. The naive test-and-set spinlock; the
test-and-test-and-set refinement; the ticket lock that adds fairness;
and the MCS queue lock, where each waiter spins on its own cache line — the
scalability fix that underlies modern kernel locks. Then we lift to user space with the
futex: fast in the common case, kernel-assisted only when threads actually collide.
First question: spin or block?
Before choosing which lock, choose what kind. A spinlock busy-waits —
the waiting core loops, burning cycles, until the lock frees. A blocking lock (a
mutex) instead
parks the waiting thread: the scheduler runs someone else and wakes the waiter later. The trade is stark
and it is all about the ratio of wait time to context-switch cost.
Parking and waking a thread costs a couple of context switches — call it a few microseconds. If the lock
is held for only a hundred nanoseconds, blocking is madness: you would spend microseconds of overhead to
avoid nanoseconds of spinning. Spin. But if the lock is held across a disk read of ten milliseconds,
spinning wastes an eternity of CPU that another runnable thread could have used. Block. The rule of thumb:
spin when the expected wait is shorter than the cost of a context switch; block otherwise.
- a spinlock busy-waits on the CPU — cheap to acquire, but wastes every cycle spent
waiting; correct only where the holder never sleeps (in-kernel, interrupt context);
- a blocking mutex parks the waiter and lets the scheduler reuse the core — it pays a
context switch, so it wins only when the critical section is long or may sleep;
- the decision hinges on T_\text{wait} vs
T_\text{ctxsw}: spin if T_\text{wait} \lesssim
T_\text{ctxsw}, block otherwise;
- real kernels hybridise — an adaptive mutex spins briefly while the holder
is running on another core, then parks if it sleeps.
Crucially, a spinlock is forbidden from holding across anything that can block: if the holder
sleeps while others spin, the spinners burn CPU forever and can deadlock the machine. Kernel spinlocks
therefore disable pre-emption (and sometimes interrupts) while held. Everything below is about making the
spin itself cheap.
The naive spinlock and its cache-line disease
Recall the test-and-set (TAS) spinlock: every waiter hammers an atomic write on the single lock word. By
MESI,
an atomic write demands the cache line in Modified state — the sole copy. So each waiter's
every attempt yanks the line to its own cache, invalidating everyone else. With n
cores spinning, the lock line ping-pongs endlessly, and each bounce is a full coherence
transaction across the interconnect. Worse, when the holder finally wants to release, it must
itself re-acquire the line the spinners keep stealing. Contention makes the lock slower to release
the more cores want it — the opposite of what you want.
Test-and-test-and-set (TTAS) is the first patch: spin on a plain read
(\texttt{while (lock != free)}), which lets every core hold the line in cheap
Shared state, and only attempt the atomic write when the read says it looks free. Quiet
reads instead of screaming writes — until the moment of release, when everyone's read fails at once and
they all storm the write together (a thundering herd). Better, but still O(n)
coherence traffic per handoff, and still unfair: nothing stops one unlucky core from being starved
forever while its neighbours keep winning the race.
| Lock | Spin target | Traffic per handoff | Fair? |
| Test-and-set (TAS) | atomic write on shared word | O(n), constant storm | no |
| Test-and-test-and-set (TTAS) | read shared word, write on release | O(n) burst (herd) | no |
| Ticket lock | read one shared counter | O(n) burst | yes (FIFO) |
| MCS queue lock | own local flag | O(1) | yes (FIFO) |
Ticket locks: fairness, one bakery number at a time
A ticket lock borrows the deli counter. Two counters live in the lock:
\texttt{next} (the number handed to the next arrival) and
\texttt{owner} (the number now being served). To acquire, atomically
fetch-and-increment \texttt{next} to grab your ticket, then spin until
\texttt{owner} equals it. To release, increment
\texttt{owner}. This is strict FIFO — you are served in arrival
order, so no one starves. Linux used ticket spinlocks as its kernel spinlock from 2008.
But every waiter still spins on the same \texttt{owner} word, so
releasing it invalidates all spinners at once even though only one — the next ticket-holder — can
proceed. The coherence storm per handoff is still O(n). Fairness solved;
scalability not yet.
MCS: everyone spins on their own doorbell
The MCS lock (Mellor-Crummey & Scott, 1991) is the breakthrough. Instead of one word
everyone watches, each waiter enqueues a small node of its own and spins on a
\texttt{locked} flag inside that node — a cache line touched by nobody
else. The lock itself is just a pointer to the tail of a queue of these nodes.
- Acquire: atomically swap yourself onto the tail. If there was a predecessor, set your
own flag to "wait", link the predecessor to you, and spin on your flag;
- Release: if a successor is linked, flip its flag — a single write that
invalidates exactly one cache line, waking exactly one waiter.
The picture is the whole lesson. On the left, ticket/TTAS: three cores staring at one word that bounces
between them. On the right, MCS: three cores, each staring at a private flag; the release pokes only the
next node. Coherence traffic per handoff drops from O(n) to
O(1), independent of how many cores are queued.
MCS is why the Linux kernel's spinlock became the qspinlock (a queued spinlock, MCS in
spirit) in 2014, and why big-server locks scale to hundreds of cores without collapsing. The cost is a
little extra bookkeeping — a per-waiter node — and slightly higher latency when uncontended. For
a lock that is only ever contended by one core, plain TTAS can still win; MCS earns its keep exactly when
the crowd arrives.
Simulate the handoff traffic
Let's count coherence invalidations per lock handoff for a ticket lock versus MCS, as the number
of spinning cores grows. The model is deliberately simple: releasing a ticket lock invalidates the shared
word in every spinner's cache; releasing an MCS lock writes exactly one successor's flag. Watch
the ticket cost climb with the crowd while MCS stays flat.
// Model coherence invalidations per handoff as `waiters` cores contend for one lock.
// Ticket lock: all waiters spin on the shared `owner` word -> release invalidates every copy.
// MCS lock: each waiter spins on its own node flag -> release pokes exactly one successor.
function ticketInvalidations(waiters: number): number {
return waiters; // O(n): the shared word is invalidated in every spinner's cache
}
function mcsInvalidations(waiters: number): number {
return waiters === 0 ? 0 : 1; // O(1): only the next node's flag is written
}
console.log("cores ticket MCS");
for (const w of [1, 2, 4, 8, 16, 32, 64]) {
const t = ticketInvalidations(w);
const m = mcsInvalidations(w);
console.log(`${String(w).padStart(4)} ${String(t).padStart(5)} ${String(m).padStart(3)}`);
}
// A tiny working MCS queue lock. Each thread owns a node; it spins on node.locked.
interface McsNode { locked: boolean; next: McsNode | null; }
class McsLock {
private tail: McsNode | null = null; // atomic tail pointer
acquire(node: McsNode): void {
node.next = null; node.locked = true;
const pred = this.tail; // atomic swap(tail, node) in real hardware
this.tail = node;
if (pred === null) { node.locked = false; return; } // lock was free
pred.next = node; // link ourselves behind predecessor
// spin on OUR OWN flag (node.locked) until the predecessor hands off
}
release(node: McsNode): void {
if (node.next === null) { this.tail = null; return; } // no waiter -> free the lock
node.next.locked = false; // ONE write: wake exactly the next waiter
}
}
const lock = new McsLock();
const a: McsNode = { locked: true, next: null };
const b: McsNode = { locked: true, next: null };
lock.acquire(a); console.log(`A acquired, A.locked=${a.locked}`);
lock.acquire(b); console.log(`B queued behind A, B.locked=${b.locked} (spinning)`);
lock.release(a); console.log(`A released -> B.locked=${b.locked} (now runs)`);
Futexes: the kernel only when it must
Everything so far assumed the lock lives in the kernel. But most locks a program takes are
uncontended — no one else wants them right now — so trapping into the kernel to acquire them is
pure waste (recall a syscall is hundreds of cycles). The Linux futex ("fast userspace
mutex") splits the job in two:
- Fast path, in user space: the lock is just an integer. To acquire, a single atomic
compare-and-swap flips it 0→1. If that succeeds — the uncontended case — you are done. No syscall at
all.
- Slow path, into the kernel: only if the CAS fails (someone holds it) do you
call \texttt{futex(FUTEX\_WAIT)} to park until the holder calls
\texttt{futex(FUTEX\_WAKE)} on release.
So the kernel maintains no data for a lock nobody is fighting over — it learns the lock exists only the
instant two threads collide. This is the mechanism behind every modern \texttt{pthread\_mutex},
Go's mutex, Rust's \texttt{std::sync::Mutex}, and glibc's condition variables. It
is the perfect embodiment of the OS design maxim: make the common case fast and pay the kernel only for the
rare, contended case.
Fairness and throughput fight each other. A strictly FIFO lock (ticket, MCS) guarantees no starvation, but
it forces the next owner to be a specific core — even if that core's cache is cold and a different,
cache-hot core could have taken and released the lock ten times over in the meantime. Unfair locks can hit
higher raw throughput by letting the lucky, cache-warm core keep "barging" in. Linux's qspinlock is FIFO;
but the kernel also keeps adaptive and test-and-set fallbacks for cases where fairness
costs more than it is worth, and the community still argues about the balance. There is no universally best
lock — only the best lock for a given contention level, NUMA topology, and critical-section length. This is
why "which lock?" is a research question, not a settled answer.
A tempting fallacy: "spinlocks avoid the context-switch overhead, so they are always faster." They are
faster only when the wait is short and the holder keeps running. Spin on a lock whose holder got
pre-empted or went to sleep, and you burn an entire time-slice achieving nothing — and on a single core,
spinning for a lock held by a descheduled thread is a guaranteed livelock, since the holder can never run
to release it. That is exactly why user-space code should almost never use a raw spinlock: a userspace
thread can be pre-empted at any instant. Use a futex-backed mutex (which spins briefly, then parks) and let
the kernel arbitrate. Raw spinlocks belong in the kernel, where the holder disables pre-emption and the
critical section is provably tiny.