Overview
Two complementary tools: a deterministic test that forces the bad interleaving with a start gate (or a hook inside the critical window), and a detector or stress harness (TSan, go test -race, jcstress) that explores interleavings you did not think of. A race fix is done when a test that failed before the fix passes after it, repeatedly.
When to use
Step 1 - Find the shared mutable state
Check each of: module-level / static mutable variables; singletons with mutable fields; get-then-set on caches or maps without a lock or CAS; per-request data stored on shared objects; pools; lazy initialisation ("if not initialised, initialise"). For each, write down the two operations that must not interleave.
Step 2 - Force the interleaving
A start gate makes both threads enter together; a hook inside the window makes the collision certain instead of likely:
import threading
def test_lazy_init_creates_one_instance(monkeypatch):
in_window = threading.Barrier(2, timeout=0.5)
real_build = LazyService._build
def slow_build(self):
try:
in_window.wait() # unfixed: both threads meet here, inside the window
except threading.BrokenBarrierError:
pass # fixed: only one thread gets here; carry on
return real_build(self)
monkeypatch.setattr(LazyService, "_build", slow_build)
target = LazyService()
results, errors = [None, None], []
def worker(i):
try:
results[i] = target.lazy_get()
except Exception as e: # surface worker failures to the test thread
errors.append(e)
threads = [threading.Thread(target=worker, args=(i,)) for i in (0, 1)]
for t in threads: t.start()
for t in threads: t.join(timeout=10)
assert not errors
assert results[0] is results[1], "lazy init built two instances"Verified with a check-then-build LazyService: the unfixed code fails 3/3 runs (both threads meet inside the window and build twice); with a lock around check-and-build it passes 3/3, because only one thread reaches _build and its barrier wait times out after 0.5 s. The same test serves before and after the fix.
Assertions belong on the test thread. An assertion or t.Fatal inside a worker thread, goroutine or executor task can fail silently; collect results and errors and assert after join().
Step 3 - Detectors: TSan and Go -race
C / C++ (ThreadSanitizer (opens in new window)):
clang -fsanitize=thread -g -O1 program.c -o program
TSAN_OPTIONS="halt_on_error=0" ./program; echo "exit=$?"TSan typically costs 5-15x time and 5-10x memory (ThreadSanitizer (opens in new window)). A report changes the exit status to exitcode (default 66); setting exitcode=0 in CI makes a run that found races pass (TSan flags (opens in new window)). Never suppress a finding to get green: a suppression is for third-party code you cannot change, with a ticket. TSAN_OPTIONS=enable_adaptive_delay=1 injects delays at synchronisation points to expose more interleavings (ThreadSanitizer (opens in new window)).
Go: go test -race ./.... The race detector needs cgo, and typically costs 5-10x memory and 2-20x time (Go race detector (opens in new window)). The full Go workflow (GORACE, -count / -cpu stress, start gates with channels, goroutine-leak gating with goleak) is in references/go.md; goleak filter choices are in references/goleak-filter-options.md. The race detector finds unsynchronised memory access only; it says nothing about goroutines that never exit.
Step 4 - jcstress for JVM publication and reordering
jcstress runs @Actor methods concurrently on a fresh @State object per trial and classifies results with @Outcome (jcstress (opens in new window)). Put the shared field on the state object so every trial publishes it anew, and mark the illegal observation FORBIDDEN so one occurrence fails the run:
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
import static org.openjdk.jcstress.annotations.Expect.*;
@JCStressTest
@Outcome(id = "0, 0", expect = ACCEPTABLE, desc = "Nothing seen yet")
@Outcome(id = "1, 1", expect = ACCEPTABLE, desc = "Both writes seen")
@Outcome(id = "0, 1", expect = ACCEPTABLE, desc = "Only x seen")
@Outcome(id = "1, 0", expect = FORBIDDEN, desc = "y seen without x")
@State
public class PublicationTest {
volatile int x, y; // remove volatile to watch 1, 0 become possible
@Actor public void writer() { x = 1; y = 1; }
@Actor public void reader(II_Result r) { r.r1 = y; r.r2 = x; }
}java -jar target/jcstress.jar -t PublicationTestA clean jcstress run means the forbidden outcome was not observed in the interleavings explored, not that it cannot happen; say so when closing a ticket.
Step 5 - Volume: virtual threads and property tests
Virtual threads (Java 21+) make thousands of concurrent tasks cheap:
@Test
void placesTenThousandOrdersWithoutLoss() throws Exception {
var service = new OrderService();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
var futures = IntStream.range(0, 10_000)
.mapToObj(i -> executor.submit(() -> service.place(i)))
.toList();
for (var f : futures) f.get(); // rethrows task failures here
}
assertEquals(10_000, service.totalProcessed());
}Property-based concurrency (Hypothesis) checks invariants over random operation mixes:
import threading
from hypothesis import assume, given, strategies as st
@given(st.lists(st.tuples(st.sampled_from(["read", "write"]), st.integers()), min_size=10, max_size=100))
def test_counter_ends_on_a_written_value(ops):
writes = [v for op, v in ops if op == "write"]
assume(writes)
counter = ThreadSafeCounter()
threads = [threading.Thread(target=(lambda v=v: counter.set(v)) if op == "write" else counter.get)
for op, v in ops]
for t in threads: t.start()
for t in threads: t.join()
assert counter.get() in writesVolume tests raise the odds; they never prove absence. Pair them with Step 2 for the specific bug.
Step 6 - Checkpoint: red before the fix, green after
Step 7 - CI
- name: Go race detector
env: { CGO_ENABLED: "1" }
run: go test -race -count=3 -cpu 1,4 -timeout 20m ./...
- name: TSan build + test
run: |
cmake -B build-tsan -DCMAKE_C_FLAGS="-fsanitize=thread -g -O1" -DCMAKE_CXX_FLAGS="-fsanitize=thread -g -O1"
cmake --build build-tsan
./build-tsan/test_runner # non-zero (66) when TSan reports
- name: jcstress
run: java -jar target/jcstress.jar -t PublicationTestKeep at least one instrumented matrix leg; never mark it continue-on-error, and never set exitcode=0.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
sleep() to "force" interleaving | Flaky; passes on fast machines | Start gate or in-window hook (Step 2) |
| Assertions inside worker threads / goroutines | Failures vanish; test passes | Collect, then assert on the test thread |
| One green run after the fix | Races are probabilistic | Repeated runs (Step 6) |
Detector step continue-on-error or exitcode=0 | Findings never fail the build | Blocking step (Step 7) |
| Suppressing a finding in your own code | Ships the race | Fix it, or ticket + owner |
Build tag excluding concurrent tests from -race | The detector never sees the contended code | Run them under -race |
Limitations
References
Go - race detector workflow (GORACE, stress, goleak)
View source (opens in a new tab) (opens in new window)The Go race detector (ThreadSanitizer compiled into the binary via -race) and goroutine leaks are two separate failure classes: the detector finds concurrent unsynchronized access, goleak finds goroutines that never stop. Run both.
Enable the race detector
Per go.dev/doc/articles/race_detector (opens in new window):
go test -race ./...
go run -race main.go
go build -race ./cmd/serverRequires cgo and a C compiler on Linux/FreeBSD/Windows (mingw-w64 v8+ on Windows; Darwin ships its own). Expected overhead per the same doc: 2-20x execution time, 5-10x memory, plus 8 bytes per defer/recover accumulating until the goroutine exits - budget CI timeouts accordingly.
A detected race prints the conflicting read/write stacks and goroutine creation sites to stderr (WARNING: DATA RACE); fix by protecting every access to the address with the same primitive (mutex, atomic, or channel hand-off).
GORACE options
GORACE="log_path=/tmp/race/report halt_on_error=1 history_size=2" go test -race ./...| Option | Default | When to change |
|---|---|---|
log_path | stderr | File path so CI can archive race reports as artifacts |
halt_on_error | 0 | 1 stops on the first race; local debugging |
history_size | 1 | Raise to 2-7 when report stacks look truncated (memory cost) |
strip_path_prefix | "" | Make report lines repo-relative |
exitcode | 66 | Override for CI exit-code conventions |
Stress with -count and -cpu
The detector only fires on races that actually execute; a single run can miss a real race. Amplify interleaving diversity:
go test -race -count=10 ./... # 10 runs per package
go test -race -cpu=1,2,4,8 ./... # re-run per GOMAXPROCS value
go test -race -count=5 -cpu=1,2,4 ./...GOMAXPROCS=1 surfaces sequencing bugs; higher values surface true parallel races.
go vet - loop-variable capture
Per pkg.go.dev/cmd/vet (opens in new window) (loopclosure), go vet flags goroutines closing over a range variable - the classic pre-Go-1.22 capture race (for _, v := range items { go func() { process(v) }() }). Run go vet ./... before -race to filter this class early; Go 1.22+ makes range variables per-iteration, but audit code that may build on older toolchains.
goleak - goroutine-leak detection
Per github.com/uber-go/goleak (opens in new window) (go get -u go.uber.org/goleak):
func TestWorkerPool(t *testing.T) {
defer goleak.VerifyNone(t) // fails if any goroutine outlives the test
pool := NewWorkerPool(4)
pool.Submit(func() { /* work */ })
pool.Shutdown()
}VerifyNone is incompatible with t.Parallel() - goleak cannot attribute goroutines to parallel sub-tests. For parallel packages wrap the runner:
func TestMain(m *testing.M) { goleak.VerifyTestMain(m) }Silence expected library goroutines by top-of-stack function:
goleak.VerifyNone(t, goleak.IgnoreTopFunction("database/sql.(*DB).connectionOpener"))Full filter catalog (IgnoreAnyFunction, IgnoreCurrent, Cleanup) in goleak-filter-options.md (opens in new window).
CI matrix
Gate at least one matrix dimension with -race (per the race-detector doc: "It is recommended to always run race-enabled tests"):
jobs:
test:
strategy:
matrix:
go-version: ["1.22", "1.23"]
race: ["", "-race"]
steps:
- uses: actions/setup-go@v5
with: { go-version: "${{ matrix.go-version }}" }
- env: { GORACE: "log_path=/tmp/race/report halt_on_error=0" }
run: |
go vet ./...
go test ${{ matrix.race }} -count=3 -cpu=1,4 -timeout=10m ./...
- if: failure()
uses: actions/upload-artifact@v4
with: { name: race-reports, path: /tmp/race/report* }Set -timeout to 5-10x the non-race run time; upload log_path files on failure.
Anti-patterns
| Anti-pattern | Why it fails | Fix |
|---|---|---|
Run -race once, see no output, ship | Detector only finds races that executed | -count / -cpu matrix |
Skip -race for "release" builds in CI | Race appears in prod, not CI | Gate one matrix dimension with -race |
VerifyNone with t.Parallel() | goleak can't attribute goroutines | VerifyTestMain |
IgnoreCurrent() at package init | Snapshot masks leaks added before each test | Call inside each test function |
Trust -race to catch goroutine leaks | Different failure class | Add goleak; the gates are complementary |
history_size=7 always | 128K history per goroutine can OOM CI | Start at 1; raise only on truncated stacks |
Limitations
References
Filter options for goleak.VerifyNone / goleak.VerifyTestMain, per pkg.go.dev/go.uber.org/goleak (opens in new window). Pass one or more as trailing arguments to suppress goroutines that are expected rather than leaked.
IgnoreTopFunction
Ignores any goroutine whose top-of-stack frame is the named function. Prefer this when the library goroutine is identifiable by name.
goleak.VerifyNone(t,
goleak.IgnoreTopFunction("database/sql.(*DB).connectionOpener"),
)IgnoreAnyFunction (v1.3.0+)
Ignores any goroutine whose stack contains the named function at any depth, not just the top frame. Use when the identifying frame is not at the top.
goleak.VerifyNone(t,
goleak.IgnoreAnyFunction("google.golang.org/grpc.(*ccBalancerWrapper).watcher"),
)IgnoreCurrent
Snapshots the goroutines already running at call time and ignores exactly those at verification.
opt := goleak.IgnoreCurrent()
// ... test logic ...
goleak.VerifyNone(t, opt)Prefer IgnoreTopFunction over IgnoreCurrent when the library goroutine is identifiable by name: IgnoreCurrent silences goroutines that were already running at snapshot time, which can mask leaks introduced before the snapshot.
Cleanup
goleak.Cleanup(func(int)) registers a function goleak calls with the exit code after the leak check, e.g. to log instead of failing the process. Used with VerifyTestMain when the default exit behavior needs to change.
References
- Fifty million samples said the registry was fine and forty-one pods disagree
- 412 customers charged twice and the build was green the whole time
- Ingest pods climb to 6GB over three days and nobody believes it is ours
- Reconciliation is a few cents short about one night in six
- Three asks landed on the instrumented build report and the branch cuts Friday