bigonotes
CS ConceptsOS Fundamentals

Spinlocks

Why a spinlock busy-waits instead of sleeping, when that beats a mutex, and why it only pays off on multi-core machines.

6 min readLast updated OS FundamentalsListen · Watch on NotebookLM

A spinlock does the same job as a mutex, protecting a critical section, but a waiting thread doesn't sleep. It stays on the CPU in a tight loop, checking the lock over and over until it's free. That sounds wasteful, and it usually is, except when the wait is shorter than the cost of sleeping and waking up.

Spin vs sleep#

The two locks differ only in what a waiting thread does when the lock is taken. Here is each one, attempt by attempt:

Attempt Mutex Spinlock
1 lock(): can I acquire? No. Go to sleep (Waiting). lock(): can I acquire? No. Check again.
2 Asleep; the OS wakes the thread later. Check again. No.
3 Pays the context-switch cost to wake. Check again. Yes, acquired.
Result Left the CPU and came back. Never left the Running state.

The spinning thread burns CPU cycles doing nothing useful. The reason anyone accepts that: sleeping isn't free either.

Operation Rough cost
Context switch to sleep ~1–10 µs
Context switch to wake ~1–10 µs
Sleep + wake total ~2–20 µs
Spinning 100 iterations ~0.05 µs

If the critical section takes 50 ns, paying ~20 µs of sleep/wake overhead to avoid a 50 ns wait is about 400× more expensive than just spinning.

The decision rule#

Compare the expected wait with the cost of a context switch. Whichever is smaller decides the lock:

Case Example Spinning wastes Sleeping wastes Winner
Wait < switch cost Lock held 50 ns, switch 5 µs 50 ns 5,000 ns Spinlock
Wait > switch cost Lock held 1 ms, switch 5 µs 1,000,000 ns 5,000 ns Mutex

Spinlocks don't remove the cost of waiting; they trade it. A mutex pays in context switches (time); a spinlock pays in burned CPU (compute). Pick whichever is cheaper for your wait.

Where spinlocks are used#

Spinlocks show up mostly below the application layer:

Layer Lock to use
Application code Mutex (almost always)
OS kernel Spinlock for very short critical sections, e.g. updating the scheduler's ready queue
Device drivers Spinlock, because interrupt handlers can't sleep

The kernel also can't build its own mutex out of a mutex. Sleeping locks need the scheduler, and the scheduler's own data structures need protecting, so the bottom layer has to be a lock that never sleeps.

For your application code (a web service, a REST API, a background worker), use a mutex. Critical sections there run for microseconds to milliseconds, so spinning would waste far more CPU than a context switch costs.

How a spinlock works across cores#

A spinlock is just a variable in shared memory that every core can see. There is one instance per lock, not one per core. You can create as many spinlocks as you need, typically one per resource you protect.

In memory it is a single word: lock = 0 means free and lock = 1 means taken. Core 1 and Core 2 both read and write that same word.

Here is how two threads on two cores take turns:

Step Core 1 (Thread 1) Core 2 (Thread 2)
1 Reads 0, sets 1, runs critical section Reads 1, reads 1, reads 1 ... (spinning)
2 ~50 ns later, sets lock = 0 Still spinning
3 Done Reads 0, sets 1, enters critical section

Why "read, then set" must be atomic#

If both cores read 0 and then both write 1, both enter the critical section. The fix is an atomic instruction such as compare-and-swap or test-and-set, which does the read and the write as one indivisible step:

With CAS(&lock, expected=0, new=1), two cores racing for the lock get different answers:

Core Call Result
Core 1 CAS(&lock, 0, 1) Succeeds, lock = 1
Core 2 CAS(&lock, 0, 1) Fails (lock is already 1), keeps spinning

Even if both execute CAS "at the same time", the hardware serializes them: one wins, one loses. Older CPUs did this by locking the memory bus; modern ones usually lock just the cache line through the cache-coherence protocol.

Here is a minimal spinlock in Python. A non-blocking acquire() on a threading.Lock behaves like test-and-set: it takes the flag if it is free and reports whether it succeeded, all in one atomic step.

import threading

class SpinLock:
    """Busy-waits instead of sleeping. A teaching example only."""

    def __init__(self) -> None:
        self._flag = threading.Lock()  # stands in for the atomic flag

    def acquire(self) -> None:
        # Keep looping while someone else already holds the flag
        while not self._flag.acquire(blocking=False):
            pass  # spin

    def release(self) -> None:
        self._flag.release()

lock = SpinLock()
total = 0

def work() -> None:
    global total
    for _ in range(1_000):
        lock.acquire()
        total += 1
        lock.release()

threads = [threading.Thread(target=work) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(total)  # 4000

In a real kernel or C implementation, the flag is an atomic variable, and the lock and unlock also use acquire/release memory ordering so the holder's writes are visible before the lock looks free. In Python, always use threading.Lock itself: with the GIL, a spinning thread even competes with the lock holder for the interpreter.

Why spinlocks are useless on a single core#

Spinning only helps if the lock holder can keep running at the same time. Compare what happens on one core and on two.

Single core#

  1. Thread 1 holds the spinlock and gets preempted (back to Ready, lock still held).
  2. Thread 2 runs and spins, waiting for Thread 1.
  3. Thread 1 can't run to release the lock: Thread 2 is using the only core.
  4. Thread 2 spins until its time slice ends.
  5. The OS switches back to Thread 1, which finally releases the lock.

All that spinning achieved nothing but a wasted time slice.

Multi-core#

  1. Thread 1 holds the lock on Core 1 and keeps running.
  2. Thread 2 spins on Core 2.
  3. Thread 1 releases ~50 ns later, and Thread 2 sees it immediately.

There is no context switch and no wasted slice.

Spinlocks only make sense when the holder can make progress while the waiter spins, i.e. on another core. Even on multi-core machines, if many threads contend for the same lock, most of them spin uselessly and you're back to the single-core problem. (This is also why kernels often disable preemption while holding a spinlock.)

Mutex vs spinlock#

The two locks do the same job and differ in what a waiter does.

Mutex Spinlock
Can't acquire Thread sleeps (Waiting) Thread loops (stays Running)
CPU while waiting None Burns CPU
Context switch Yes, sleep and wake None
Best for Longer critical sections (µs to ms+) Very short critical sections (ns)
Used in Application code OS kernel, device drivers
Needs multi-core? No Effectively yes

Many real mutexes are hybrids: they spin briefly in case the lock frees up quickly, then fall back to sleeping. For the three-way comparison with atomics, see the concurrency cheat sheet.

Key takeaways#

  • A spinlock busy-waits instead of sleeping, avoiding context switches at the price of burned CPU.
  • It wins only when the expected wait is shorter than a sleep/wake round trip, typically nanoseconds.
  • It is a single shared variable acquired with an atomic instruction (CAS or test-and-set).
  • On a single core, spinning can't help because the holder can't run; spinlocks need multiple cores.
  • In application code, use a mutex; spinlocks belong in kernels, drivers and other no-sleep contexts.

Found a mistake? Tell me on LinkedIn ↗

Further reading#