5.3.14 · D2Build Systems & Toolchain

Visual walkthrough — Thread Sanitizer (TSan)

1,875 words9 min readBack to topic

We use a running story throughout: two threads, A and B, and one shared box of memory called .


Step 1 — Two threads, one box, and the "who went last?" question

WHAT. Draw time flowing downward. Thread A is one column, thread B is another. Between them sits a single shared memory cell (think: one number in a box).

WHY. Before we can talk about ordering accesses, we need a picture where "earlier" and "later" mean something. Time-down / threads-across is that picture. Every event (a read, a write, a lock) is a dot in one column.

PICTURE. Look at the two coral/lavender columns. A writes at one dot; later B writes at another dot. The whole question of the page is the dashed grey arrow marked "?": is there any chain of events forcing A's write to come before B's write? If no chain exists, the two dots are concurrent, and a write is involved — that is a data race.


Step 2 — Give each thread a private tick counter

WHAT. Give thread A its own counter and thread B its own counter . Each starts at . Every time a thread does a synchronization event (a lock, an unlock, a thread start/join), its own counter ticks up by one.

WHY. We need a way to say "this write, not that one." A bare label "A wrote " is ambiguous if A writes ten times. A number stamped on each event makes every event uniquely nameable: "A's write at A-time 3." That stamped number is the from the parent's rule — now you know where it comes from.

PICTURE. Watch the little counter chips beside each column climb as sync events fire. The write is tagged with whatever the counter reads at that instant.

Here is "how many milestones A has personally passed," and is the tick — one milestone at a time, never skipping.


Step 3 — Upgrade to a vector clock: one entry per thread

WHAT. Replace A's single counter with an array that has one slot per thread. With two threads it is just two numbers:

is A's own milestone count (same as before). is the new, crucial slot: "the highest B-time whose effects A is guaranteed to have received."

WHY. The whole race question is "does A's knowledge already cover B's write?" That is a question about B, so A must carry a dedicated number about B. One slot per other thread is the minimum bookkeeping that can answer it. This is the vector clock idea, purpose-built for memory ordering.

PICTURE. Each column now carries a two-cell strip . The own cell is solid; the other-thread cell starts empty (value : "I've heard nothing from B yet").


Step 4 — How knowledge flows: release writes it down, acquire reads it back

WHAT. Synchronization moves knowledge through a shared object (a mutex). Two mechanical rules, straight from the happens-before axioms:

The symbol means componentwise maximum: for each slot, keep the larger of the two numbers. Reading it term by term: on unlock, A deposits its knowledge into the lock's own clock (max keeps the freshest of each slot). On lock, A withdraws whatever fresher knowledge the lock holds.

WHY. This is the mutex turned into arithmetic. Unlock = "I'm done, here's everything I know" (a release in memory-ordering terms). Lock = "before I start, give me everything the last releaser knew" (an acquire). Max, and only max, correctly merges two histories without losing any milestone.

PICTURE. Follow the mint arrow: A's strip flows into the lock chip on unlock; later B's lock pulls the lock chip into B's strip. After that pull, B's "about-A" cell finally holds a real number instead of .

Each slot is maxed independently — that is why B ends up knowing both A's milestone 3 and its own milestone 1.


Step 5 — The race check as one comparison

WHAT. When thread A accesses , TSan looks at the shadow record of a previous access: "B wrote at B-time ." The verdict:

Term by term: is "how much of B's timeline A is guaranteed to see." is "the B-time of that earlier write." If A's knowledge falls short of (), then B's write is not in A's past — it is concurrent. Add a write, and it is a race.

WHY this comparison and not another? We chose < because "ordered" means exactly "already covered by my knowledge." If , A's slot has reached or passed B's write — a happens-before edge got the news to A, so the two are ordered and safe. The single inequality is the entire happens-before test compressed into one number-vs-number.

PICTURE. Two panels side by side. Left (RACE): B wrote at but A's about-B cell is still , whistle. Right (SAFE): a mutex ran between them, so A's about-B cell is now , silent.


Step 6 — Edge case: two reads never race

WHAT. Suppose both accesses to are reads, and (genuinely concurrent). Still no race.

WHY. The rule has an guard. Two reads leave the value untouched, so the final contents of don't depend on their order. Concurrency alone is harmless; concurrency plus a write is the danger. This is why the parent insists on "at least one write."

PICTURE. Both dots are reads (open circles). The concurrency arrow is still dashed-grey — but the whistle is crossed out, because the write-guard fails.


Step 7 — Edge case: volatile looks like a fix but moves no knowledge

WHAT. Declare volatile int x; and race on it anyway. TSan still whistles.

WHY. volatile only tells the compiler "don't cache this in a register." It performs no release and no acquire — it never runs the merge of Step 4. So B's about-A cell stays at ; remains true; the check still fires. The cure is a real synchronizing tool (std::atomic or a mutex) that actually executes the merge. Skipping it is textbook undefined behavior.

PICTURE. The lock chip from Step 4 is replaced by a volatile tag that is transparent — the knowledge arrow passes straight through and deposits nothing. B's about-A cell stays empty.


Step 8 — Edge case: join is a release/acquire in disguise

WHAT. Thread A writes , then the main thread calls pthread_join(A), then reads . No race.

WHY. join is defined to acquire everything the joined thread released on exit. Mechanically: A's exit is a release ( deposited), the return of join is an acquire (). After that merge , so the later read is safely ordered — the exact same machinery as a mutex, just spelled differently.

PICTURE. A's whole strip flows across the join barrier into the main thread; the about-A cell fills up before the read.


The one-picture summary

WHAT. One diagram compressing all eight steps: the two-column timeline, the vector strips ticking, a mutex depositing/withdrawing knowledge, and the final vs comparison branching into RACE or SAFE.

Recall Feynman: the whole walkthrough in plain words

Picture two kids editing one shared line in a notebook. Each kid keeps a little scorecard with two numbers: "my own turn count" and "the last turn of the other kid I was actually told about." Whenever a kid finishes and passes the notebook (that's a lock/unlock, or a join), they hand over their whole scorecard — the other kid copies down any number bigger than what they had. Now the hall monitor's whole job is one glance: when a kid touches the shared line, check the scorecard cell for the other kid against the turn-number stamped on the other kid's last edit. If your cell is smaller, you were never told about that edit — you're editing blind. If someone's edit was a write, the monitor blows the whistle: collision possible. Two reads? No whistle — nobody changed anything, order can't matter. volatile? Still a whistle — it's a kid who never passes the notebook, so nobody's scorecard ever updates. join? Silent — it's just a formal handoff of the whole scorecard. Every case on this page is that same one glance: my knowledge of you vs. your last stamp.


See also: Thread Sanitizer (TSan) · Vector Clocks · Data Race vs Race Condition · Memory Models · std::atomic and Memory Ordering · Mutexes and Locks · AddressSanitizer (ASan) · Undefined Behavior in C and C++