Race Conditions vs Data Races

Updated

September 8, 2026

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-lab

data_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 channel

Race 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 model

Correct 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 -v

Experiment: Run the atomic composition example from 259 atomics under -race (clean) and print varying totals (condition).

Pitfalls

  1. Believing “atomics make the program correct.” They make operations race-free, not policies.
  2. Sleeping to “fix” races. Masks bugs; fails under load or different GOMAXPROCS.
  3. Only testing happy serial paths. Concurrent bugs need stress (-count=N, -race, high worker counts).
  4. 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.