5.3.14 · D4Build Systems & Toolchain

Exercises — Thread Sanitizer (TSan)

2,618 words12 min readBack to topic

Before we start, one shared picture we will re-use. It shows two threads on two vertical time-lines and the one arrow — the happens-before edge— that decides everything.

Figure — Thread Sanitizer (TSan)
Recall Refresher: the whole test in one line

Two accesses to the same location race when: at least one is a write, and no happens-before arrow connects them (they are concurrent). Written with vector clocks: an access by thread races with a prior access by thread recorded at -time when Here = "the newest event of that is guaranteed to already know about." If , thread already knows 's access — it is in 's past — safe.


Level 1 — Recognition

Just decide race / no race. No clocks yet — only the WORN checklist (Write, Order absent, Reached by both, Not atomic).

Recall Solution L1.1

No race. Both accesses are reads. Two reads never conflict: neither changes the value, so the order is irrelevant. The W in WORN fails → no whistle.

Recall Solution L1.2

Race. One access is a write (A), one a read (B), same location, no ordering, plain (non-atomic). All four WORN conditions hold. In C/C++ this is undefined behavior.

Recall Solution L1.3

No race. pthread_join establishes a happens-before edge: everything A did happens-before join returns in main. So main's read is in A's future — ordered. O (order absent) fails.


Level 2 — Application

Now run the mechanical rules on a tiny event list.

Recall Solution L2.1

Start both at .

  • A's local tick: . A writes , recorded as (thread A, A-time , write).
  • B's local tick: . B now writes ; it compares against A's record.
  • Race rule for B vs A's record: is one a write? Yes (both are). Is ? Here and , so is true.

Race. B never absorbed A's history (no acquire), so B does not know about A's write. Concurrent writes ⇒ TSan whistles.

Recall Solution L2.2
  • , A writes (record: A, , write).
  • A unlocks (release): .
  • B locks (acquire): .
  • B writes , compares to A's record: , , so is = false.

No race. The unlock→lock pair carried A's clock into B via the max-merge, so : A's write is now in B's past. This is axiom 2 (release/acquire) made mechanical.


Level 3 — Analysis

Trace a longer scenario and reason about which edge saves you.

Recall Solution L3.1

Step 4 — B vs A's record (A-time 1): , , so = false → no race. The lock in step 3 pulled A's history into B.

Step 6 — C vs B's record (B-time 2): , , so = true, and both writes → RACE (C vs B). C never acquired anything B released.

Step 6 — C vs A's record (A-time 1): , , so = true, both writes → RACE (C vs A) as well.

Reading it: A and B are safely chained by the lock (edge exists). C is off on its own island — no acquire ever reached it, so it is concurrent with both prior writers. TSan would report C's access as racy against the most recent conflicting access it still has in shadow memory.

Figure — Thread Sanitizer (TSan)

Level 4 — Synthesis

Design the fix and justify why it removes the race.

Recall Solution L4.1

Fix 1 — mutex:

pthread_mutex_lock(&m); counter++; pthread_mutex_unlock(&m);

Thread A's unlock does (release); Thread B's next lock does (acquire). So B's clock now covers A's write index: . The read-modify-write is also serialized, so no lost updates. Edge: unlock→lock. See Mutexes and Locks.

Fix 2 — atomic:

std::atomic<int> counter{0}; counter++; // or counter.fetch_add(1)

Each ++ is a single indivisible read-modify-write with (by default) sequentially-consistent ordering, so accesses are atomic — the N in WORN fails, and default ordering creates happens-before between successive atomic ops. TSan treats atomics as synchronizing and stays quiet. See std::atomic and Memory Ordering.

Why volatile fails: volatile only forbids the compiler from caching the value in a register; it provides no atomicity and no happens-before. Two threads still perform two plain, concurrent, non-atomic writes: WORN's N (not atomic) still holds and O (order absent) still holds → still a race, still UB.

Recall Solution L4.2
std::atomic<bool> ready{false};
// producer:
fill(buffer);
ready.store(true, std::memory_order_release);
// consumer:
while(!ready.load(std::memory_order_acquire)) { }
use(buffer);

Why: the producer's store(release) publishes everything sequenced before it (the fill). The consumer's load(acquire) that reads true synchronizes-with that release, importing the producer's history: . Now use(buffer) is happens-before-ordered after fill(buffer) → no race. This is a release-acquire pair — the exact software analogue of the unlock/lock edge.


Level 5 — Mastery

Edge cases, degenerate inputs, and the toolchain itself.

Recall Solution L5.1

No. Within one thread, program order (axiom 1) totally orders every access: access happens-before access . There is never a concurrent pair, so always covers the previous access. A data race is fundamentally inter-thread; a single thread cannot race with itself. (It can still have logic bugs — TSan just isn't the tool for those.)

Recall Solution L5.2

TSan works in two stages: instrumentation at compile (it rewrites every memory access to call into the runtime) and a special runtime swapped in at link. Here the flag was passed only to the compile step; the link produced a binary with instrumented calls but without the TSan runtime (or the runtime's process setup). The result is a broken/no-op detector. Fix: pass -fsanitize=thread to both the compile and the link command:

clang -fsanitize=thread -g -O1 -c race.c -o race.o
clang -fsanitize=thread race.o -o race
Recall Solution L5.3

It fails to build / is unsupported: TSan and AddressSanitizer (ASan) use conflicting shadow-memory layouts and runtimes and cannot coexist in one binary. Fix: build two separate binaries, one with -fsanitize=thread, one with -fsanitize=address, and run each in CI. They answer different questions (races vs memory errors).

Recall Solution L5.4

No — TSan is a finder, not a prover. It only reasons about code paths actually executed on that run, and about synchronization it models. A race on an untaken branch, or hidden behind a custom lock-free protocol TSan doesn't understand, escapes. Absence of a report means "no race observed on these executions," not "no race exists." Fix: pair TSan with high test/branch coverage and stress; treat it as evidence, not proof.

Recall Solution L5.5

Time: (≈ 6 min 40 s). Memory: (≈ 3.5 GB). That is affordable on CI: run it at least per-commit on a fast subset and nightly on the full suite. It's too slow for production, but 6–7 minutes is fine for CI where the whole point is catching the 1-in-a-million interleaving reasoned about, not hit.


Self-check ladder

Answer without peeking, then reveal.

L1 — Two threads read the same global, no writes. Race?
No — no write, WORN's W fails.
L2 — After unlock then a later lock, what merges into the locker's clock?
The lock's clock via componentwise max (), importing the releaser's history.
L3 — Do two threads writing the same variable get ordered because they share it?
No — sharing a variable creates no edge; only release/acquire, join, or create does.
L4 — Why is volatile not a fix?
It gives no atomicity and no happens-before — only prevents register caching.
L5 — Zero TSan reports: proven race-free?
No — only executed, modelled paths were checked; TSan finds, never proves absence.