You already met the machinery in Thread Sanitizer (TSan) : each thread carries a vector clock V t , every synchronization event merges clocks, and a race is write + concurrent . This page does nothing new theoretically — instead it runs the machine by hand on every kind of case the topic can throw at you, so no scenario ever surprises you.
Intuition What we are actually computing on each example
A vector clock V t is just a little row of counters, one number per thread . The number V t [ u ] means "the highest event index of thread u that thread t is guaranteed to already know about." When thread t touches a byte and finds an old access by u tagged with u -time k , the single question is: is V t [ u ] ≥ k ? If yes, that old access is in t 's past — safe. If V t [ u ] < k and a write is involved — race.
We will write clocks as ⟨ A , B ⟩ (thread A's counter, thread B's counter) for two-thread cases, extending to three when needed.
Every data-race question is one cell of this grid. The examples below are labelled with the cell they hit.
#
Cell (case class)
Access kinds
Ordering present?
Race?
Example
C1
Both read , no sync
read + read
—
No
Ex 1
C2
Write + write , no sync
W + W
none
Yes
Ex 2
C3
Read + write , no sync
R + W
none
Yes
Ex 3
C4
Write + write, mutex ordered
W + W
release→acquire
No
Ex 4
C5
Write + read, thread join ordered
W + R
join edge
No
Ex 5
C6
Degenerate: same thread twice
W + W
program order
No
Ex 6
C7
Transitive order via 3 threads
W + R
A → B → C
No
Ex 7
C8
Boundary: volatile / relaxed without ordering
W + W
none
Yes
Ex 8
C9
Real-world word problem (bank balance)
W + W
depends
decide
Ex 9
C10
Exam twist: order edge on the wrong object
W + W
mismatched lock
Yes
Ex 10
The matrix has three axes: (a) what the two accesses are (RR / RW / WW), (b) whether a happens-before edge exists, and (c) if an edge exists, does it actually connect these two accesses. Every real bug is a failure on axis (b) or (c).
Worked example Ex 1 — Cell C1: two readers, no synchronization
int g = 42 ; // never written after startup
void* reader ( void* ){ return ( void* )( long )g; } // two threads read g
Forecast: two threads both touch g. Does the "same memory + concurrent" part alone make a race? Guess before scrolling.
List the two accesses. A reads g; B reads g. Why this step? The formal rule from Data Race vs Race Condition needs the access kinds first — that is axis (a).
Check the "at least one write" clause. Both are reads. Why this step? A read never changes the stored value, so the final value of g is 42 regardless of who went first.
Apply the rule. (write involved?) = false, so the whole conjunction is false — no race , even though nothing orders them.
Verify: final value of g is order-independent: 42 in both interleavings. TSan is silent. ✓
Worked example Ex 2 — Cell C2: two writers, no synchronization (the canonical race)
int counter = 0 ;
void* w ( void* ){ counter ++ ; return 0 ; } // A and B each run once, no lock
Forecast: both threads write counter. What are their vector clocks when they collide, and which inequality fires?
Initialize clocks. Both start at V A = ⟨ 0 , 0 ⟩ , V B = ⟨ 0 , 0 ⟩ . Why this step? No sync has happened, so neither thread knows anything about the other.
B writes first. B ticks its own entry: V B = ⟨ 0 , 1 ⟩ . The shadow cell for counter records "written by B at B-time k = 1 ." Why this step? Step-1/2 of the Vector Clocks rules: local tick on the event.
A writes next. A looks at the recorded access: it belongs to B at k = 1 . A checks V A [ B ] . Since A never acquired anything B released, V A [ B ] = 0 .
Fire the rule. V A [ B ] = 0 < k = 1 and a write is involved ⇒ race .
Verify: 0 < 1 is true and both accesses are writes ⇒ conjunction true. This matches the parent note's textbook race. ✓
Worked example Ex 3 — Cell C3: one reader, one writer, no synchronization
bool ready = false ; // written by A
// A: ready = true;
// B: while(!ready){} // spins reading ready
Forecast: only one side writes. Is one write enough, or do you need both to write?
Classify. A writes ready (tick → V A = ⟨ 1 , 0 ⟩ , shadow says "W by A at k = 1 "). B reads ready. Why this step? Axis (a): the rule says at least one write, not two.
B's clock when it reads. B never acquired anything from A, so V B [ A ] = 0 .
Fire the rule. Write involved (A's write) and V B [ A ] = 0 < k = 1 ⇒ race .
Verify: 0 < 1 true, write present ⇒ race. Note this is also a broken spin-lock — see std::atomic and Memory Ordering for the correct ready.store/load fix. ✓
Worked example Ex 4 — Cell C4: writers ordered by a mutex (the fix)
pthread_mutex_t m;
// A: lock(m); counter++; unlock(m);
// B: lock(m); counter++; unlock(m); // A's unlock happens before B's lock
Forecast: same two writes as Ex 2. Which merge operation makes the inequality flip from < to ≥?
A ticks and writes. V A = ⟨ 1 , 0 ⟩ ; shadow: "W by A at k = 1 ." Why this step? Same start as the racing case — the only difference will be the mutex edges.
A unlocks (release). Copy A's clock into the lock: V m ← V m ⊔ V A = ⟨ 1 , 0 ⟩ . Why this step? Release rule from Mutexes and Locks : componentwise max (⊔ ) pushes A's history into m.
B locks (acquire). B absorbs the lock: V B ← V B ⊔ V m = ⟨ 1 , 0 ⟩ , then ticks itself: V B = ⟨ 1 , 1 ⟩ . Why this step? Acquire pulls A's knowledge out of the lock — this is the happens-before edge.
B writes. It re-checks the old access "W by A at k = 1 ": now V B [ A ] = 1 ≥ 1 . No race.
Verify: V B [ A ] = 1 , need ≥ k = 1 : 1 ≥ 1 true ⇒ ordered ⇒ silent. ✓
Worked example Ex 5 — Cell C5: write then read ordered by
pthread_join
int result;
// A: result = compute(); // write
// main: pthread_join(A, ...); // waits for A to finish
// main: use(result); // read AFTER join
Forecast: the read happens on a different thread than the write. Is that automatically a race, or does join save us?
A writes. V A = ⟨ 1 , 0 ⟩ (indices A, main); shadow: "W by A at k = 1 ."
join is a release→acquire pair. A's termination releases its clock; join in main acquires it: V main ← V main ⊔ V A = ⟨ 1 , 0 ⟩ . Why this step? Thread termination/join is a synchronization event (parent's Forecast drill #2) — it forges the edge exactly like unlock/lock.
main reads. V main [ A ] = 1 ≥ k = 1 ⇒ no race .
Verify: 1 ≥ 1 true ⇒ ordered. Matches the parent's "join creates happens-before." ✓
Worked example Ex 6 — Cell C6 (degenerate): the
same thread writes twice
// A: x = 1; x = 2; // two writes, same thread, no lock
Forecast: no lock anywhere. Surely that's a race? Watch the axis carefully.
Both accesses are by A. The second write records shadow "W by A at k = 1 "; but the checking thread is also A . Why this step? The race rule compares accesses by different ordering status — within one thread, program order (happens-before axiom 1) always applies.
Program-order edge. The first write happens-before the second because they are in the same thread and the first comes first. V A [ A ] always equals A's own current time, so V A [ A ] = 1 ≥ 1 .
Result: no race — degenerate single-thread case.
Verify: V A [ A ] = 1 ≥ k = 1 true ⇒ ordered. A thread never races with itself. ✓
Worked example Ex 7 — Cell C7: transitive order through three threads
// A: data = 5; release(f1); // hand off to B
// B: acquire(f1); acquire→release(f2); // just passes the baton on
// C: acquire(f2); read data; // reads data
Forecast: A never talks to C directly. Can the read still be safe?
A writes and releases f1. V A = ⟨ 1 , 0 , 0 ⟩ (A,B,C); shadow "W by A at k = 1 "; V f 1 = ⟨ 1 , 0 , 0 ⟩ .
B acquires f1, then releases f2. V B ← V B ⊔ V f 1 = ⟨ 1 , 0 , 0 ⟩ , tick → ⟨ 1 , 1 , 0 ⟩ ; then V f 2 ← V f 2 ⊔ V B = ⟨ 1 , 1 , 0 ⟩ . Why this step? B carries A's knowledge forward — this is happens-before transitivity (axiom 3) made mechanical.
C acquires f2, then reads. V C ← V C ⊔ V f 2 = ⟨ 1 , 1 , 0 ⟩ . Now V C [ A ] = 1 ≥ k = 1 ⇒ no race .
Verify: V C [ A ] = 1 ≥ 1 true. A → B → C chains, so C safely sees A's write it never directly received. ✓
Worked example Ex 8 — Cell C8 (boundary):
volatile gives no ordering
volatile int counter;
// A: counter++; B: counter++; // no lock, no atomic
Forecast: volatile looks concurrency-flavored. Does it move any vector clock?
What volatile does. It forbids the compiler from caching counter in a register. It performs zero synchronization events. Why this step? No release, no acquire ⇒ no ⊔ merge ever happens (see Undefined Behavior in C and C++ ).
Clocks stay isolated. Exactly like Ex 2: V A [ B ] = 0 < k = 1 with two writes.
Result: race — TSan whistles identically to the un-annotated version.
Verify: 0 < 1 , two writes ⇒ race. The correct tool is std::atomic from std::atomic and Memory Ordering , which does inject acquire/release into the clock. ✓
Worked example Ex 9 — Cell C9 (word problem): the shared bank balance
Two ATMs run balance -= 20; on the same account object at the "same time," each doing a read-modify-write. Starting balance is $100. Is it a race, and what wrong answer can appear?
Forecast: predict the value a race can leave behind, not just yes/no.
Decompose the -=. Each ATM does: read balance, subtract 20, write back. Two writes to the same location. Why this step? A read-modify-write is not one indivisible step unless it's atomic (see Data Race vs Race Condition ).
No lock stated ⇒ clocks isolated ⇒ V A [ B ] = 0 < k on the collision ⇒ race (cell C2 shape).
Lost-update value. Both read 100, both compute 80, both write 80. One -20 vanished: final $80 instead of the correct $60.
Verify: correct answer 100 − 20 − 20 = 60 ; lost-update answer 100 − 20 = 80 ; they differ, confirming a real observable bug. Fix: hold a mutex across the whole read-modify-write. ✓
Worked example Ex 10 — Cell C10 (exam twist): edge on the
wrong lock
pthread_mutex_t m1, m2;
// A: lock(m1); x++; unlock(m1);
// B: lock(m2); x++; unlock(m2); // different mutex!
Forecast: both writes are inside locks. Trap: does "has a lock" equal "ordered"?
A releases into m1. V m 1 = ⟨ 1 , 0 ⟩ ; shadow "W by A at k = 1 ." Why this step? Release always pushes into the object being unlocked — m1 , not m2.
B acquires m2. V B ← V B ⊔ V m 2 . But V m 2 is still ⟨ 0 , 0 ⟩ — A never touched m2. Why this step? Axis (c): a happens-before edge only connects a release and acquire of the same object .
B checks the old access. V B [ A ] = 0 < k = 1 , two writes ⇒ race .
Verify: 0 < 1 true ⇒ race. Locks help only when both threads use the same lock protecting the same data. ✓
Recall Self-test: name the deciding fact for each cell
C1 no race because ::: both accesses are reads (no write involved)
C2 races because ::: two writes and V A [ B ] < k (isolated clocks)
C4 is safe because ::: unlock→lock on the same mutex sets V B [ A ] ≥ k
C5 is safe because ::: pthread_join is a release/acquire edge
C6 is safe because ::: program order orders same-thread accesses
C7 is safe because ::: transitivity chains A → B → C
C8 races because ::: volatile performs no synchronization event
C10 races because ::: the release/acquire used different mutexes
Mnemonic The one question behind every cell
For the old access by u at time k : is V t [ u ] ≥ k ? Yes → ordered → safe. No, and a write is present → race . Everything above is just computing V t [ u ] and k honestly.