08 · Concurrency Testing¶
A data race can pass every functional test, every code review, and run correctly ten thousand times in a row on a developer's laptop — and then corrupt state in production under a load pattern nobody reproduced locally. ThreadSanitizer (TSan) is the tool that turns "probably fine" into a detected, reproducible failure, and it's the centerpiece of this module.
Environment note
Everything in this module was compiled and run on this host. The
Command Line Tools issue affecting other modules in this level is
specific to the C++ standard library headers (<cstddef>, <iostream>,
etc.); plain C with pthreads and -fsanitize=thread was unaffected, so
all output below is real, captured on this machine.
1. A real, caught data race¶
/* race_demo.c */
#include <stdio.h>
#include <pthread.h>
static int counter = 0;
static void *increment(void *arg) {
(void)arg;
for (int i = 0; i < 100000; ++i) {
counter++; /* unsynchronized read-modify-write: a data race */
}
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter = %d (expected 200000)\n", counter);
return 0;
}
Built and run without a sanitizer first — the bug is silent, not absent:
Wrong answer, no crash, no warning — the exact failure mode that makes data races so dangerous: nothing in the program's own output tells you it's wrong unless you already know what the right number is.
Now built with ThreadSanitizer:
==================
WARNING: ThreadSanitizer: data race (pid=46675)
Write of size 4 at 0x0001048ec000 by thread T1:
#0 increment race_demo_tsan:arm64+0x10000077c
Previous write of size 4 at 0x0001048ec000 by thread T2:
#0 increment race_demo_tsan:arm64+0x10000077c
Location is global 'counter' at 0x0001048ec000 (race_demo_tsan+0x100008000)
Thread T1 (tid=1407641, running) created by main thread at:
#0 pthread_create ...
#1 main race_demo_tsan:arm64+0x100000690
Thread T2 (tid=1407642, running) created by main thread at:
#0 pthread_create ...
#1 main race_demo_tsan:arm64+0x1000006a8
SUMMARY: ThreadSanitizer: data race (race_demo_tsan:arm64+0x10000077c) in increment+0x64
==================
counter = 100000 (expected 200000)
ThreadSanitizer: reported 1 warnings
TSan names the exact line, both threads involved, and where each was created — everything needed to fix it without a debugging session.
2. The fix, verified¶
/* race_fixed.c */
#include <stdio.h>
#include <pthread.h>
static int counter = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static void *increment(void *arg) {
(void)arg;
for (int i = 0; i < 100000; ++i) {
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("counter = %d (expected 200000)\n", counter);
return 0;
}
Clean TSan run, and — just as importantly — the correctness bug (wrong count) also disappeared, because the two were the same bug seen from two angles.
3. Turning it into a CI-enforced regression test¶
A race demonstrated once by hand is a story; a race checked on every commit
is a regression test. Wrap the same logic in an assertion-based harness
instead of eyeballing printf output.
/* test_counter_concurrency.c */
#include <assert.h>
#include <pthread.h>
static int counter = 0;
static pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
static void *increment(void *arg) {
(void)arg;
for (int i = 0; i < 100000; ++i) {
pthread_mutex_lock(&lock);
counter++;
pthread_mutex_unlock(&lock);
}
return NULL;
}
int main(void) {
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
assert(counter == 200000); /* fails the build, not just prints wrong */
return 0;
}
gcc -fsanitize=thread test_counter_concurrency.c -o test_counter_concurrency -lpthread
./test_counter_concurrency && echo "PASS: no race, correct count"
The combination matters: the assert catches a logic bug even if TSan is
somehow disabled in a given build; TSan catches races that happen to
produce a coincidentally correct count on a given run (which does happen
— unsynchronized access to memory is undefined behavior, not "usually
wrong by a little").
4. Races that don't corrupt data but still get flagged¶
TSan flags any unsynchronized concurrent access to non-atomic shared memory, including ones that "seem harmless" — a flag being polled by another thread, for instance. This isn't over-cautious: the C/C++ memory model does not guarantee the reading thread ever observes the write at all without synchronization, regardless of how the race looks in practice.
/* Looks harmless, still UB and still a real bug: */
static volatile int ready = 0; /* volatile does NOT fix this -- it affects
compiler codegen, not the memory model's
cross-thread visibility guarantees */
The correct fix is _Atomic int ready (C11) or std::atomic<bool> (C++11)
with appropriate memory ordering, or a condition variable if the waiting
thread should also block rather than spin.
5. Deadlock detection¶
TSan also detects lock-order inversions — the classic setup for a deadlock that might not manifest for weeks in production because the unlucky interleaving is rare.
/* Thread A: lock(mutex1) then lock(mutex2)
Thread B: lock(mutex2) then lock(mutex1)
-- TSan reports this as a potential deadlock even on a run where the
unlucky interleaving didn't actually occur, because it tracks the
lock-order graph, not just observed hangs. */
WARNING: ThreadSanitizer: lock-order-inversion (potential deadlock)
Cycle in lock order graph: M1 (0x...) => M2 (0x...) => M1
Mutex M2 acquired here while holding mutex M1:
#0 pthread_mutex_lock ...
#1 thread_a_func ...
Mutex M1 acquired here while holding mutex M2:
#0 pthread_mutex_lock ...
#1 thread_b_func ...
This is strictly stronger than a test that only catches deadlocks that actually hang — the lock-order analysis finds the structural problem independent of scheduling luck.
6. Stress testing as a complement, not a substitute¶
TSan needs the race to actually execute under instrumentation to catch it — a code path only exercised under specific timing or load might not trigger during a quick test run. Running the same test many times, or with induced scheduling delays, increases the odds of exposure but is not a substitute for the instrumented detection in section 1: no amount of repetition proves absence, only TSan's static lock-order and access-tracking analysis does.
for i in $(seq 1 50); do ./race_demo_tsan >/dev/null 2>&1 || echo "run $i: TSan flagged a race"; done
Cheat sheet¶
| Symptom | Likely cause | Tool |
|---|---|---|
| Wrong result, no crash, varies run to run | Unsynchronized read-modify-write | TSan |
| Rare, unreproducible hang | Lock-order inversion | TSan's deadlock detection |
| "Volatile should have fixed this" | Volatile isn't a memory-model tool | Use _Atomic/std::atomic instead |
| Passes locally, fails under load | Race only exercised at higher concurrency | Stress-run the same TSan binary many times, at higher thread counts |
| Slow to detect | TSan needs the racy access to actually execute | Combine with fuzzing/property tests generating varied thread interleavings via delays |
How It Actually Works: ThreadSanitizer's happens-before vector clocks¶
TSan catches races that never actually corrupt anything visible in a given run — it doesn't wait for a wrong answer, it detects the absence of synchronization directly.
- Every memory access and every synchronization event is instrumented, like ASan but tracking a different kind of shadow state. Where ASan's shadow memory encodes "is this byte addressable," TSan's shadow state encodes, per memory location, the set of prior accesses (thread ID, clock value, read/write) that touched it. Every load/store your compiled code performs is wrapped with a call into the TSan runtime that consults and updates this shadow state.
- Each thread carries a vector clock, and synchronization primitives
transfer clock information between threads. A vector clock is an array of
per-thread logical counters. Acquiring a mutex that another thread
previously released causes the acquiring thread's vector clock to be
updated (
max'd) against the releasing thread's clock at release time — this is exactly what "happens-before" means formally: event A happens-before event B if B's vector clock dominates A's after any chain of such transfers (through mutexes, atomics with acquire/release semantics, thread creation/join). - A race is flagged when two accesses to the same location, from different threads, are not ordered by any happens-before chain, and at least one is a write. TSan checks this by comparing the current access's (thread, clock) against the shadow state's recorded prior accesses — if neither vector clock dominates the other, there is no synchronization event that could have prevented them from executing concurrently, regardless of whether they actually interleaved badly this run. That's the mechanical reason TSan can report a race the very first time it executes that code path, while a stress-testing loop might run the same binary a thousand times without ever hitting the specific interleaving that produces a visibly wrong result.
volatilecarries no happens-before information, which is precisely why it doesn't fix races.volatileonly tells the compiler "don't cache/reorder this access relative to other volatile accesses" — it emits no memory fence, no cross-core cache synchronization, and updates no vector clock.std::atomicwith acquire/release ordering, by contrast, is exactly what TSan's instrumentation recognizes as a synchronization event and uses to propagate vector-clock information between threads.
Exercise¶
- Build and run
race_demo.cexactly as shown, confirm you see a similar TSan report (exact addresses/PIDs will differ), then apply the mutex fix and confirm a clean run. - Modify
race_demo.cto use_Atomic int counter(C11<stdatomic.h>,atomic_fetch_add) instead of a mutex, rebuild with TSan, and confirm it's also race-free — then explain in one sentence when you'd choose an atomic over a mutex. - Reproduce the deadlock scenario from section 5 as real code (two mutexes, two threads, opposite lock order) and confirm TSan reports the lock-order inversion, ideally without the program actually hanging in your test run.
- Turn one of your fixes into an
assert-based regression test like section 3, and wire it into your project's CI as a job that builds with-fsanitize=threadand runs it — say what job definition you'd use (reuse the shape from module 06). - Write two sentences on one piece of shared state in your own project (real or hypothetical) that is accessed from more than one thread, and whether you're confident it's correctly synchronized or would want to run it under TSan to find out.