Locks & Mutexes

Picture a single-occupancy café toilet with one door and one latch. Anyone can walk up, but the moment someone steps in and slides the latch, the little sign flips to OCCUPIED. The next person tries the handle, sees it's taken, and waits. When the occupant slides the latch back to FREE and leaves, exactly one waiting person goes in. One door, one latch, one occupant at a time — nobody ever collides inside.

That latch is a mutex (short for mutual exclusion), and it is the primary cure for the race conditions that plague shared data. A mutex is a lock: a tiny object with two operations — acquire() (also called lock()) and release() (also called unlock()) — that guarantees only one thread at a time may run the dangerous stretch of code in between.

mutex.acquire(); // slide the latch to OCCUPIED — waits here if someone's inside count = count + 1; // the critical section — now safely ours alone mutex.release(); // slide it back to FREE — let one waiter in

This page is about that one idea — a lock enforcing mutual exclusion over a critical section — explored from the door-latch down to the atomic instruction that makes it real.

Mutual exclusion and the critical section

The stretch of code that touches shared data — reading and writing count, pushing to a shared list, updating a bank balance — is the critical section. The whole danger of concurrency lives there: if two threads are inside it at the same time, their read–modify–write steps can interleave and one update vanishes. A lock's single job is to make that impossible.

Mutual exclusion is the guarantee: at most one thread is inside the critical section at a time. The lock is the mechanism that delivers it. To enter the critical section a thread must first hold the lock; while it holds the lock, every other thread that tries to acquire() is made to wait. When the holder release()s, one waiter is let through. Enter with the key, do your work, hand the key back.

Below is the whole life of a lock as a state machine. There are only two states — FREE and HELD — joined by two transitions: acquire() flips FREE → HELD, and release() flips HELD → FREE. Press play to watch one thread take the lock, a second thread block and join the wait queue, and the release wake it.

A mutex has an owner

This is the feature that makes a mutex a mutex and not just any counter: it has an owner. The thread that calls acquire() is recorded as the holder, and only that same thread is allowed to call release(). You can't lock a door for someone else to unlock — the person who slid the latch is the one who slides it back.

Ownership buys two things. It catches bugs — releasing a lock you never acquired is a programming error the runtime can detect and reject. And it enables reentrancy (below): because the lock knows who holds it, it can tell "the owner is asking again" apart from "a different thread is asking". This is exactly what a semaphore does not have — a semaphore is ownerless, and any thread may signal it. If you only want a lock, reach for a mutex, not a semaphore.

What if a function that holds the lock calls another function that also tries to acquire the same lock? With a plain mutex, the thread would block waiting for… itself. Frozen forever. A reentrant (or recursive) mutex fixes this by keeping a hold count alongside the owner: when the owning thread acquires again, the count just goes up (1 → 2 → 3), and it only truly frees the lock when the count returns to zero. So the golden rule becomes: a reentrant lock must be released exactly as many times as it was acquired. Handy for recursive code, but many experts avoid reentrant locks precisely because that "it's fine to re-lock" habit hides sloppy design.

How a lock is actually built: one atomic step

A lock is itself just a variable in shared memory — held = true. But setting that flag is its own read–modify–write, so it can race exactly like the data it's meant to protect. You cannot build mutual exclusion out of ordinary reads and writes. The hardware rescues us with atomic instructions — test-and-set and compare-and-swap — that fuse a read and a write into a single indivisible step that no thread can interrupt. That is the whole trick; a acquire() is a loop around one atomic operation.

// TEST-AND-SET: atomically read the old value AND write true, returning // the old value. The read+write is ONE unbreakable hardware step. function testAndSet(lock: { held: boolean }): boolean { const old = lock.held; // read ┐ these two are fused — lock.held = true; // write ┴ no thread can slip between them return old; } // A SPINLOCK: keep hammering test-and-set until WE were the one who // flipped it from false to true. Two threads can never both see false. function acquire(lock: { held: boolean }): void { while (testAndSet(lock)) { // it was already true => someone else holds it => spin and retry } } function release(lock: { held: boolean }): void { lock.held = false; // a single plain write is enough to let a spinner win }

Because exactly one thread can win the atomic testAndSet, exactly one thread gets past the while loop — everyone else keeps looping. That is mutual exclusion, bootstrapped from a single instruction the CPU promises is indivisible.

Spinlocks vs blocking mutexes: to wait, spin or sleep?

The acquire() above busy-waits: it spins in a tight loop, burning CPU cycles doing nothing but checking again. That is a spinlock. It sounds wasteful — and it is, if the wait is long — but it is exactly right when the critical section is very short: spinning for a few hundred nanoseconds is far cheaper than the thousands of cycles it costs to put a thread to sleep and wake it again. Spinlocks are also the only option in places where you can't sleep at all — inside an operating-system interrupt handler, for instance.

A blocking mutex takes the opposite bet. When it can't get the lock, instead of spinning it asks the scheduler to put the thread to sleep and hand the CPU to someone useful; when the owner releases, the scheduler wakes one sleeper. That wake-up costs real time, but the CPU wasn't wasted meanwhile. This is the right default when a critical section might be held for a while.

Two matching mistakes. First, spinning while holding a lock for a long time is a double crime: you waste your own core doing work slowly and every waiter wastes its core spinning behind you. Second, calling anything that might block or sleep — I/O, a network read, another blocking lock — while inside a spinlock defeats the entire point: the whole idea of a spinlock is "I'll be done in a flash," so a spinner that naps starves every other core waiting on it. Keep spinlock critical sections tiny and non-blocking.

Run it yourself: with the lock, always 2; without it, a lost update

The sandbox runs on one thread, so it can't produce a real race — which is perfect, because we can script the exact interleaving and make the bug happen every time. A tiny Mutex class (a held flag plus an owner) guards a shared counter. We run the worst interleaving both without the lock and with it, and print the results. Press Run ▶:

// A minimal mutex: a held flag plus the OWNER that acquired it. class Mutex { private held = false; private owner = ""; // one atomic test-and-set attempt: succeeds only if free tryAcquire(who: string): boolean { if (this.held) return false; // someone owns it => caller must wait this.held = true; this.owner = who; return true; } release(who: string): void { if (this.owner !== who) throw new Error(who + " does not own the lock!"); this.held = false; this.owner = ""; } } let count = 0; // NO LOCK: script the losing interleave — both threads READ before // either WRITES, so one increment is clobbered. function withoutLock(): number { count = 0; const regA = count; // A reads 0 const regB = count; // B reads 0 (both saw 0!) count = regA + 1; // A writes 1 count = regB + 1; // B writes 1 (clobbers A's update) return count; } // WITH LOCK: B's acquire fails while A holds it, so B must wait until // A releases. The two critical sections can no longer overlap. function withLock(): number { count = 0; const m = new Mutex(); console.log(" A tryAcquire -> " + m.tryAcquire("A")); console.log(" B tryAcquire -> " + m.tryAcquire("B") + " (A owns it, B blocks)"); let reg = count; reg = reg + 1; count = reg; // A's critical section m.release("A"); console.log(" A release, then B is woken:"); console.log(" B tryAcquire -> " + m.tryAcquire("B")); reg = count; reg = reg + 1; count = reg; // B's critical section m.release("B"); return count; } console.log("No lock => count = " + withoutLock() + " (LOST UPDATE — expected 2!)"); console.log(""); console.log("With lock:"); console.log("Final => count = " + withLock() + " (correct)");

Same two increments, same shared counter — the only difference is that the lock forbids the overlapping schedule. The unsynchronised run loses an update and ends at 1; the locked run serializes the two critical sections and reliably reaches 2.

Contention: the cost of everyone fighting for one lock

A lock's guarantee has a price. If a critical section can hold only one thread, then while one thread is inside, all the others are waiting — the work is serialized. When many threads pile up on the same lock, that's contention, and it can quietly erase the speed-up you bought eight cores for: eight threads sharing one hot lock spend most of their time queued behind it, not computing.

The tuning knob is granularity:

The engineering trade-off is real: coarse locking is correct and slow; fine-grained locking is fast and fiddly. The best lock is one held so briefly that contention rarely arises in the first place — which brings us to the golden rules.

The most common lock bug isn't exotic: it's an early return or a thrown exception in the middle of a critical section that skips right past the release(). The lock is now held forever, and every other thread that ever wants it blocks permanently — a frozen program with no crash to point at. The cure is to bind the release to the scope, not to a line you hope executes:

mutex.acquire(); try { doWorkThatMightThrowOrReturnEarly(); // whatever happens in here… } finally { mutex.release(); // …this ALWAYS runs }

Never write a bare acquire()release() pair with real logic between them unless nothing in that logic can throw or return early. When in doubt, use finally.

Sometimes — with lock-free (optimistic) concurrency. Instead of locking, a thread reads a value, computes its new version, and uses a single compare-and-swap to install it only if nobody changed it meanwhile. If someone did, the CAS fails and the thread simply retries. No thread ever blocks another, so there's no contention bottleneck and no deadlock — at the cost of trickier code and wasted retries under heavy contention. Beware its evil twin, the double-checked locking pattern (check a flag, then lock, then check again): it looks clever for lazy initialisation but is subtly broken without the right memory barriers, and has burned generations of programmers. Lock-free code is powerful and famously hard to get right — a whole field of its own.

Deadlock. Thread A locks mutex 1 then waits for mutex 2; thread B locks mutex 2 then waits for mutex 1. Each holds what the other needs, and both wait forever. Nothing is racing now — the program has simply frozen solid. The cure (acquire locks in a fixed global order, and a few related tricks) is a big enough topic to earn its own page. Locks are the primary cure for race conditions; using them carelessly invites the deadlock.