AlgoMaster Logo

Race Conditions and Hardware Atomicity

20 min readUpdated August 7, 2026
Listen to this chapter
Unlock Audio

A backend service has four worker threads. Each thread increments a shared counter after completing a request. If every thread completes 500,000 requests, the final counter should be 2,000,000.

Yet an unsynchronized implementation may report a smaller value, even though no thread crashed and every increment statement ran.

The missing updates are caused by a race condition: the result depends on the relative timing of concurrent operations. Understanding this failure requires looking below a source-code statement, identifying the exact state transition that must be indivisible, and knowing what atomic operations the hardware can provide.

Valid Interleavings Under Concurrency

A thread executes an ordered stream of instructions, but the instructions of different threads can overlap.

On one CPU, the scheduler may preempt one thread and run another. On multiple CPUs, threads may execute at the same time. In both cases, their operations on shared state can be interleaved:

The scheduler does not preserve source-level operations as indivisible units. It may stop a thread between the machine instructions used to implement one statement. On a multicore machine, another core does not need to wait for that statement to finish unless the program uses a mechanism that requires it to wait.

Most interleavings are harmless because the threads use independent data or only read shared data. The danger appears when at least one execution path modifies shared state and correctness depends on the order of access.

A race condition exists when a program's correctness depends on which of several concurrent operations happens first.

A race does not require simultaneous execution. Preemption on a single CPU is enough to expose one.

The Lost-Update Race

Consider this increment:

The statement expresses one calculation, but it conceptually requires three steps:

Suppose the counter initially contains 40. Two threads can interleave their steps like this:

Starting from completed_jobs = 40:

StepThread AThread B
1load 40
2load 40
3compute 41
4compute 41
5store 41
6store 41

The shared value ends at 41, not 42. One increment was lost.

Both threads performed an increment, but the counter increased only once. Thread B's store overwrote the value produced by Thread A. This failure is called a lost update.

Other schedules happen to produce the expected result:

That variability makes races difficult to reproduce. A program can pass thousands of tests because it repeatedly receives a harmless schedule, then fail under a slightly different workload, CPU count, compiler optimization, or logging configuration.

The exact machine instructions generated for the C statement depend on the compiler and target architecture. The load–modify–store sequence is a conceptual model of the dependency. The important fact is that an ordinary increment does not ask the language or hardware to make the entire read-modify-write transition indivisible.

Loading simulation...

Race Conditions vs. Data Races

The terms race condition and data race are often used interchangeably, but they describe different levels of the problem.

A race condition is a broad correctness bug caused by timing. It can involve memory, files, signals, processes, devices, or a sequence of service calls.

A data race is a more specific kind of concurrent memory access. In the C and C++ model, it occurs when concurrent execution paths access the same memory location, at least one access modifies it, and the accesses are not coordinated as the language requires.

The distinction matters for two reasons.

First, a program can have a race condition without a data race. Suppose stock is an atomic variable:

Every individual access is atomic, so the accesses do not form an ordinary data race. The logic still races: two threads can both observe positive stock before either subtracts, then both reserve the last item.

Second, an unsynchronized shared counter in C is not merely “likely to lose increments.” It has a language-level data race, which gives the program undefined behavior. The compiler is not required to preserve the intuitive load-and-store behavior used in the earlier diagram.

The diagram explains the underlying lost-update pattern. It is not a promise that an invalid C program will fail only by producing a slightly smaller counter.

Invariant Protection with Critical Sections

A critical section is a region of code that accesses shared state and must not overlap with conflicting operations if the program is to remain correct.

For the counter, the critical section is the complete read-modify-write transition:

The sequence reads the current value, calculates the next value, and publishes it.

For more complex state, the boundary comes from an invariant: a condition that must remain true whenever other execution paths can observe the state.

Imagine moving one queued job from pending to running:

If the system requires pending + running to remain constant during the move, the two updates form one logical critical section. Making each assignment individually indivisible would not be enough. An observer that runs between them could see a state in which the total is temporarily too small.

Critical sections are therefore defined by correctness, not by syntax:

  • One source statement may contain several conflicting memory operations.
  • Several source statements may implement one state transition.
  • Code in different functions may participate in the same invariant.

The goal of synchronization is to ensure that conflicting operations observe a valid sequence of state transitions.

What Atomicity Means

An operation is atomic with respect to a set of observers if it appears to occur as one indivisible event.

For an atomic increment, every valid competing atomic observer sees either the value before the increment or the value after it. No competing increment can slip between the read and write portions of that operation.

An atomic increment takes the counter from 40 to 41 with no publicly visible state in between.

Atomic does not necessarily mean:

  • The operation completes in one clock cycle.
  • The CPU cannot receive an interrupt while implementing it.
  • Every nearby operation becomes part of the same transaction.
  • The operation is inexpensive under contention.

Atomicity is also scoped. A CPU instruction may atomically update one machine word, while the application needs an atomic transition across several fields. The smaller hardware guarantee does not automatically satisfy the larger application invariant.

The useful question is not simply “Is this atomic?” It is:

Which state transition is indivisible, and with respect to which competing observers?

Ordinary Loads and Stores

Processors provide some atomicity even for ordinary memory instructions, but the exact guarantee depends on the instruction-set architecture, access size, alignment, and memory type.

On common architectures, a naturally aligned load or store of a native word is normally observed without tearing. If one thread stores a 64-bit value atomically, another does not see a value assembled from half of the old bits and half of the new bits.

That statement has important limits:

  • Wider-than-supported accesses may require multiple instructions.
  • Misaligned accesses may cross hardware boundaries and have weaker guarantees.
  • Device memory can follow different access rules from ordinary cached memory.
  • The programming language may still classify unsynchronized concurrent access as invalid.

Most importantly, an indivisible load and an indivisible store do not make their combination atomic:

The lost-update example can occur even when neither the load nor the store tears. Both threads can read the same complete old value and then store the same complete new value.

Hardware atomicity must therefore be matched to the operation the program actually needs.

Atomic Read-Modify-Write Operations

Processors expose special mechanisms that let software read a value and conditionally or unconditionally update it as one atomic event.

Common atomic read-modify-write operations include:

  • Exchange: replace a value and return the old value
  • Fetch-and-add: add a number and return the old value
  • Test-and-set: set a value and report its previous state
  • Compare-and-swap: update a value only if it still equals an expected value

For a shared counter, fetch-and-add directly expresses the required transition:

atomic_fetch_add(counter, 1) returns the old value and atomically moves the shared state from that old value to one more than it.

If Thread A's fetch-and-add changes 40 to 41, Thread B's competing fetch-and-add must operate on either the state before or the state after A's operation. Both cannot successfully claim that the previous value was 40.

Conceptually, the two operations become serialized:

Starting at 40, thread A's atomic increment produces 41 and thread B's produces 42. No update is lost.

The threads can continue executing unrelated instructions in parallel. Only their conflicting atomic updates to this location must have a single observable order.

Compare-and-Swap

Compare-and-swap, commonly abbreviated CAS, performs a conditional state transition.

It takes:

  • A memory location
  • An expected old value
  • A desired new value

The operation atomically compares the current value with the expected value. If they match, it stores the desired value and reports success. If they do not match, it leaves the location unchanged and reports failure.

The failure path is not an error. It reports that someone else changed the value first, which is the information the caller needs in order to retry against fresh state.

CAS is useful when the next state must be calculated from the current state:

The calculation can happen outside the atomic operation. CAS validates that its input is still current at the moment of publication.

Some architectures provide compare-and-swap instructions directly. Others provide a load-linked/store-conditional pair or related exclusive-access instructions. These mechanisms differ at the instruction level but let the operating system, runtime, and compiler build atomic state transitions.

How a Multicore CPU Enforces Atomicity

Two cores can have cached copies of the same memory location. An atomic read-modify-write operation must prevent both from independently committing updates based on the same old state.

On modern cache-coherent systems, a core normally obtains exclusive ownership of the relevant cache line before completing the update. Other cores' conflicting requests are coordinated through the cache-coherence mechanism. The atomic operation becomes visible as one ordered modification of that location.

Architecture-specific instructions express this requirement. For example, x86 provides locked read-modify-write operations, while AArch64 provides exclusive-access sequences and atomic instructions.

The word “locked” does not mean that every atomic update freezes the entire processor. Modern implementations usually coordinate ownership of the affected cache line. Some unusual accesses or older implementations may use stronger machinery.

Contention is still real. If many cores repeatedly update one atomic counter, ownership of its cache line must move among them and the updates must be serialized. The final result is correct, but throughput may be much lower than for independent per-thread counters.

Atomicity guarantees correctness for the supported transition. It does not create parallel capacity for inherently conflicting updates.

Language-Level Participation

Writing code that happens to compile to one atomic hardware instruction is not a valid synchronization strategy. The compiler needs to know that an object participates in concurrent access.

Language atomic types and operations provide that contract. In C11, the header <stdatomic.h> exposes atomic objects and operations:

The compiler must preserve the atomic semantics of atomic_fetch_add. Depending on the type and target, it may emit a hardware instruction, an exclusive-access retry sequence, or a call to implementation support code.

A language-level atomic operation is not guaranteed to be implemented without an internal lock on every platform. Code can query whether a particular atomic object is lock-free when that property is a genuine requirement:

The correctness guarantee comes from using the language's atomic interface, not from assuming one particular instruction sequence.

A Complete Atomic-Counter Example

The following program creates four threads. Each performs 500,000 atomic increments:

Compile and run it on a POSIX system:

The completed pthread_join calls ensure all workers have finished before the result is printed. The expected output is:

Changing the declaration to an ordinary integer and replacing atomic_fetch_add with ++completed_jobs does not create a valid “faster version.” It creates a C data race and undefined behavior.

A race detector can often identify such a mistake during testing. With a compiler that supports ThreadSanitizer, an intentionally broken build can be instrumented with:

Tool availability varies by compiler and platform. A clean detector run is useful evidence, but it does not prove that every possible race has been eliminated.

Atomic Variables and Compound Logic

Return to the stock-reservation example:

If stock starts at 1, this schedule is possible:

StepThread AThread B
1load stock: 1
2load stock: 1
3condition is true
4condition is true
5subtract 1, stock becomes 0
6subtract 1, stock becomes -1

The bug is a check-then-act race. The check and the update must form one conditional transition.

CAS can express that transition:

If the comparison succeeds, the operation changed a positive value to one less as one atomic event. If another thread changed stock first, the comparison fails and updates observed with the current value. The loop then checks the newer state instead of acting on stale information.

This fixes the one-variable invariant: stock never becomes negative through this operation.

It does not make arbitrary surrounding work atomic. If a reservation must also update a customer record, an order object, and a billing state as one invariant, one CAS on stock is too small a boundary. The program needs a coordination mechanism whose protected region matches the complete state transition.

volatile vs. Thread Synchronization

Developers sometimes try to repair a race by declaring the shared object volatile:

This does not make ++completed_jobs atomic. It still consists conceptually of a read, a calculation, and a write that can lose updates.

In C, volatile tells the compiler that accesses to an object are externally observable and should not be removed or combined in certain ordinary ways. It is useful for specialized purposes such as some device-register access and narrowly defined communication with signal handlers.

It does not provide the thread-to-thread contract of _Atomic, and it does not turn a data race into valid C.

Race Conditions Beyond Shared Counters

The same timing pattern appears throughout an operating system and its applications.

A kernel data structure may be accessed by two CPUs, or by ordinary kernel execution and an interrupt handler. A process may inspect a pathname and later use it, while another process changes what that name refers to between the two operations. Two backend workers may both read a job as “unclaimed” and then both attempt to claim it.

These cases share one structure:

The decision is made against an observation that may already be out of date by the time it is acted on.

The durable repair is to remove the vulnerable gap. That may mean one atomic CPU operation, one protected critical section, or an operating-system or storage interface that performs the check and action as a single operation.

Adding a delay, retrying blindly, or hoping that one actor usually wins does not define a correctness guarantee.

Finding and Preventing Race Conditions

Race debugging starts by identifying shared mutable state. For every such object, determine who can read it, who can modify it, and which larger invariant it participates in.

Then inspect compound actions. Increments, “check then act,” lazy initialization, state transitions, and updates across multiple fields are common race sites because they look simpler in source code than they are at the machine level.

Practical techniques include:

  • Prefer immutable data or thread-owned data when sharing is unnecessary.
  • Use language atomic operations for state transitions they can represent completely.
  • Use a larger protected critical section when the invariant spans several operations.
  • Run race detectors and stress tests under different loads and CPU counts.
  • Treat failures that disappear under logging or debugging as possible timing bugs.

Testing alone cannot enumerate every schedule. The final argument for correctness must come from the synchronization guarantee: which interleavings are prevented, and why every remaining interleaving preserves the invariant.

Summary

A race condition occurs when correctness depends on the timing of concurrent operations. The classic lost update happens because an ordinary increment is a read-modify-write sequence, and two threads can compute from the same old value.

Atomic operations make a supported state transition appear indivisible. Hardware provides primitives such as fetch-and-add, exchange, and compare-and-swap; language atomic APIs give compilers and programs a valid way to use those guarantees.

Atomicity must match the application's invariant. Atomic loads and stores do not combine themselves into an atomic sequence, volatile does not provide thread synchronization, and individually atomic fields do not protect a multi-field transition. Correct synchronization begins by identifying the complete critical section that competing execution paths must not observe halfway through.

Quiz

Race Conditions and Hardware Atomicity Quiz

5 quizzes