Concurrency Roadmap/Race Conditions & Critical Sections

Data Races & Shared Mutable State

The formal, tool-detectable defect behind almost every 'works on my machine' concurrency bug — two unsynchronized threads touching the same memory location where at least one is a write — and why it's a distinct (if closely related) concept from the broader 'race condition.'

!!2/5Theory: 1h 10m
Language-specific mechanics: Concurrency Language Manual — Concurrent Collections, Shared State & Debugging Tools

What a data race actually is

A data race occurs when two or more threads access the same memory location concurrently, with no synchronization ordering the accesses, and at least one of those accesses is a write. All three conditions matter: same location, no ordering, and a conflicting access (a read paired with a write, or a write paired with a write — two concurrent reads are always fine, since neither can corrupt what the other observes).

The precise word is "concurrently," and it means something narrower than "at the same nanosecond." Two accesses race if nothing in the program guarantees one happens strictly before the other from the perspective of the underlying memory model — no lock, no atomic operation, no other synchronization primitive establishing an ordering between them. This "before" relationship is usually called a happens-before relationship, formalized in depth in the Memory Models & Atomics topic later on this roadmap; for now, treat it as "some synchronization mechanism forces thread B's access to wait until thread A's access is complete."

This makes a data race a property of a specific execution — a specific interleaving of instructions across threads — not an abstract property of source code in isolation. A program can contain thousands of concurrent memory accesses and still be entirely data-race-free, as long as every pair of conflicting accesses is properly ordered. The goal is never "avoid concurrency" — it's "make sure every conflicting access is ordered."

Why shared mutable state is the root cause

A data race needs exactly three ingredients, and removing any one makes the race structurally impossible — not just unlikely:

IngredientWhat removing it looks likeWhere you'll see it later on this roadmap
SharingThread confinement — give each thread (or task) its own private copy of the state instead of one shared instanceActor-style designs, thread-local storage
MutabilityImmutability — once constructed, the state never changes, so there's nothing for a second thread to catch mid-updatePersistent/functional data structures, value objects
Unsynchronized accessSynchronization — force conflicting accesses into a happens-before relationshipLocks, atomics, and everything else on this roadmap from here on

This is why "avoid shared mutable state" is the single most repeated piece of advice in concurrent programming: it's a strictly easier bar to clear than "get synchronization right." Removing sharing or mutability eliminates the bug category at the design level; synchronization requires reasoning correctly about every access, forever, including every access added by future code changes.

Race condition vs. data race — related, but not identical

These two terms get used interchangeably in casual conversation, and conflating them is a reliable way to lose points in a senior-level interview.

  • A data race is the strict, execution-level, mechanically checkable definition above: two conflicting, unordered, concurrent accesses. Tools like ThreadSanitizer can detect it automatically because it has nothing to do with what the program is supposed to do — it's purely about memory accesses and ordering.
  • A race condition is a correctness property: the program's observable outcome depends on the relative timing or interleaving of operations, in a way that can produce a result you didn't intend. It's a semantic, human-defined notion of "wrong," not a mechanically checkable one.

Critically, neither implies the other:

  • A data race without a race condition. Two threads, unsynchronized, both write the exact same constant value to a variable. That's formally a data race (two unordered conflicting writes) — undefined behavior in languages like C/C++ regardless of outcome — but if both writes are always the same value, no interleaving produces a "wrong" result.
  • A race condition without a data race. A thread checks "is the queue empty?" under a lock, releases the lock, then acts on that answer. Every individual access here is properly synchronized — no data race exists anywhere in the trace — but another thread can modify the queue in the gap between the check and the act, making the first thread's decision stale. The logic is still timing-dependent even though nothing "raced" at the memory level.

The practical takeaway: eliminating data races (e.g. wrapping every access in a lock) is necessary but not sufficient for eliminating race conditions. You also have to reason about which sequences of operations need to be treated as a single atomic unit — the subject of the next subtopic.

Why a "single line" isn't the unit of atomicity

The most common source of an accidental data race is a read-modify-write operation that looks like one indivisible step in source code but isn't one at the machine level. A counter increment is the canonical example. Conceptually, it decomposes into three separate steps:

load r, counter # read the current value into a register add r, r, 1 # compute the new value store counter, r # write the new value back

Nothing prevents a scheduler from suspending a thread between any of these steps and running another thread that touches the same variable. Trace two threads racing on the same counter:

Thread A: load r=5 Thread B: load r=5 Thread A: add r=6 Thread A: store counter=6 Thread B: add r=6 Thread B: store counter=6

Two increments happened, but the counter only went up by one — a lost update. Nothing in this trace is exotic or requires unusual timing; it's simply one legal interleaving among many that the scheduler is free to produce.

The general lesson: any operation that combines "read the current state," "compute a new value from it," and "write the new value back" is a compound operation, and no amount of it looking like one statement or one line makes it atomic. Atomicity is a guarantee that has to be established explicitly — it's never a side effect of syntax.

Interleaving and non-determinism — why tests pass 999 times out of 1,000

With even two threads each performing a modest number of operations, the number of legal interleavings the scheduler could produce grows combinatorially. Only a small fraction of that enormous space actually exposes a given bug — the specific instant a context switch has to land for the lost update to occur is often a narrow window compared to everything else the two threads are doing.

On a given machine under a given load, the scheduler tends to produce similar interleavings run after run, because the conditions that trigger a context switch (timer interrupts, I/O waits, cache behavior) are themselves fairly stable. So a data race can sit dormant through code review, local testing, and hundreds of CI runs, then manifest exactly once — under production traffic, on a more heavily loaded machine, or after a garbage-collection pause lands at just the wrong moment. This is precisely why data-race bugs earn the name Heisenbug: the act of observing them (attaching a debugger, slowing execution down) often changes the timing enough to make them disappear.

This is also why "the test suite passed" is not evidence of the absence of a data race — only that the interleavings sampled that run didn't happen to expose one. Dynamic race detectors like ThreadSanitizer take a fundamentally different approach: instead of hoping a buggy interleaving occurs during a test, they instrument every memory access and directly check whether any two conflicting accesses lack a happens-before ordering — catching the bug even on a run where the outcome happened to be correct.

Pitfalls and interview gotchas

  • "It's just a boolean flag / one field" is not a safety argument. The size or apparent simplicity of shared state has nothing to do with whether concurrent unsynchronized access to it is safe — mutability plus sharing is the entire risk surface, independent of how small the update looks.
  • Confusing "the code is simple" with "the operation is atomic." A single high-level statement is a promise about readability, not about the machine instructions it compiles to. Always ask: does this decompose into more than one memory access?
  • Treating a passing test suite as proof of thread safety. Insufficient sampling of an enormous interleaving space cannot demonstrate the absence of a race — only a correctness argument or a race detector can.
  • Conflating "no torn reads" with "safe compound operations." A guarantee that a single aligned word never yields a partial value on read says nothing about a read-modify-write sequence built from multiple such operations.
  • Using "race condition" and "data race" as synonyms. Precisely distinguishing them — including giving an example of one without the other — is exactly the rigor that separates a strong answer at the senior+ level.

Where this leads

A data race tells you what can happen at the memory level; a race condition tells you whether it matters. Solving both requires a shared vocabulary for "this sequence of operations must not be interleaved with any other thread's conflicting access" and a precise definition of what a correct solution to that problem even looks like — which is exactly "the critical section problem," the subject of the next subtopic, and the direct predecessor to Locks & Mutual Exclusion, the next topic on this roadmap.

Reference implementations in:

Lost Update: Two Threads Incrementing a Shared Counter

The classic minimal data race — no locks, no atomics, just two threads and a plain mutable field.

var count int var wg sync.WaitGroup increment := func() { defer wg.Done() for i := 0; i < 100_000; i++ { count++ // load, add, store — NOT one step, and a data race } } wg.Add(2) go increment() go increment() wg.Wait() fmt.Println(count) // almost never 200000; `go run -race` flags this

count++ is a data race under the Go memory model: two unsynchronized concurrent accesses, at least one a write. Unlike Python there is no GIL to hide behind — this is undefined behavior, not just a lost update. go run -race reports it; the fix is a sync.Mutex around the increment or atomic.Int64.Add.

The One Place JS Can Race: SharedArrayBuffer + Workers

Ordinary JS values are copied between threads; SharedArrayBuffer is the deliberate opt-in exception that makes a real data race possible.

Not applicable in Go

Goroutines share the same address space by default — there's no opt-in step analogous to SharedArrayBuffer because every goroutine already sees the same heap unconditionally. The plain count++ example in the previous block already is the shared-memory case for this language. Go's closest 'opt out of sharing' is to pass values over a channel (copy) or spawn a separate process.

Further Resources (Optional)