Hardware Support: Test-and-Set and Atomic Instructions

Two threads run on two different CPU cores. Both try to increment a shared bank balance at the same instant. Without help from the hardware, no amount of clever software can stop them. Both may read the old value, compute a new value, and overwrite each other's work. This is the classic race condition. It is the central problem that any concurrency primitive must solve. Software-only fixes like Peterson's Software Solution can enforce mutual exclusion. This works on a single processor with a sequentially consistent memory model. But such fixes are fragile and slow. They break down on modern multicore hardware with instruction reordering and caching. The real fix lives one layer down, in the CPU itself. Hardware designers give the operating system a small set of atomic read-modify-write instructions. These operations read a memory location, compute something, and write back a new value. This happens as a single, indivisible step. No other core can interleave with it. Test-and-set and compare-and-swap are the two workhorses of this toolbox. Essentially every lock, semaphore, and lock-free data structure in a modern operating system or runtime is built on one of them. This article explains precisely what "atomic" means at the hardware level. It explains why software alone cannot provide it. It also shows how test-and-set and compare-and-swap work as building blocks. We derive a correct spinlock from each primitive. We argue carefully why the resulting lock satisfies mutual exclusion, progress, and bounded waiting. We also examine the real performance costs — cache-line bouncing, memory bus traffic, and fairness. These costs separate a naive spinlock from a production-quality one. By the end you will be able to write a correct spinlock from scratch in pseudocode. You will be able to explain why test-and-set is not enough to build a fair lock without extra care. You will understand why compare-and-swap became the atomic instruction of choice. It is used for building both locks and modern lock-free data structures. This sets up the next natural topic. It concerns how operating systems layer full blocking locks, condition variables, and semaphores on top of these atomic primitives.

Key takeaways
  • Software solutions like Peterson's algorithm cannot scale or survive on real multicore hardware with weak memory models; mutual exclusion needs hardware help.
  • The CPU exposes atomic instructions — test-and-set, compare-and-swap (CAS), fetch-and-add — that read-modify-write a memory word as one indivisible step.
  • Test-and-set (TAS) atomically sets a lock word to 1 and returns its old value, enabling a simple but "dumb" spinlock.
  • Compare-and-swap (CAS) atomically checks if a value equals an expected value, and if so swaps in a new one — strictly more expressive, and the basis of most lock-free algorithms.
  • Correctness of a TAS/CAS spinlock follows from the hardware's own guarantee: exactly one core can win the atomic operation on any given attempt.
  • Naive spinlocks work but scale poorly due to cache-coherence traffic; real systems use backoff, ticket locks, or queue-based locks built from the same primitives.
The core insight

Atomicity — the hardware's guarantee that a read-modify-write happens as one indivisible step — is the one thing software cannot fake, and it is the true foundation of every lock.

The idea

A critical section is a code region that touches shared state and must not be executed by more than one thread at a time. A correct solution needs three properties: mutual exclusion (at most one thread in the critical section at once), progress (a thread not in its critical section cannot block others from entering), and bounded waiting (a thread requesting entry is not overtaken infinitely often). Peterson's algorithm achieves all three using only ordinary loads and stores to shared variables — but it relies on an assumption that real hardware routinely violates: that memory operations from different threads become visible to everyone in the exact order they were issued, with no reordering and no caching artifacts. On real multicore machines with per-core caches and out-of-order execution, a plain store followed by a plain load can appear, from another core's point of view, to happen in the opposite order unless the hardware is explicitly told otherwise. Peterson's algorithm silently breaks under this reordering. The hardware solves this by exposing a small number of atomic read-modify-write (RMW) instructions. An instruction is atomic if, from the point of view of every other core, it appears to execute as a single, uninterruptible step: no other core can observe an intermediate state, and no other core's competing RMW on the same memory location can interleave with it. The CPU implements this guarantee at the level of the cache-coherence protocol, typically by having the core that issues the instruction acquire exclusive ownership of the cache line before performing the read-modify-write, and holding that ownership until the write commits. Two atomic instructions dominate real systems:

  • Test-and-Set (TAS): atomically writes 1 (or `true`) into a memory location and returns the location's previous value, in one indivisible step.
  • Compare-And-Swap (CAS): atomically compares a memory location's current value to an expected value; if they match, it writes a new value into the location; either way it returns (or reports) whether the swap happened. CAS is strictly more powerful than TAS — it can simulate TAS and also implement arbitrary optimistic updates, which is why it underlies most modern lock and lock-free algorithms.

A related but weaker primitive, fetch-and-add, atomically adds a value to a memory location and returns the old value; it is useful for counters and ticket locks. All of these instructions share the same essential property: the hardware, not the compiler and not the programmer, guarantees indivisibility, and this is exactly the missing ingredient that made pure software solutions like Peterson's algorithm fragile.

How it works

Test-and-Set as a hardware primitive. Conceptually, TAS is a single CPU instruction with the following atomic semantics: ``` // TAS executes as ONE indivisible hardware step. // No other core can observe or interleave with it. function TestAndSet(lock: pointer to boolean) -> boolean: old := lock // read lock := true // write return old // to the caller, both steps are atomic ``` Building a spinlock from TAS is now straightforward: a thread spins, repeatedly calling TAS, until it observes that the lock was free (old value `false`) at the moment it grabbed it. ``` // Lock word, shared, initialized to false (unlocked) shared lock: boolean := false procedure acquire(lock): while TestAndSet(lock) == true: // someone else already held it; spin and retry continue procedure release(lock): lock := false // plain store is fine; releaser owns the lock ``` The key insight is that `TestAndSet` returning `false` can happen for at most one thread among all concurrent callers, because the hardware serializes the RMW on that memory location: whichever thread's TAS instruction executes "first" (in the hardware's total order for that location) is the only one that can see the old value `false`; every other concurrent caller is guaranteed to see `true`, either the pre-existing `true` or the `true` just written by the winner.

Compare-And-Swap as a hardware primitive. CAS generalizes this idea to arbitrary values, not just booleans: ``` // CAS executes as ONE indivisible hardware step. function CompareAndSwap(location: pointer, expected: value, new: value) -> value: old := location if old == expected: location := new return old // Caller checks: "old == expected" tells you whether the swap happened. ``` A CAS-based spinlock looks almost identical to the TAS version, but now the lock word distinguishes "free" (0) from "held" (1) using an explicit compare: ``` shared lock: integer := 0 // 0 = free, 1 = held procedure acquire(lock): while CompareAndSwap(lock, 0, 1) != 0: // CAS failed: lock was already 1 (held) — retry continue procedure release(lock): lock := 0 ``` CAS is strictly more powerful than TAS because it lets a thread implement an optimistic retry loop: read the current value, compute a new value based on it, and only commit if nothing changed in between. This pattern underlies non-blocking stacks, queues, and counters: ``` // Lock-free increment of a shared counter, no lock needed at all procedure atomicIncrement(counter: pointer to integer): repeat: old := counter new := old + 1 until CompareAndSwap(counter, old, new) == old // if CAS failed, another thread updated counter first — retry with fresh 'old' ``` Note the failure mode: CAS can fail spuriously in the sense that some other thread's write slipped in between the read and the CAS, but it can never* fail silently in a way that loses an update — the loop simply retries with a fresh read. This retry-until-success pattern is the essence of lock-free programming.

Why release does not need to be atomic. In both designs, `release` is a plain store. This is safe because only the thread currently holding the lock is permitted to call `release`, so there is no concurrent writer to race against — the "atomic" part of the protocol is entirely about acquiring the lock, where multiple threads truly race. ```pseudocode // Generalized spinlock built on either primitive (RMW is the abstraction) procedure acquire(lock): while RMW(lock) indicates "already held": pause_or_backoff() // yield / exponential backoff, see below // invariant restored: this thread now holds the lock exclusively procedure release(lock): store(lock, FREE) // ordinary atomic store, no RMW needed ```

Figure
Test-and-Set spinlock: two cores racing to acquire the same lock word
lock = false shared memory word Core A (thread 1) TAS(lock) → false wins: enters critical section lock now = true Core B (thread 2) TAS(lock) → true loses: spins (busy-wait) retries TAS(lock) Hardware serializes both TAS calls on this cache line — only one can read the "old" value false

Complexity & correctness

Time complexity. A single TAS or CAS instruction takes O(1) time — it is one hardware instruction, though its latency is higher than an ordinary load or store because it must acquire exclusive cache-line ownership across the coherence protocol (often tens of cycles under contention, versus a few cycles for a normal memory access). The `acquire` loop's running time is not O(1) in general: under contention, a thread may spin for an unbounded number of retries in the worst case if the scheduler is adversarial (e.g., other threads keep grabbing the lock first). In the uncontended case — no other thread wants the lock — `acquire` costs exactly one atomic instruction, O(1). Space overhead is O(1) per lock: a single word.

Correctness — mutual exclusion. The argument rests entirely on the hardware's atomicity guarantee, which we take as an axiom of the machine: for any single memory location, all RMW operations issued by different cores are totally ordered, and each one's read sees exactly the effect of all RMWs before it in that order, with no other operation interleaved inside it. Claim: at most one thread can be inside the critical section at any time. Proof sketch. Consider the sequence of TAS calls on `lock`, ordered by the hardware's serialization. The very first TAS call after `lock` was last `false` reads `false` and writes `true` — call this thread the winner; it proceeds into the critical section. Every other TAS call, until the winner calls `release` and sets `lock` back to `false`, must occur after the winner's TAS in the hardware's total order (because the winner's TAS already set the word to `true`, and by atomicity no call can "skip over" this write). Each of these later calls therefore reads `true` and loops. Hence exactly one thread can hold the lock between an `acquire` and its matching `release`, establishing mutual exclusion by induction on the sequence of acquire/release pairs. The identical argument applies to CAS, substituting "reads `expected` and writes `new`" for "reads `false` and writes `true`."

Progress. If no thread holds the lock (`lock == false`/`0`), some thread attempting `acquire` will succeed on its very next TAS/CAS call, because the RMW is unconditional — it does not depend on any other thread's future behavior. This satisfies the progress requirement: the decision of who enters next is made only among threads that are actually trying to enter, in bounded time (one instruction), not deferred indefinitely by a thread outside the critical section.

Bounded waiting — the honest tradeoff. Plain TAS/CAS spinlocks do not guarantee bounded waiting: with an adversarial scheduler, one thread could in principle keep re-losing races to other threads forever (spinlock starvation), since which of several simultaneously-spinning threads wins the next TAS is unspecified by the hardware — it depends on arbitration order in the coherence protocol, which is not required to be fair. This is fixed at a higher layer: a ticket lock (built from fetch-and-add) or an MCS/CLH queue lock (built from CAS) adds explicit FIFO ordering on top of the same atomic primitives, restoring bounded waiting. This is the natural next construction on the road to full Locks and Mutual Exclusion.

Why software alone cannot do this. Peterson's algorithm relies on the compiler and CPU preserving program order for ordinary loads/stores, and on writes becoming visible to other cores promptly and in order. Modern CPUs reorder instructions and buffer writes in per-core caches for performance; enforcing the necessary ordering purely in software would require the compiler and hardware to insert full memory fences around every shared access, which is exactly as expensive as — and strictly less general than — just providing atomic RMW instructions directly. Hardware support is not a convenience; it is the only way to get a genuine indivisibility guarantee at reasonable cost.

Worked example

Trace three threads, T1, T2, T3, contending for a CAS-based lock (`lock` starts at 0 = free), with instructions interleaved by the hardware's arbitration in the order shown.

StepThreadOperationSees `lock` beforeCAS resultNew `lock`Outcome
1T1`CAS(lock, 0, 1)`0success (old=0)1T1 enters critical section
2T2`CAS(lock, 0, 1)`1fail (old=1≠0)1 (unchanged)T2 spins, retries
3T3`CAS(lock, 0, 1)`1fail (old=1≠0)1 (unchanged)T3 spins, retries
4T1`release()` → `lock := 0`——0T1 leaves critical section
5T3`CAS(lock, 0, 1)`0success (old=0)1T3 enters critical section
6T2`CAS(lock, 0, 1)`1fail1T2 spins, retries again
7T3`release()` → `lock := 0`——0T3 leaves critical section
8T2`CAS(lock, 0, 1)`0success (old=0)1T2 finally enters (after 3 failed attempts)

Two things this trace makes concrete. First, at every step at most one thread ever holds the lock — mutual exclusion visibly holds because every failed CAS reads exactly the value the current holder wrote. Second, T2 is repeatedly overtaken by T3 at step 5 purely because of arbitration order, not because of anything T2 did wrong — this is the concrete manifestation of the bounded-waiting gap discussed above. A ticket lock would prevent step 5 from happening out of arrival order by handing out sequence numbers atomically with fetch-and-add and only letting the thread whose ticket matches a "now serving" counter proceed.

Common pitfalls & traps

PitfallWhy it happensFix
Treating a plain `if (lock == false) lock = true;` as atomicTwo ordinary instructions can be interleaved by another core between the read and the writeUse an actual hardware RMW instruction (TAS/CAS), never separate load+store
Forgetting the ABA problem in CAS-based lock-free codeCAS only checks the value, not history; a location can go A→B→A and CAS wrongly thinks nothing changedUse tagged/versioned pointers (value + counter) or hazard pointers
Assuming TAS/CAS spinlocks are fair (FIFO)Hardware arbitration order is unspecified, not first-come-first-servedUse ticket locks or MCS queue locks for fairness guarantees
Busy-waiting without backoff under high contentionEvery failed TAS/CAS still invalidates other cores' cached copies of the lock line, flooding the coherence busAdd exponential backoff, or test-and-test-and-set (spin on a plain read first)
Using CAS/TAS for long critical sectionsSpinning wastes CPU cycles proportional to how long the section takesPrefer blocking locks (futex-based) for long sections; reserve spinlocks for short ones
Missing memory-ordering annotations around the RMWSome architectures need explicit fences even with atomic instructions to order surrounding accessesUse the language's documented atomic/memory-order semantics, not ad hoc assumptions

Pioneers & history

Origins · 1 minEdsger W. Dijkstra and how this was discovered
EW
Edsger W. Dijkstra
1930–2002 · Formalized the mutual-exclusion problem and semaphores, the conceptual scaffolding atomic hardware instructions were later built to support

Dijkstra's 1965 work on the mutual exclusion problem, and his subsequent invention of the semaphore, established the requirements — mutual exclusion, progress, bounded waiting — against which every hardware and software synchronization mechanism is still judged today.

History
1965
— Dijkstra publishes the mutual exclusion problem and proposes semaphores as a solution abstraction.
1972
— IBM System/370 introduces the Compare-and-Swap (CS) instruction directly into its instruction set architecture.
1981
— Leslie Lamport and others formalize correctness criteria (mutual exclusion, progress, bounded waiting) for concurrent algorithms.
1991
— Herlihy proves compare-and-swap is universal for wait-free synchronization among an unbounded number of processes, while test-and-set is not.
2000
s — Multicore CPUs make cache-coherent atomic instructions (CAS, LL/SC) a standard, load-bearing part of every mainstream ISA (x86 `CMPXCHG`, ARM `LDXR/STXR`, RISC-V `LR/SC`).

Where this matters

Every operating system kernel uses spinlocks built on TAS or CAS to protect short, performance-critical data structures such as scheduler run queues, interrupt-handling state, and page tables, precisely because these sections are too short to justify the overhead of a full blocking lock. User-space threading libraries (pthreads mutexes, Java's `synchronized`, Go's `sync.Mutex`) implement their "fast path" — the uncontended, common case — with a single CAS instruction, only falling back to the operating system's blocking mechanism (futex on Linux) when contention is detected. Lock-free and wait-free data structures — concurrent queues, stacks, hash tables used in high-throughput databases and in-memory caches — are built almost entirely on CAS loops of the pattern shown above, because they avoid the possibility of one thread blocking all others even during a scheduler preemption. Garbage collectors in managed runtimes (JVM, Go runtime) use CAS to implement lock-free allocation paths and to coordinate concurrent marking. Modern instruction sets also provide load-linked/store-conditional (LL/SC) as an alternative to CAS on ARM and RISC-V, avoiding the ABA problem inherent to plain CAS, but the underlying design principle — an atomic hardware read-modify-write as the indivisible unit of synchronization — is identical, and it is the bedrock on which the entire discipline of Locks and Mutual Exclusion and concurrent programming is built.

Ask the tutor: How would you build a fair ticket lock using fetch-and-add, and why does it guarantee bounded waiting where a plain CAS spinlock does not?

Why can't we just use a regular boolean variable and an `if` statement to build a lock?

A plain `if (lock == false) { lock = true; }` compiles to at least two separate machine instructions: a load and a store. Between those two instructions, another core can execute the same load, also see `false`, and also proceed to set `lock = true` — both threads believe they acquired the lock. This is a textbook race condition. The only way to prevent it is to make the check-and-set a single, indivisible hardware operation, which is exactly what test-and-set and compare-and-swap provide. Software running on top of ordinary loads and stores simply cannot recreate this indivisibility, because the CPU is always free to interleave another core's instructions between any two of yours unless the hardware itself refuses to allow it.

Is Peterson's algorithm actually broken on real machines, or just theoretically fragile?

It is genuinely broken on real machines, not just fragile in theory. Peterson's algorithm assumes that writes become visible to other cores in program order and that reads see the most recent write. Modern CPUs with store buffers and out-of-order execution, plus optimizing compilers, routinely violate this assumption unless explicit memory fences are inserted. Documented failures of Peterson's algorithm on real x86 and ARM hardware, without added fences, are a standard demonstration in operating systems and computer architecture courses. This is precisely why hardware vendors provide atomic RMW instructions with well-defined ordering guarantees, rather than leaving synchronization entirely to software.

Does using an atomic instruction guarantee good performance, or just correctness?

Only correctness, not performance. TAS and CAS guarantee that the read-modify-write appears indivisible, which is enough to prove mutual exclusion. But under heavy contention, many cores hammering the same cache line with atomic instructions generates enormous cache-coherence traffic, since each attempt (successful or not) typically requires exclusive ownership of that line. This can make a naively-written spinlock dramatically slower than a well-designed one. Real systems add techniques like exponential backoff, test-and-test-and-set (spin on a cheap local read first), or queue-based locks (ticket locks, MCS locks) that reduce contention on the shared line while still relying on the same underlying atomic instruction.

What is the ABA problem, and why does it matter for compare-and-swap?

CAS only checks whether a memory location's current value equals the expected value — it has no memory of the location's history. If a location holds value A, another thread changes it to B and then back to A before the first thread's CAS runs, the CAS succeeds even though the world has changed underneath it (for example, a pointer might now point to a freed-and-reallocated node with the same address). This is the ABA problem, and it can silently corrupt lock-free data structures like stacks and queues. The standard fixes are tagging values with a version counter that always increases, using double-word CAS to swap both the value and a tag atomically, or using load-linked/store-conditional, which detects any intervening write to the location, not just a value mismatch.

Why do some architectures use load-linked/store-conditional (LL/SC) instead of compare-and-swap?

LL/SC splits the atomic update into two instructions: `LL` reads a value and marks the cache line as "watched," and `STC` writes a new value only if no other core's write touched that line since the `LL`. This sidesteps the ABA problem entirely, because `STC` fails on any intervening write, not just one that changes the value back. ARM and RISC-V use LL/SC as their primitive RMW building block, and CAS is then implemented as a short retry loop around LL/STC. x86, by contrast, provides a direct hardware `CMPXCHG` instruction. Both approaches provide the same atomicity guarantee; they differ in implementation and in their susceptibility to ABA-style bugs.

How do these hardware primitives connect to the operating system's blocking locks and semaphores?

Atomic instructions solve the correctness half of synchronization — guaranteeing indivisible updates — but they do not by themselves decide what a losing thread should do while waiting: spin forever, or give up the CPU. Full-featured blocking locks use a TAS/CAS instruction for the fast, uncontended path, and fall back to an operating-system call (like Linux's futex) that puts the waiting thread to sleep and has the scheduler wake it later, avoiding wasted CPU cycles during long waits. Semaphores and condition variables are built the same way: an atomic counter update for the fast path, with kernel support for blocking and waking threads when the fast path cannot proceed immediately.

Is a spinlock ever actually the right choice, given all these fairness and contention caveats?

Yes, for short critical sections on multiprocessor systems where the expected wait is shorter than the cost of a context switch. Since a spinning thread keeps its CPU core, if the lock will be released in, say, a few hundred cycles, spinning can be faster than sleeping and being woken by the scheduler, which typically costs thousands of cycles. This is exactly why operating system kernels use spinlocks to protect very short sections like interrupt handlers and scheduler queues, while using blocking mutexes or semaphores for anything that might hold the lock for a long time, such as I/O operations.

Before this

Where this leads