Race Conditions vs Data Races
Race Conditions vs Data Races
Overview
These terms are not interchangeable. A data race is unsynchronized concurrent memory access where at least one access is a write—undefined behavior under the Go memory model, and usually reported by the race detector (-race). A race condition is a logic timing bug: the program’s outcome depends on interleaving in a way the author did not intend. Race conditions can exist without a data race when every memory access is synchronized (atomics, mutexes, channels) but the algorithm is still wrong.
concurrent bug
├── data race → memory model violation; -race often finds it
└── race condition → wrong logic under interleaving
(can be -race clean!)
Related chapters: 257 data races & mutex, 259 atomics.
Teaching rule
-race clean ⇏ correct concurrent algorithm
correct algorithm ⇒ define happens-before for shared memory
AND make multi-step decisions atomic as a unit
| Symptom | Likely class | Detector |
|---|---|---|
WARNING: DATA RACE |
data race | go test -race |
| Concurrent map writes / fatal | data race | runtime + -race |
| Wrong totals, double-insert, lost update with atomics | race condition | logic tests, stress |
| TOCTOU / check-then-act | race condition | design review + stress |
Data race: classic counter
Unsynchronized ++ is a read-modify-write. Two goroutines can both read the same value and both write the same next value—lost updates—and the accesses form a data race.
mkdir race-lab && cd race-lab
go mod init example.com/race-labdata_race.go:
package main
import (
"fmt"
"sync"
)
func main() {
var x int
var wg sync.WaitGroup
for i := 0; i < 2; i++ {
wg.Go(func() {
for j := 0; j < 100_000; j++ {
x++ // data race: concurrent read+write without sync
}
})
}
wg.Wait()
fmt.Println("x =", x, "(often not 200000)")
}go run -race .
# WARNING: DATA RACE
# x = <something ≤ 200000>What to notice: Without -race you might only see a wrong number; with -race the detector pins the concurrent accesses.
Fixes (any one is enough for the race)
// 1) Mutex
var mu sync.Mutex
mu.Lock()
x++
mu.Unlock()
// 2) Atomic
var x atomic.Int64
x.Add(1)
// 3) Ownership: only one goroutine mutates; others send ops on a channelRace condition without data race: atomic composition
Each Load / Add is race-free, but the pair is not a single atomic decision. Two goroutines can interleave between Load and Add—classic TOCTOU on a “policy” that depends on the observed value.
package main
import (
"fmt"
"sync"
"sync/atomic"
)
func main() {
var counter atomic.Int64
var wg sync.WaitGroup
// Intent: if even, +1; if odd, +2. Not atomic as a unit.
bump := func() {
if counter.Load()%2 == 0 {
counter.Add(1)
} else {
counter.Add(2)
}
}
for i := 0; i < 1000; i++ {
wg.Go(bump)
}
wg.Wait()
fmt.Println("final", counter.Load())
// Re-run many times: totals vary. go test -race stays clean.
}go run -race .
# clean — no data race
# final value still non-deterministic relative to a sequential modelCorrect patterns
// Mutex around the whole decision
mu.Lock()
if counter%2 == 0 {
counter++
} else {
counter += 2
}
mu.Unlock()
// Or CAS loop that retries the whole policy
for {
old := counter.Load()
var next int64
if old%2 == 0 {
next = old + 1
} else {
next = old + 2
}
if counter.CompareAndSwap(old, next) {
break
}
}Check-then-act
G1: if !exists { create() }
G2: if !exists { create() }
both pass check → duplicate / conflict
This appears in maps, files, databases, and caches:
// Broken even if map is protected by separate Lock per line incorrectly,
// or if you use atomics only for the flag and not the create body.
if _, ok := m[k]; !ok {
m[k] = expensiveInit() // two Gs both see !ok
}| Domain | Broken pattern | Fix |
|---|---|---|
| In-process map | check then insert | single mutex around both; sync.Map LoadOrStore |
| Files | Stat then Create |
O_EXCL, or lock file |
| SQL | SELECT then INSERT | UNIQUE constraint + handle conflict; transaction |
| HTTP | “if not cached then fetch” | single-flight, mutex, or CAS |
LoadOrStore sketch
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Go(func() {
actual, loaded := m.LoadOrStore("cfg", "once")
fmt.Println("loaded_existing=", loaded, "value=", actual)
})
}
wg.Wait()
}Only one store wins; others see loaded=true. That closes the check-then-act hole for this map pattern.
Diagram: three layers of “correct”
┌─────────────────────────────────────────┐
│ Algorithm / invariants │ ← race conditions live here
│ (idempotent create, unique keys, CAS) │
└─────────────────────────────────────────┘
▲
┌─────────────────────────────────────────┐
│ Synchronization (mutex, chan, atomic) │ ← kills data races
└─────────────────────────────────────────┘
▲
┌─────────────────────────────────────────┐
│ Memory model / happens-before │ ← -race reasons about this
└─────────────────────────────────────────┘
Runnable lab: compare three versions
Create racecond_test.go in a module:
package racecond
import (
"sync"
"sync/atomic"
"testing"
)
// Broken: data race on plain int.
func badInc(n *int, workers, per int) {
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Go(func() {
for i := 0; i < per; i++ {
*n++
}
})
}
wg.Wait()
}
// Race-free memory, wrong if you expected a specific composite policy;
// here simple Add is actually correct for a counter.
func atomicInc(n *atomic.Int64, workers, per int) {
var wg sync.WaitGroup
for w := 0; w < workers; w++ {
wg.Go(func() {
for i := 0; i < per; i++ {
n.Add(1)
}
})
}
wg.Wait()
}
// Race condition: double-checked init without proper once.
func racyInit(ready *atomic.Bool, initCount *atomic.Int32) {
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Go(func() {
if !ready.Load() {
// pretend expensive init
initCount.Add(1)
ready.Store(true)
}
})
}
wg.Wait()
}
func TestBadIncRace(t *testing.T) {
// Run with: go test -race -run TestBadIncRace
var n int
badInc(&n, 4, 10_000)
// Do not assert equality under race; detector is the signal.
t.Log("n=", n)
}
func TestAtomicInc(t *testing.T) {
var n atomic.Int64
atomicInc(&n, 4, 10_000)
if n.Load() != 40_000 {
t.Fatalf("got %d", n.Load())
}
}
func TestRacyInitCondition(t *testing.T) {
// Often initCount > 1 even though -race is clean.
// Fix with sync.Once or mutex around check+init.
var ready atomic.Bool
var initCount atomic.Int32
racyInit(&ready, &initCount)
if c := initCount.Load(); c != 1 {
t.Logf("race condition: init ran %d times (expected 1)", c)
}
}
func TestOnceInit(t *testing.T) {
var once sync.Once
var initCount atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 32; i++ {
wg.Go(func() {
once.Do(func() { initCount.Add(1) })
})
}
wg.Wait()
if initCount.Load() != 1 {
t.Fatalf("once failed: %d", initCount.Load())
}
}go test -race -count=20 -vExperiment: Run the atomic composition example from 259 atomics under -race (clean) and print varying totals (condition).
Pitfalls
- Believing “atomics make the program correct.” They make operations race-free, not policies.
- Sleeping to “fix” races. Masks bugs; fails under load or different GOMAXPROCS.
- Only testing happy serial paths. Concurrent bugs need stress (
-count=N,-race, high worker counts). - Confusing channel safety with payload safety. Sending a pointer does not protect the pointee.
Checklist
- Can I name the shared memory and who writes it?
- Is every multi-step decision under one lock, one CAS loop, or one owner goroutine?
- Does CI run
go test -race ./...on supported platforms? - Are uniqueness / idempotency enforced at the storage boundary?
Keep going
| Next | Why |
|---|---|
| 257 Data races & mutex | Detector + critical sections |
| 259 Atomics | RMW and composition traps |
| 260 Testing concurrent code | Deterministic tests |
| 263 Cond & Broadcast | Waiting on predicates safely |
Try next: Find a production if err := find(); err == notFound { insert } and document the uniqueness constraint (DB unique index, etcd create, etc.) that makes the race condition harmless.