Concurrency¶
Scope: the module overview already named a data race and its two fixes in brief. This page goes one layer deeper on why those are the only two general-purpose fixes, what each one actually costs, and how to tell them apart when a bug report says "it works most of the time."
TL;DR — in 30 seconds:
- A data race needs all three: same location, concurrent access, no ordering, at least one write — two concurrent reads, or writes to different variables, are both fine.
- Only two general-purpose fixes exist: a lock (orders access in time, cost climbs under contention) or single-writer (orders by construction, no lock needed).
- A race can hide for thousands of runs — don't trust "it hasn't crashed"; use a race detector (ThreadSanitizer-style tooling).
1. What makes something a data race¶
A data race is two or more threads accessing the same memory location concurrently, with no ordering between the accesses, where at least one of those accesses is a write. All three conditions have to hold — two threads only reading the same value concurrently is fine; two threads writing different variables is fine; the race is specifically an unordered read/write or write/write pair on the same location.
That "no ordering" clause is what makes races so hard to catch by testing: the buggy interleaving might show up once in ten thousand runs, on one particular core count, under one particular load pattern. A data race is a property of the program, not of any one run of it — the absence of a crash today proves nothing about tomorrow.
sequenceDiagram
participant T1 as Thread 1
participant M as shared variable
participant T2 as Thread 2
T1->>M: read (0)
T2->>M: read (0)
T1->>M: write (1)
T2->>M: write (1)
Note over M: final value is 1, not 2 — one increment silently lost
2. The two fixes, and why there are only two¶
Once two threads must touch the same mutable state, there are exactly two general-purpose ways to make that safe:
| Fix | How it works | What it costs |
|---|---|---|
| Mutual exclusion (a lock) | Only one thread may hold the lock at a time; every access to the shared state happens while holding it | Coordination overhead that gets worse under contention — threads queue up and wait |
| Message passing / single-writer | Exactly one thread ever mutates the state; every other thread sends it a message asking for a change instead of touching the state directly | No lock is needed, because there's never more than one writer — but state changes are now asynchronous |
Both fixes remove the race the same way: they turn an unordered pair of accesses into an ordered one. A lock orders accesses in time (whoever gets the lock next goes next). Single-writer orders them by construction — there's only ever one writer, so a write/write race is structurally impossible, and every reader gets a consistent value because nothing else is mutating it underneath them.
flowchart TB
subgraph LOCK["Mutual exclusion"]
A1["Thread 1"] --> LK["lock"]
A2["Thread 2"] --> LK
A3["Thread 3"] --> LK
LK --> SV["shared state<br/>(one writer at a time, enforced at runtime)"]
end
subgraph SW["Message passing / single-writer"]
B1["Thread 1"] -->|message| OW["owner thread<br/>(only writer)"]
B2["Thread 2"] -->|message| OW
B3["Thread 3"] -->|message| OW
OW --> OS["its own state<br/>(one writer, enforced by design)"]
end
3. Why single-writer often wins¶
A lock is correct — but it's a runtime cost that gets paid on every access, and that cost climbs sharply as more threads contend for the same lock: each contending thread has to be parked and later woken, overhead that compounds as more threads pile onto the same lock.
Single-writer sidesteps that cost entirely rather than optimizing it: if only one thread ever writes, there is nothing to coordinate, so there is no lock to contend for and no queue to build up. The trade-off is that other threads no longer get to mutate the state directly — they hand the owning thread a message and get an eventual, not immediate, effect. That's a real constraint: single-writer fits naturally when one component logically owns a piece of state (a single event loop, a single aggregator), and fits poorly when several threads genuinely need to cooperatively read-modify-write the same value right now.
4. Catching races you didn't see¶
Because a race can hide for thousands of runs, code review and manual testing are unreliable ways to find one. Race detectors (ThreadSanitizer-style tooling, available across most mainstream languages) instrument every memory access at runtime and flag unordered read/write or write/write pairs directly, regardless of whether that particular run happened to produce a wrong answer — turning a race from "maybe visible under load, someday" into a build-time or test-time failure.
5. How this connects¶
- The memory-hierarchy page's cache-line mechanics (§2 there) are exactly what the false sharing and cache coherence pages build on next — those are races' close cousin: not a correctness bug, but a performance one caused by how shared writes propagate between cores.
- The exercise on this module's overview page — sketching a shared counter unsynchronized, behind a lock, and via single-writer — is the concrete, checkable version of §2 here: mark exactly where the race would occur in the unsynchronized version, and explain why it structurally cannot occur in the other two.
Common gotchas
- Trusting that a program "worked in testing" proves there's no race. Fix: a data race is a property of the program, not of any one run — use a race detector, not repeated manual testing.
- Reaching for a lock as the default fix. Fix: check whether one thread can just own the state (single-writer) first — a lock's coordination cost climbs specifically under contention.
- Assuming single-writer keeps updates synchronous. Fix: single-writer trades immediate mutation for an asynchronous message to the owning thread — it fits when one component logically owns the state, not when several threads need to read-modify-write it right now.
Check yourself — two threads read the same variable concurrently, with no writes involved. Is this a data race?
No. A data race requires at least one write among the concurrent, unordered accesses — two concurrent reads are always safe.
Check yourself — why does a lock's cost get worse specifically under contention, while single-writer's doesn't?
A lock makes contending threads queue, park, and get woken — overhead that compounds as more threads pile onto the same lock. Single-writer has no lock to contend for in the first place, so additional threads have nothing to queue behind.
You can defend this when you can state all three conditions that make an access pattern a data race (not just "two threads touching the same thing"); name the two general-purpose fixes and explain why each one restores ordering; and say, for a concrete piece of shared state, whether a lock or a single-writer design fits it better and why.