Shared State & Races

Data Race Is Not Race Condition

Two different words for two different things, used interchangeably by almost everyone. A race condition is a logical bug: correctness depends on timing. A data race is a *memory model* violation: unsynchronized conflicting access to one location, at least one of which writes. You can have either without the other, and in C++ the second one is undefined behaviour rather than a wrong answer.

▶ Run the lab

The question this answers

The question

What exactly is the difference between a race condition and a data race, and why does the distinction change what I am allowed to assume?

The work

Two threads and one bool ready flag: a producer writes data then sets ready = true; a consumer spins on ready and then reads data. Contrasted with two threads performing a properly locked transfer between two accounts.

What is shared

The ready flag and the data buffer in the first case — both accessed by two threads with no synchronization. In the second case, two account balances, each accessed only under its own mutex.

The invariant — what must stay true under every interleaving

When the consumer observes ready === true, data holds the fully written payload. In the transfer case: the sum of the two balances is unchanged by any transfer.

Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.

WorkCan it overlap?Can it parallelise?What is shared?What ordering?What synchronization?Where is contention?What can deadlock?What can race?What is gained?What complexity?

Two definitions, held apart

A race condition is a property of your program's logic: the result depends on the relative timing of operations, and some timings give a wrong answer. It is defined by reference to *your* invariant. You can have one in a shell script, in a database transaction, between two microservices, or between a user's two browser tabs. No memory model is involved.

A data race is a property defined by a language's memory model. The usual formulation: two accesses to the same memory location, from different threads, at least one of which is a write, not ordered by any synchronization (no happens-before relationship between them). It is a statement about *memory operations*, not about your invariant — and crucially, whether it exists is decided by rules in the language standard, not by whether the output was wrong.

The consequence of that second point is the whole reason to keep the words apart. In C and C++ a data race is undefined behaviour. Not "you get one of two values". Not "the counter is off by one". Undefined: the compiler was permitted to assume it could not happen, and it optimised accordingly. A spin loop reading an unsynchronized flag may be hoisted out of the loop entirely, turning while (!ready); into if (!ready) for(;;); — an infinite loop that appears at -O2 and vanishes at -O0. That is not a timing bug; it is a compiler acting correctly on a promise you broke.

No data raceData race
No race conditionThe goal. Shared state accessed under a lock or an atomic, and the critical section spans the whole invariant.Possible but not useful: unsynchronized accesses whose outcome you genuinely do not care about — and in C++ still undefined behaviour, so "I do not care" is not a defence.
Race conditionDouble booking under a lock: every access synchronized, so no data race exists, and two customers hold seat H12 because the check and the act were separate regions. Race detectors report nothing.The classic unsynchronized counter++ from two threads: a lost update (race condition) *and* unsynchronized conflicting access (data race). Two distinct problems in one line.
The two concepts on their own axes, with an example in each quadrant

What each language actually says

The term "data race" is only meaningful relative to a memory model, and the four languages this domain compares have four different answers — one of which is "the concept does not arise here". That is why "is this a data race?" cannot be answered without naming the language.

The one to internalise: in C++ the consequence of a data race is not a wrong value, it is that the standard stops describing your program. In CPython and in single-isolate JavaScript you cannot produce a data race on ordinary objects at all, because the runtime never runs two of your operations on the same object simultaneously — and yet race conditions remain trivially easy to write in both. This is the sharpest evidence that the two concepts are independent.

One flag, one payload, two threads — and four different sets of rules — A producer writes a payload then signals a consumer through a shared flag.
C++LANGUAGE-SPECIFIC
1// DATA RACE — undefined behaviour under the C++ memory model.
2int data = 0;
3bool ready = false;
4
5void producer() { data = 42; ready = true; } // two plain writes
6void consumer() { while (!ready) {} print(data); } // plain reads, unsynchronized
7
8// The standard says: conflicting non-atomic accesses with no happens-before
9// relation => undefined behaviour. Legal outcomes include printing 0, hanging
10// forever because the compiler hoisted the load of 'ready' out of the loop,
11// or working perfectly at -O0 and failing at -O2.
12
13// FIXED — atomics create the happens-before edge the model requires.
14std::atomic<int> data{0};
15std::atomic<bool> ready{false};
16void producer() { data.store(42, std::memory_order_relaxed);
17 ready.store(true, std::memory_order_release); }
18void consumer() { while (!ready.load(std::memory_order_acquire)) {}
19 print(data.load(std::memory_order_relaxed)); } // prints 42

A data race is undefined behaviour, full stop. The fix is not "add a delay" or "make it volatile" — volatile is about device memory and gives no ordering. The fix is an atomic or a mutex, which establishes happens-before.

JavaScriptNODE.JS
1// Within one isolate: NO data race is possible on ordinary objects.
2let data = 0, ready = false
3async function producer() { data = 42; ready = true }
4async function consumer() { if (ready) console.log(data) }
5// Only one task runs at a time; nothing interleaves mid-statement.
6// A RACE CONDITION is still easy:
7async function consumer2() {
8 if (ready) { await audit(); console.log(data) } // data may have changed
9}
10
11// Across workers sharing a SharedArrayBuffer: data races ARE possible,
12// and Atomics is the only correct tool.
13const buf = new Int32Array(new SharedArrayBuffer(8))
14Atomics.store(buf, 0, 42) // ordered
15Atomics.store(buf, 1, 1) // release-ish; pair with Atomics.load

Two regimes in one language. Ordinary objects in one isolate cannot data-race because there is no simultaneity. SharedArrayBuffer between workers is real shared memory with a real memory model, and only Atomics operations are ordered.

TypeScriptNODE.JS
1// TypeScript adds no runtime concurrency semantics whatsoever.
2// The type system cannot express "this field is only read under lock L".
3interface Promo { remaining: number }
4const promo: Promo = { remaining: 1 }
5
6async function redeem(p: Promo) {
7 if (p.remaining > 0) { // check
8 await load() // <- the type checker is perfectly happy
9 p.remaining -= 1 // act on a stale fact: RACE CONDITION
10 }
11}
12// readonly / Readonly<T> are compile-time only and are erased at runtime;
13// they document intent and enforce nothing about concurrent access.

The same runtime as JavaScript, therefore the same answer: no data races within an isolate, and no help at all with race conditions. Type-level readonly is erased and must not be read as a concurrency guarantee.

PythonCPYTHON
1# CPython 3.12, threading module.
2data, ready = 0, False
3def producer():
4 global data, ready
5 data = 42
6 ready = True # each STORE_NAME is one bytecode: not torn
7
8def consumer():
9 if ready: print(data) # may print 0 if scheduled between the two stores
10
11# No torn values: the interpreter lock serialises bytecode execution, so you
12# never observe a half-written object reference. That is NOT the same as
13# atomicity of a statement:
14counter = 0
15def inc():
16 global counter
17 counter += 1 # LOAD_NAME, LOAD_CONST, BINARY_OP, STORE_NAME
18 # -> a switch between them loses updates.
19
20# Free-threaded builds (PEP 703, 3.13+ experimental, GIL disabled) change the
21# first guarantee: without the interpreter lock, ordinary attribute access
22# from multiple threads needs real synchronization.

CPython gives no torn reads or writes because bytecode execution is serialised, which removes the low-level data race on ordinary objects. Multi-bytecode statements are not atomic, so race conditions are as available as anywhere else — and the free-threaded build removes even the first guarantee.

What actually differs
  • C++ is the only one of the four where a data race is undefined behaviour rather than an unspecified value. That changes the fix from "tolerate it" to "it must not exist".
  • JavaScript and CPython eliminate data races on ordinary objects by construction — one isolate, or serialised bytecode — and eliminate exactly zero race conditions.
  • TypeScript contributes nothing at runtime. readonly, const and immutability types are erased; they are documentation for humans, not barriers for schedules.
  • Both JS and Python have an escape hatch back into real shared memory — SharedArrayBuffer with workers, and multiprocessing shared memory or free-threaded CPython — where the full memory model applies again.
  • The practical rule: if the language can give you two threads writing the same location simultaneously, you must reason about the memory model. If it cannot, you must still reason about interleavings.

A race condition with no data race

The quadrant people find hardest to believe is the one where every access is properly synchronized and the program is still wrong. It is not exotic — it is the most common shape in production code, because it is what happens when a team adopts a thread-safe collection and assumes the problem is solved.

In the schedule below, both threads use ConcurrentHashMap.get and put, or their equivalent in any language. Every access is atomic. Every access is ordered. A dynamic race detector — ThreadSanitizer, the Go race detector, Java's jcstress — will run this all day and report a clean bill of health, because there is no unsynchronized conflicting access anywhere. And the balance is wrong.

This is why the distinction is not pedantry. It determines which tool can find your bug. A race detector finds data races; it does not find race conditions. If your bug is in this quadrant, no amount of running under a sanitizer will surface it, and the only thing that will is the reasoning from Reasoning About Races: A Method, Not an Instinct.

Every access atomic and synchronized. No data race exists. The money is gone anyway.ILLUSTRATIVE
Invariant · balances["acct-7"] reflects every completed deposit
#Thread A — deposit 50 into acct-7Thread B — deposit 30 into acct-7State
1balances.get("acct-7") → 100 [atomic]·acct-7=100
2·balances.get("acct-7") → 100 [atomic]acct-7=100
3compute 100 + 50 = 150 [thread-local]·acct-7=100
4·compute 100 + 30 = 130 [thread-local]acct-7=100
5balances.put("acct-7", 150) [atomic]·acct-7=150
6·balances.put("acct-7", 130) [atomic]acct-7=130
✕ Two deposits totalling 80 were applied to 100; the balance is 130. A's deposit vanished — and every memory access in this trace was synchronized.
A race condition with no data race. ThreadSanitizer reports nothing because there is no unsynchronized conflicting access — the map made every individual operation atomic and the invariant spans two of them. The mirror case also exists: an unsynchronized read of a flag whose value you would have accepted either way is a data race with no race condition, and in C++ it is still undefined behaviour, which is why "the outcome does not matter" is never a valid defence there.

Key points

  • Race condition: correctness depends on timing. Defined against *your* invariant. Exists in shell scripts and between microservices.
  • Data race: unsynchronized conflicting access to one memory location from two threads, at least one a write, with no happens-before between them. Defined by the *language memory model*.
  • In C and C++ a data race is undefined behaviour — the compiler assumed it could not happen and optimised on that assumption. The symptom can be an infinite loop, not a wrong number.
  • A race condition with no data race is the common production case: a thread-safe collection makes each operation atomic and leaves check-then-act broken.
  • A data race with no race condition exists too, and in C++ it is still a bug, because "I do not care about the value" is not something the standard accepts.
  • The distinction decides tooling: race detectors find data races. They are blind to race conditions over properly synchronized accesses.
  • CPython and single-isolate JavaScript eliminate data races on ordinary objects and eliminate no race conditions at all.

The loop, answered

Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.

How it works
  • The language memory model defines a happens-before relation over memory operations, established by synchronization: lock acquire/release, atomic release/acquire pairs, thread start and join.
  • Two accesses to the same location from different threads *conflict* if at least one is a write.
  • If two conflicting accesses are not ordered by happens-before, the program contains a data race.
  • In C++ that program has undefined behaviour: the compiler may reorder, hoist, fuse, invent or eliminate the accesses, because it was entitled to assume no race existed.
  • A race condition is established separately, by evaluating your invariant against the reachable interleavings — a synchronized program with a badly chosen critical section has none of the first and plenty of the second.
Interleavings that matter
  • Unsynchronized flag: producer writes data=42 then ready=true; consumer sees ready=true and reads data=0, because the two plain stores were reordered or the load was hoisted. A data race, and undefined behaviour in C++.
  • Unsynchronized flag under -O2: the consumer's while (!ready) load is hoisted out of the loop and the program hangs forever. Same source, different optimisation level, no timing involved at all.
  • Thread-safe map deposit: A gets 100; B gets 100; A puts 150; B puts 130. A race condition with every access synchronized — no data race exists.
  • Locked transfer done correctly: A holds both locks in a fixed order, transfers, releases; B waits and observes a consistent pair. Neither a data race nor a race condition.
  • Atomic flag with release/acquire: producer stores data then releases ready; consumer acquires ready and is guaranteed to see data=42. The happens-before edge is what makes the read defined.
What it guarantees — and does not
  • A mutex guarantees both things at once: mutual exclusion (which addresses race conditions over the region) and a happens-before edge (which removes the data race). This is why "just use a lock" works when the region is right.
  • An atomic guarantees the absence of a data race on *that location* and nothing about the invariant that spans several locations.
  • A thread-safe collection guarantees each operation is atomic and ordered. It explicitly does not guarantee anything about a sequence of operations you perform on it.
  • A race detector guarantees, at best, that no data race occurred *on the schedules it observed*. It is a dynamic tool: unexecuted code and unobserved interleavings are outside its report.
  • CPython's interpreter lock guarantees no torn reads or writes of object references on the standard build. It guarantees nothing about multi-bytecode statements, and PEP 703 free-threaded builds withdraw even the first guarantee.
Where contention appears
  • Removing a data race by adding an atomic moves the cost onto the cache line: every write invalidates the line in every other core's cache. See False Sharing: Different Variables, Same Cache Line and What a Shared Write Costs.
  • Removing a race condition by widening a critical section moves the cost onto wait time. The two fixes have different costs because they are fixes to different problems.
  • Running under a race detector typically costs a large multiple in time and memory — commonly cited as roughly 5–15× slowdown for ThreadSanitizer — which is why it runs in CI on a subset rather than in production.
How it fails
  • Undefined behaviour (C/C++) — hoisted loads, eliminated stores, infinite loops, and behaviour that changes with optimisation level or compiler version.
  • Torn read or write of a value larger than the platform's atomic width, so a reader observes half of one value and half of another. See The Atomicity Illusion.
  • Stale visibility — the write happened, the other thread never sees it, and no amount of waiting helps because there is no happens-before edge. See Happens-Before: The Edge That Makes a Write Visible.
  • Lost update over a thread-safe collection — the race condition with no data race, invisible to every detector.
  • Terminology failure in the incident review: the team says "race condition", reaches for ThreadSanitizer, finds nothing, and concludes the bug is elsewhere.
When it helps
  • The distinction helps most when choosing tools: it tells you immediately whether a sanitizer can find this bug or whether you need reasoning and stress tests.
  • It helps in code review of C and C++, where "this access is unsynchronized but the value does not matter" must be rejected outright rather than debated.
  • It helps when porting between languages — the same source shape is UB in C++, benign in CPython, and reachable again in a free-threaded build.
When it hurts
  • When the distinction becomes vocabulary policing in a discussion where everyone already understands the failure. Name the schedule, then name the category.
  • When "there is no data race in this language" is taken as "this code is concurrency-safe" — the most dangerous misreading of the whole lesson.
  • When teams add atomics everywhere to silence a detector rather than fixing the invariant, producing code that is race-free, slower, and still wrong.
How you would know
  • Run a dynamic detector in CI on the concurrency-heavy test suite: ThreadSanitizer (-fsanitize=thread), the Go race detector, or jcstress for JVM memory-model questions.
  • Vary the optimisation level. A bug that appears at -O2 and disappears at -O0 is close to a proof of a data race rather than a timing race.
  • For race conditions, no detector applies: assert the invariant from an independent computation and stress test with more tasks than cores. See Stress Testing: A Test That Passed Once Proves Nothing.
  • Grep C and C++ for volatile used as a synchronization tool. It provides no ordering or atomicity between threads and is a reliable marker of a data race someone believed they had fixed.
Complexity it introduces
  • Keeping the two concepts distinct is genuine cognitive load, and the payoff only appears at debugging time — which is why teams collapse them and then lose a day to a sanitizer that reports nothing.
  • Memory-model reasoning (release/acquire, sequential consistency) is the steepest material in the domain and is required only where real shared memory exists. See What a Memory Model Defines.
  • A codebase spanning languages carries several answers at once: the C++ extension has UB risk, the Python layer above it does not, and the boundary between them is where the assumption breaks.
Simpler alternatives
  • Do not have shared mutable memory across threads: message passing removes data races by construction and leaves only the logical races to reason about. See Message Passing.
  • Use a mutex rather than hand-rolled atomics unless you have a measured reason. It fixes both problems at once and is far harder to get subtly wrong. See Mutexes: What They Protect and What They Do Not.
  • Use the language's ready-made atomic primitives (std::atomic, Atomics, AtomicLong) rather than volatile, raw pointers or hope.
  • For the logical half, push the invariant into a system that arbitrates it — a database constraint or a conditional update. See The Database Solves Concurrency For Its Data, Not For Your Memory.

Race detector lab

Race detector: what it catches and what it cannot
The left panel is a detector — it looks at accesses and locks. The right panel enumerates every schedule and checks the invariant. They do not always agree, and that disagreement is the lesson.
Scenario
Two tasks run count = count + 1 with nothing around it.
Detector · accesses observed
taskaccesslocationholding
Areadcount
Awritecount
Breadcount
Bwritecount
DATA RACE A.readB.write — read/write on count from different tasks with no common lock
DATA RACE A.writeB.read — write/read on count from different tasks with no common lock
DATA RACE A.writeB.write — write/write on count from different tasks with no common lock
Enumerator · 6 schedules explored
Invariant · count equals the number of increments that have completed
#Task ATask BState
1r1 = count·count=0 done=0
2·r2 = countcount=0 done=0
3count = r1 + 1·count=1 done=1
4·count = r2 + 1count=1 done=2
✕ count equals the number of increments that have completed — broken here
Shown: the first schedule that breaks the invariant. Every step is a legal execution — no compiler trick, no exotic hardware, just an ordering the scheduler is allowed to pick.
3 unsynchronized conflicting pairs on count, at least one of them a write. That is a data race by definition, and in C++ it is undefined behaviour rather than a wrong number. 4 of 6 schedules also break the invariant, so this one is a race condition too.
Both a data race and a race condition, which is why this example teaches so badly on its own: it lets people believe the two words mean the same thing.
Real detectors (ThreadSanitizer, Helgrind, Go’s -race) work on happens-before edges observed at runtime, so they only report races on code paths that actually executed, and they slow the program enough to change its timing. This model shows the reasoning, not their output.
3 data races4/6 schedules break the invariantSIMULATED

Both threads read 0, and both wrote first

Both threads read 0, and both wrote first
Two threads, two variables, four operations. Reason sequentially about it and one outcome is provably impossible: whichever store happened first, the other thread's load comes after it. Then let the load move up one line.
x = y = 0

Thread 1                Thread 2
    x  = 1;                 y  = 1;
    r1 = y;                 r2 = x;

Sequential reasoning: one of the two stores must land first,
so at least one load must see a 1. r1 == 0 && r2 == 0 is impossible.
0 of 6 schedules give (0,0)
r1=0, r2=0
never
r1=0, r2=1
1 schedule
r1=1, r2=0
1 schedule
r1=1, r2=1
4 schedules
Invariant · r1 == 0 && r2 == 0 cannot both hold — at least one thread must observe the other's store.
#Thread 1Thread 2State
1x ← 1·x=1 y=0 r1=0 r2=0
2r1 ← y·x=1 y=0 r1=0 r2=0
3·y ← 1x=1 y=1 r1=0 r2=0
4·r2 ← xx=1 y=1 r1=0 r2=1
As written, all 6 interleavings are enumerated above and none produces (0,0). That is what sequential consistency buys you: the program behaves as if there is one global order of operations that respects each thread's program order. Every argument you make about concurrent code by "walking through it" assumes this — and no mainstream language guarantees it for ordinary variables. The repair is not to reason harder about timing; it is to declare the ordering you need. A release store paired with an acquire load, a sequentially-consistent atomic, or an explicit fence turns the pair of accesses into a happens-before edge the compiler and the CPU must both respect. Ordering is something you *request*, and the cost of requesting it is the reason it is not the default.
CPU-SPECIFICWhether this reordering is observable depends on the processor, the compiler and the language memory model. x86 is strong and still permits exactly this case; ARM and POWER permit far more. The hardware mechanism — store buffers, invalidation queues, speculative loads — belongs to Computer Architecture; this lab only shows what the *program* can observe.

Publish a value, then a flag — which edge makes it visible?

Publish a value, then a flag
The writer fills in the data and sets a ready flag. The reader waits for the flag and reads the data. It looks airtight, and without a synchronization edge between the two threads it is not — no matter how long the reader waits.
Writer                          Reader
    data  = 42;                     while (ready == 0) { }
    ready = 1;                      use(data);
reader starts
edge
none
reader delay
immediately
values data may show
42 or 0
guarantee
none
Invariant · if the reader observes ready == 1, it observes data == 42.
#WriterReaderState
1data ← 42·data=42 ready=0 reader sees=—
2ready ← 1 (plain store)·data=42 ready=1 reader sees=—
3·read ready → 1data=42 ready=1 reader sees=—
4·read data → 0data=42 ready=1 reader sees=0
✕ the reader observed ready = 1 and data = 0 — it saw the flag that announces the write without seeing the write
Without an edge the reader can observe ready = 1 and data = 0. Both writes happened, in that order, in the writer's source. The reader simply has no relation to them: the two plain stores may be published out of order, and the reader's two plain loads may be satisfied out of order or from a stale cached value. Happens-before is a partial order the language defines over operations, built from program order plus specific synchronizing pairs — a lock release and the next acquire of the same lock, a release store and the acquire load that reads it, a thread start, a thread join, a channel send and its receive. Two operations with no path between them in that order are concurrent, and a data race on them is undefined behaviour rather than a stale value: the compiler is entitled to assume it never happens. So the question to ask of any cross-thread visibility argument is never "could the other thread really be that fast?" — it is which edge makes this visible?
SIMPLIFIEDModelled at the level of a language memory model: an edge exists or it does not. Which spellings create one is language-specific — release/acquire in C++ and Rust, a lock or a volatile write in Java, a channel send or a lock in Go.

What people believe, and what is true

Claim

Race condition and data race are two names for the same thing.

Reality

They are independent. Every combination exists, including the common and dangerous one: fully synchronized memory access with a broken invariant.

Claim

A data race just means you get one value or the other.

Reality

In C and C++ it means the standard no longer describes your program. Observed consequences include hoisted loads producing infinite loops and stores eliminated entirely.

Claim

volatile makes it thread-safe.

Reality

In C and C++, volatile prevents certain compiler optimisations on that access and provides no atomicity and no ordering with respect to other locations. It does not remove a data race. In Java, volatile does carry memory-model ordering — the same keyword, two different meanings.

Claim

ThreadSanitizer found nothing, so the code is concurrency-safe.

Reality

It found no data race on the schedules it ran. Logical races over synchronized accesses are outside its definition entirely.

Claim

Python has a GIL, so Python code is thread-safe.

Reality

The interpreter lock removes torn values and low-level data races on the standard build. x += 1 is still several bytecodes and still loses updates.

Go deeper

Overview

Race condition = wrong because of timing. Data race = unsynchronized access to the same memory, defined by the language. Different problems, different fixes, different tools.

Practical

If you write C or C++, a data race is not negotiable — it is UB and must be removed with an atomic or a lock. In JavaScript or standard CPython, spend your attention on race conditions instead; data races on ordinary objects are not reachable.

Advanced

The four quadrants are all inhabited. The one that costs teams the most time is race-condition-without-data-race, because the reflex is to reach for a detector that is definitionally blind to it.

Internals

A data race is defined via happens-before, which is a partial order built from program order plus synchronization edges (release/acquire pairs, lock release to subsequent acquire, thread start and join). "Unordered conflicting access" means these two operations are incomparable in that order. This is a *language* memory model; the CPU has its own, and the compiler must bridge them with fences. See What a Memory Model Defines, Happens-Before: The Edge That Makes a Write Visible and Memory Barriers Constrain Ordering, Not Caches.

Apply it