Data races and the limits of ThreadSanitizer

title
Data races and the limits of ThreadSanitizer
type
summary
summary
Phil Eaton rebuilds a FastTrack-style race detector, then shows TSan missing obvious races in C and Golang once its fixed budgets run out
tags
concurrency, golang, c, testing, debugging
created
2026-09-13
updated
2026-09-14

Phil Eaton's September 2026 piece for The Consensus explains what a data-race is, how ThreadSanitizer finds one, and where it stops finding them. TSan matters because almost everything uses it: Clang, GCC, Golang's -race, Swift and OCaml all ship LLVM's ThreadSanitizer. It is also thinly documented. Version two (2012) has a written algorithm; for version three (2021) its author suggested reading the source. Some of the blind spots below also appear in chapter 6 of Farzam Dorostkar's 2025 PhD thesis on implementation-induced detection blind spots in TSan v3.

Eaton is explicit that this is not a case against TSan. He would not want to work without it. The point is to know what a clean run does not prove.

Data races and general races

C11 defines a data race as two conflicting actions in different threads, at least one not atomic, where neither happens before the other. Golang's memory model says roughly the same: a write concurrent with another read or write of the same location, unless every access goes through sync/atomic.

The running example is two threads doing v = counter; counter = v + 1. Built normally it passes CI nearly every time and fails at N=10000. Built with -fsanitize=thread or go build -race it is reported on the first run. The Golang version is reported even under GOMAXPROCS=1, because Golang tells TSan about every goroutine as if it were a thread.

Wrapping the read and the write in two separate lock sections removes the data race and keeps the bug. The program still loses increments, and no race detector will say anything, because every access is now ordered by the mutex. That is a general race, and the rest of the article is only about data races.

How detection works

Compiling with -fsanitize=thread inserts calls to __tsan_read* and __tsan_write* around memory accesses and __tsan_func_entry/__tsan_func_exit around function bodies, then links against libtsan. The compiler does nothing about pthreads; TSan intercepts those calls itself, and any other threading library has to teach TSan about its primitives.

Eaton walks two algorithm families. Eraser (1997) tracks the set of locks held on every access to a shared address and reports when the intersection goes empty. It produces many false positives and cannot see a race in code that uses no locks at all. TSan v1 combined locksets with vector clocks; v2 dropped locksets. FastTrack's approach, which v2 and v3 resemble without being identical to, gives each thread a logical clock plus a vector of the clock values it knows for every other thread. Releasing a primitive (unlocking, exiting a thread) publishes the releaser's vector; acquiring one (locking, joining) takes the element-wise maximum. An access is racy if the last writer's recorded time is later than what the current thread knows about that writer.

To show it concretely, the article implements an interpreter for a tiny subset of C in Python, then hooks a FastTrack-style detector into it, and the detector reports the same race TSan does on a program where one thread writes x = 1 and another reads it.

Where TSan misses races

The first gap is over-ordering, an example from the Eraser paper's Figure 2. One thread writes x and then locks and unlocks a mutex; the other locks and unlocks the same mutex and then writes x. The mutex protects nothing, but when the lock happens to be taken in that order it creates a happens-before edge between the two writes. TSan reported the race 14 times in 1,000 runs.

The rest are fixed budgets that keep TSan's overhead bounded, and each can be exhausted by ordinary programs.

TSan tracks 255 thread slots and then reuses them. A long-lived writer, then a stream of short-lived workers, then a read of the writer's variable: in C, 253 workers still gets a report and 254 does not. Golang's runtime takes one slot of its own, so the cutoff is one goroutine earlier. Eaton notes this is not exotic for a Golang service that spawns a goroutine per request.

Each slot's clock is 14 bits. A thread that locks and unlocks enough times uses up the clock and forces TSan onto a new slot. In C, 4.1 million lock/unlock pairs still report the race and 4.2 million do not. A Golang mutex lock/unlock pair costs three releases, so 1.3 million is caught and 1.4 million is not.

Access history is kept per 8-byte granule, four cells per granule. Writes by another thread to neighbouring bytes of the same granule cannot race with the original write, but they claim cells, and the fifth distinct access evicts the record that would have produced the report.

Golang's sync.Pool tells the race detector it acquired and released an address on every get and put, so reusing pooled objects across goroutines does not look like a race. It cannot use the object's own address, which might carry its own synchronization, so it hashes the pointer into one of 128 slots. Two objects in unrelated pools that hash to the same slot create an ordering between their goroutines, and any real race between those goroutines disappears. Eaton's guess for why the table is so small is that every binary pays for it, with or without -race.

The closing extra-credit exercise is to find the missing detection when using chan struct{}.

This is the second Eaton piece in the vault after golang-green-tea-gc, and it has the same habit of reproducing the claim on a real machine rather than stopping at the documentation. It sharpens a number from message-passing-shared-mutable-state, where the race detector caught roughly half of the non-blocking bugs in Tu et al's Golang study: part of the other half is general races, which no data-race detector is built to see. go-channel-bug-patterns lists those failure modes. gosentry-go-fuzzing-fork runs Golang fuzz targets with race detection enabled, which inherits every budget above. For the hardware-level meaning of "happens before" that C11 borrows, see shared-memory-consistency-causality. For goroutines that are blocked forever rather than racing, see goroutine-leak-profiler.