Race Condition Debugging Checklist
Checklist for identifying and fixing race conditions in concurrent code: symptom identification, reproduction strategies, debugging tools, common patterns, fixes using locks, atomics, channels, and prevention techniques with code examples.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Overview
This checklist guides engineers through identifying, reproducing, and fixing race conditions in concurrent code. Race conditions occur when multiple threads access shared state without proper synchronization, leading to non-deterministic bugs that are hard to reproduce and debug. This document covers symptom identification, reproduction strategies, debugging tools, common patterns, and fixes.
1. Symptom Identification
1.1 Common Symptoms
Symptom | Likely cause
─────────────────────────────────┼──────────────────────────────────────
Intermittent test failures | Non-deterministic thread scheduling
Corrupted data / wrong values | Lost updates or torn reads
Deadlocks / hangs | Lock ordering inconsistency
High CPU with no progress | Livelock or spin-wait
Memory corruption | Unsynchronized writes to shared struct
Random crashes / segfaults | Dangling pointer + concurrent free
Stale data in cache | Missing memory barrier / visibility
Counter off by N | Non-atomic read-modify-write
1.2 Initial Triage Questions
- [ ] Does the bug reproduce intermittently (not every run)?
- [ ] Does it reproduce more under load?
- [ ] Does it disappear when you add logging?
- [ ] Does it disappear when you run single-threaded?
- [ ] Are there shared mutable variables accessed from multiple threads?
- [ ] Are there lazy initialization patterns (double-checked locking)?
- [ ] Are there compound operations (check-then-act) on shared state?
- [ ] Is there a global/static mutable variable?
- [ ] Are locks acquired in different orders in different code paths?
- [ ] Are there callbacks or event handlers that run on different threads?
2. Reproduction Strategies
2.1 Stress Testing
import threading
import pytest
class Counter:
def __init__(self):
self.value = 0
def increment(self):
self.value += 1 # RACE CONDITION: read-modify-write is not atomic
def test_race_condition():
counter = Counter()
threads = []
# High concurrency to trigger race
for _ in range(100):
t = threading.Thread(target=lambda: [counter.increment() for _ in range(1000)])
threads.append(t)
for t in threads:
t.start()
for t in threads:
t.join()
# If race condition exists, counter.value < 100000
assert counter.value == 100000, f"Race detected: {counter.value} != 100000"
2.2 Controlled Reproduction with Barriers
import threading
def reproduce_race():
shared = {}
barrier = threading.Barrier(2)
def writer():
barrier.wait() # Ensure both threads start simultaneously
shared['key'] = 'value'
def reader():
barrier.wait() # Ensure both threads start simultaneously
return shared.get('key') # May not see the write
t1 = threading.Thread(target=writer)
t2 = threading.Thread(target=reader)
t1.start()
t2.start()
t1.join()
t2.join()
2.3 Java Concurrency Tests with jcstress
import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.II_Result;
@JCStressTest
@Outcome(id = "1, 1", expect = Expect.ACCEPTABLE, desc = "Sequential")
@Outcome(id = "0, 1", expect = Expect.ACCEPTABLE, desc = "Writer first")
@Outcome(id = "1, 0", expect = Expect.ACCEPTABLE, desc = "Reader first")
@Outcome(id = "0, 0", expect = Expect.FORBIDDEN, desc = "Race condition")
@State
public class RaceConditionTest {
int x = 0;
int y = 0;
@Actor
public void actor1() {
x = 1;
y = 1;
}
@Actor
public void actor2(II_Result r) {
r.r1 = y;
r.r2 = x;
}
}
3. Debugging Tools
3.1 Tool Selection
Language | Tool | What it detects
────────────┼─────────────────────────┼──────────────────────────────
Java | Thread Sanitizer (TSan) | Data races, deadlocks
Java | jstack | Thread dumps, deadlock detection
Java | jconsole / VisualVM | Live thread monitoring
Java | jcstress | Concurrency correctness tests
Python | threading.debug | Thread state inspection
Python | pytest-repeat | Flaky test detection
Go | -race flag | Data races at runtime
Go | pprof | Goroutine profiling
C/C++ | Thread Sanitizer (TSan) | Data races, tsan compile flag
Rust | -Z sanitizer=thread | Data races (nightly)
3.2 Go Race Detector
# Run tests with race detector
go test -race ./...
# Run binary with race detector
go run -race main.go
# Build with race detector
go build -race -o myapp
// Example: Go race detector output
// ==================
// WARNING: DATA RACE
// Read at 0x00c00001e0c0 by goroutine 7:
// main.increment()
// /app/main.go:12 +0x44
//
// Previous write at 0x00c00001e0c0 by goroutine 6:
// main.increment()
// /app/main.go:12 +0x64
// ==================
3.3 Java Thread Dump Analysis
# Take a thread dump
jstack <pid> > thread_dump.txt
# Detect deadlock
jstack <pid> | grep -A 5 "Found .* deadlock"
# Find waiting threads
jstack <pid> | grep "waiting to lock"
4. Common Race Condition Patterns
4.1 Check-Then-Act
# BAD: check-then-act race
if cache.get(key) is None: # Thread A checks
# Thread B also checks here — both see None
value = expensive_computation(key)
cache.set(key, value) # Both threads compute and set
# FIX: use lock or atomic operation
lock = threading.Lock()
def get_or_compute(key):
with lock:
if cache.get(key) is None:
value = expensive_computation(key)
cache.set(key, value)
return cache.get(key)
4.2 Lost Update (Read-Modify-Write)
// BAD: lost update
class Counter {
private int count = 0;
public void increment() {
count++; // Not atomic: read, add 1, write
}
}
// FIX 1: synchronized
class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
}
// FIX 2: AtomicInteger (better performance)
import java.util.concurrent.atomic.AtomicInteger;
class Counter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet();
}
}
4.3 Lazy Initialization (Double-Checked Locking)
// BAD: broken double-checked locking (Java < 5)
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // Check 1 (no lock)
synchronized (Singleton.class) {
if (instance == null) { // Check 2 (with lock)
instance = new Singleton(); // Not visible to other threads
}
}
}
return instance;
}
// FIX: volatile keyword (Java 5+)
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
// BETTER: use holder idiom (no synchronization needed)
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
4.4 Non-Atomic Compound Operations
// BAD: non-atomic check-and-set
var balance int
func withdraw(amount int) bool {
if balance >= amount { // Check
balance -= amount // Act — another goroutine can modify balance here
return true
}
return false
}
// FIX: mutex
var mu sync.Mutex
var balance int
func withdraw(amount int) bool {
mu.Lock()
defer mu.Unlock()
if balance >= amount {
balance -= amount
return true
}
return false
}
// FIX: atomic compare-and-swap
import "sync/atomic"
var balance int64
func withdraw(amount int64) bool {
for {
current := atomic.LoadInt64(&balance)
if current < amount {
return false
}
if atomic.CompareAndSwapInt64(&balance, current, current-amount) {
return true
}
// Retry if CAS failed (another goroutine modified balance)
}
}
5. Fixes by Pattern
5.1 Locks (Mutex)
import threading
class SafeCounter:
def __init__(self):
self._value = 0
self._lock = threading.Lock()
def increment(self):
with self._lock:
self._value += 1
def get(self):
with self._lock:
return self._value
5.2 Atomics
import java.util.concurrent.atomic.*;
// Atomic integer
AtomicInteger counter = new AtomicInteger(0);
counter.incrementAndGet();
counter.compareAndSet(0, 1);
// Atomic reference
AtomicReference<String> ref = new AtomicReference<>("initial");
ref.compareAndSet("initial", "updated");
// LongAdder for high-contention counters
LongAdder adder = new LongAdder();
adder.increment();
adder.sum();
5.3 Channels (Go)
// Instead of sharing a variable, communicate through a channel
func counter(incrementCh chan struct{}, resultCh chan int, done chan struct{}) {
count := 0
for {
select {
case <-incrementCh:
count++
case resultCh <- count:
// Send current count
case <-done:
return
}
}
}
// Usage
inc := make(chan struct{})
res := make(chan int)
done := make(chan struct{})
go counter(inc, res, done)
inc <- struct{}{}
fmt.Println(<-res) // 1
5.4 Thread-Local Storage
import threading
local_data = threading.local()
def process_request(request):
local_data.request_id = request.id
local_data.user = request.user
# Each thread has its own copy — no race
handle_request()
6. Prevention Checklist
Design phase:
- [ ] Identify all shared mutable state
- [ ] Prefer immutability where possible
- [ ] Use thread-safe data structures (ConcurrentHashMap, etc.)
- [ ] Minimize lock scope — lock only what you need
- [ ] Define lock ordering to prevent deadlocks
- [ ] Document thread-safety guarantees of each class
Implementation:
- [ ] Use language-provided synchronization (not custom spinlocks)
- [ ] Prefer atomics over locks for single-variable operations
- [ ] Prefer channels/message-passing over shared memory (Go)
- [ ] Use final/const/readonly for immutable fields
- [ ] Avoid lazy initialization unless necessary
- [ ] Use volatile/atomic for visibility guarantees
Testing:
- [ ] Run concurrency stress tests in CI
- [ ] Run with race detector (-race, TSan)
- [ ] Run tests with high thread count and iterations
- [ ] Use jcstress for Java memory model verification
- [ ] Test on multi-core machines (not single-core CI)
Code review:
- [ ] Check for shared mutable state in new code
- [ ] Verify lock scope is minimal
- [ ] Check for nested locks (deadlock risk)
- [ ] Verify compound operations are atomic
- [ ] Check for proper error handling inside locks
FAQ
What is the difference between a race condition and a data race?
A race condition is a logic flaw where the outcome depends on the timing of thread execution. A data race is a specific type of race condition where two threads access the same memory location concurrently, at least one is a write, and there is no synchronization. All data races are race conditions, but not all race conditions are data races. For example, a check-then-act pattern can be a race condition without a data race if both accesses are reads but the logic depends on ordering.
How do I reproduce a race condition that only happens in production?
Start by collecting as much context as possible from production logs — thread dumps, timestamps, request traces. Build a stress test that simulates the production load and concurrency level. Use barriers or countdown latches to force threads to execute at the same time. Run the test thousands of times in a loop. Use a race detector (Go -race, Java TSan) to catch races at runtime. If the race involves specific data patterns, use production data snapshots in your test.
When should I use locks vs atomics vs channels?
Use atomics for single-variable operations (counters, flags, simple state) — they are faster than locks and avoid deadlock risk. Use locks (mutex) for multi-variable operations or compound actions that must be atomic. Use channels (Go) when you want to communicate between goroutines without sharing memory — channels make the data flow explicit. In Go, prefer channels for orchestration and mutex for protecting shared state. In Java, prefer atomics for counters and locks for complex critical sections.
How do I prevent deadlocks when using multiple locks?
Always acquire locks in a consistent global order. If thread A acquires lock1 then lock2, thread B must also acquire lock1 before lock2. Never acquire a lock while holding another lock unless following the global order. Use tryLock with a timeout to detect and recover from potential deadlocks. Keep lock scope as small as possible — acquire late, release early. Consider using a single coarse-grained lock if contention is low, and split into fine-grained locks only when profiling shows contention.
What is a memory barrier and why does it matter?
A memory barrier (fence) is a CPU instruction that enforces ordering of memory operations. Without barriers, CPUs and compilers can reorder reads and writes for optimization, which can break concurrent code. The volatile keyword in Java inserts memory barriers, ensuring that writes are visible to other threads. In Go, channel operations and sync primitives insert barriers automatically. In C/C++, you must use atomic operations or explicit barriers. Missing memory barriers cause stale reads and invisible writes — classic race condition symptoms.
See Also
Related Resources
Async Task Cancellation Runbook
Runbook for safely cancelling long-running async tasks in Python, JavaScript, Go, and Java: cancellation tokens, context propagation, resource cleanup, timeout strategies, and graceful shutdown procedures with code examples.
DocThread Pool Sizing Template
Template for documenting thread pool configuration per service: pool type selection, sizing formulas, CPU vs I/O bound tuning, queue strategies, rejection policies, monitoring metrics, and tuning examples for Java, Python, Go, and Node.js.
GuideComplete Guide to Go Concurrency
Master Go concurrency in production. Covers goroutines, channels, context, select, sync primitives, worker pools, pipelines, fan-out/fan-in, and patterns for high-throughput concurrent Go applications.
GuideComplete Guide to Java Concurrency
Master Java concurrency in production. Covers threads, locks, CompletableFuture, virtual threads, executors, concurrent collections, memory model, and patterns for high-throughput parallel applications.
RecipeCoordinate Concurrent Tasks with Communicating
How to structure concurrent programs using channels, select statements, and goroutines for safe communication without shared mutable state in Go, Rust, and JavaScript.