The Race Detector and Memory Model

Reviewed & published by Brayan K

Concurrent bugs are the hardest to find — until you let Go find them for you. You'll learn what a data race really is, how the Go memory model and happens-before decide when writes are visible, how go test -race instruments your code, and how to fix races with mutexes, channels, and sync/atomic.

Part of the free Go course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn in This Lesson

1️⃣ A program with a data race

Here 100 goroutines increment the same plain int with no synchronization. counter++ is a read-modify-write, so updates are lost and the result varies. Run with -race and Go prints a detailed report.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var counter int
    var wg sync.WaitGroup

    // 100 goroutines each increment the SAME plain int with no
    // synchronization — that is a textbook data race.
    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter++ // read-modify-write, unsynchronized => RACE
        }()
    }
    wg.Wait()
    fmt.Println("counter:", counter) // often < 100, and undefined
}

2️⃣ Fix it with a sync.Mutex

A mutex serializes access: Unlock happens-before the next Lock, so only one goroutine touches counter at a time. The result is now always 100 and the detector is silent.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var (
        counter int
        mu      sync.Mutex // guards counter
        wg      sync.WaitGroup
    )

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            mu.Lock()    // Unlock happens-before the next Lock:
            counter++    // only one goroutine is in here at a time
            mu.Unlock()
        }()
    }
    wg.Wait()
    fmt.Println("counter:", counter) // always 100
}

3️⃣ Fix it with sync/atomic

For a single counter, an atomic is lighter than a mutex. atomic.Int64.Add performs a race-free read-modify-write in hardware and also establishes happens-before ordering.

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

func main() {
    var counter atomic.Int64 // lock-free atomic counter (Go 1.19+)
    var wg sync.WaitGroup

    for i := 0; i < 100; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            counter.Add(1) // atomic read-modify-write, no mutex needed
        }()
    }
    wg.Wait()
    fmt.Println("counter:", counter.Load()) // always 100
}

4️⃣ Channels create happens-before too

A channel send happens-before the corresponding receive completes. That ordering makes the goroutine's earlier writes visible after the receive — no mutex required for the handoff.

package main

import "fmt"

func main() {
    done := make(chan int)

    var result int
    go func() {
        result = compute() // write in goroutine
        done <- 1          // send happens-before the receive below
    }()

    <-done                 // receive: now result is safely visible
    fmt.Println(result)    // no race: the send/receive ordered the writes
}

func compute() int { return 42 }

🎯 Your Turn

Protect the shared sum. Fill in the two blanks marked ___ so the write is guarded, then run with -race.

package main

import (
    "fmt"
    "sync"
)

func main() {
    var total int
    var mu sync.Mutex
    var wg sync.WaitGroup

    for i := 1; i <= 50; i++ {
        wg.Add(1)
        go func(n int) {
            defer wg.Done()
            // 🎯 YOUR TURN — guard the shared write so there is no race
            mu.___()        // 👉 Lock
            total += n
            mu.___()        // 👉 Unlock
        }(i)
    }
    wg.Wait()
    fmt.Println(total) // expected: 1275 (sum 1..50)

    // ✅ Run with:  go run -race main.go   (must print NO warning)
}

Common Errors (and the fix)

❌ Using time.Sleep to "fix" a race — it only hides the symptom.

✅ Add real synchronization: mutex, channel, or atomic.

❌ Locking the read but not the write (or vice versa) — still a race.

✅ Guard every access to the shared variable with the same mutex.

❌ Mixing atomic and non-atomic access to the same variable.

✅ Use atomic operations for all reads and writes of it.

❌ Shipping the -race binary to production — big CPU/memory overhead.

✅ Enable -race in tests and CI; ship the normal build.

🧠 Quick Recall

two concurrent reads, no writes — race?

No. A race needs at least one write. Concurrent reads with no writes are safe.

channel send vs receive ordering = ?

Send happens-before the receive completes. That makes the sender's earlier writes visible to the receiver.

🎯 Mini-Challenge: Atomic Counter

Use an atomic.Int64 so 200 goroutines can count even indices with no mutex and no data race.

package main

import (
    "fmt"
    "sync"
    "sync/atomic"
)

// 🎯 MINI-CHALLENGE: count successes without a mutex.
// 1. Use an atomic.Int64 named ok.
// 2. Launch 200 goroutines; each calls ok.Add(1) when its index is even.
// 3. Wait with a sync.WaitGroup, then print ok.Load().
// 4. Run with go run -race main.go and confirm there is NO data race.
//
// ✅ Expected: 100 (indices 0,2,4,... up to 198) and no WARNING.

func main() {
    var ok atomic.Int64
    var wg sync.WaitGroup
    _ = wg
    fmt.Println(ok.Load())
}

🎉 Lesson Complete!

Practice quiz

What is a data race in Go?

  • two goroutines accessing the same memory concurrently with at least one write and no synchronization
  • any program with more than one goroutine
  • a deadlock between two channels
  • a slow function that misses a deadline

Answer: two goroutines accessing the same memory concurrently with at least one write and no synchronization. A data race is concurrent access to the same memory where at least one access is a write and there is no happens-before ordering.

How do you enable the race detector when running tests?

  • go test -v
  • go test -cover
  • go test -race
  • go test -bench

Answer: go test -race. go test -race instruments the binary to detect races; -race also works with go run, go build, and go install.

What does the Go memory model define?

  • the maximum heap size
  • the happens-before conditions under which one goroutine's writes are visible to another
  • how garbage collection schedules
  • the size of an int

Answer: the happens-before conditions under which one goroutine's writes are visible to another. The memory model specifies happens-before guarantees that determine when a read observes a particular write across goroutines.

Which of these establishes a happens-before relationship?

  • calling time.Sleep
  • printing to stdout
  • incrementing a plain int
  • sending on a channel before the corresponding receive

Answer: sending on a channel before the corresponding receive. A channel send happens-before the receive completes; mutex Unlock happens-before a later Lock; these create ordering.

How does the race detector find races?

  • it instruments memory accesses at runtime and tracks happens-before ordering
  • it scans source code statically
  • it counts goroutines
  • it reads the go.mod file

Answer: it instruments memory accesses at runtime and tracks happens-before ordering. ThreadSanitizer-based instrumentation records each memory access and reports when two are unordered with a write involved.

Which type provides lock-free atomic operations on a counter?

  • sync.Once
  • sync/atomic (e.g. atomic.Int64)
  • time.Timer
  • context.Context

Answer: sync/atomic (e.g. atomic.Int64). sync/atomic offers atomic loads, stores, and Add operations; atomic.Int64.Add is a race-free way to update a counter.

A key LIMITATION of the race detector is that it...

  • finds every possible race by static analysis
  • only works on Windows
  • slows code by exactly 2x
  • only reports races on code paths that actually execute at runtime

Answer: only reports races on code paths that actually execute at runtime. It is a dynamic tool: it only detects races that actually happen during the run, so good test coverage matters.

Which is a correct way to fix a race on a shared counter?

  • run with more CPUs
  • add a time.Sleep before the write
  • guard it with a sync.Mutex around read and write
  • make the variable global

Answer: guard it with a sync.Mutex around read and write. A sync.Mutex (Lock/Unlock) serializes access; channels and sync/atomic are other valid fixes. Sleeping does not fix races.

Does the race detector add overhead?

  • no, it is free
  • yes — typically several times more CPU and memory, so it is for testing/CI not production
  • it makes code faster
  • only on the first run

Answer: yes — typically several times more CPU and memory, so it is for testing/CI not production. Instrumentation increases CPU and memory use significantly, so you enable -race in tests and CI, not in production binaries.

Why is a data race undefined behavior even if the program 'seems to work'?

  • the compiler and hardware may reorder or tear accesses, giving torn or stale values unpredictably
  • Go converts it to a deadlock
  • races only matter in C, not Go
  • it always crashes immediately

Answer: the compiler and hardware may reorder or tear accesses, giving torn or stale values unpredictably. Without happens-before ordering, reads may observe partial or stale writes; the behavior is undefined and platform-dependent.

Continue this course

Frequently asked questions

What exactly counts as a data race?

A data race occurs when two goroutines access the same memory location concurrently, at least one of the accesses is a write, and there is no synchronization (no happens-before relationship) ordering them. Concurrent reads are fine; a read together with a write, or two writes, without ordering is the problem. The fix is to introduce ordering with a mutex, channel, or atomic operation.

How do I turn on the race detector?

Add the -race flag: go test -race ./..., go run -race main.go, or go build -race. It compiles an instrumented binary that watches memory accesses at runtime and prints a detailed WARNING: DATA RACE report (with both stacks) when it finds one. Because of the overhead, run it in tests and CI rather than shipping the instrumented binary to production.

What is the Go memory model and 'happens-before'?

The memory model specifies the conditions under which a read in one goroutine is guaranteed to observe a write in another. The core concept is happens-before: if event A happens-before event B, then A's effects are visible to B. Channel sends/receives, mutex Lock/Unlock, sync.Once, and sync/atomic establish these orderings. Without such an ordering, the compiler and CPU are free to reorder or tear accesses, so the result is undefined.

How does the detector actually work, and what are its limits?

It is built on ThreadSanitizer: the instrumented binary records each memory access and the happens-before edges between goroutines, then reports any pair of unordered accesses where at least one is a write. The crucial limitation is that it is dynamic — it only catches races on code paths that actually execute during the run. A race in a rarely taken branch will go unnoticed unless your tests exercise it, so coverage matters.

Mutex, channel, or atomic — which should I use to fix a race?

Use a sync.Mutex to guard a critical section that touches several fields or does compound logic. Use a channel when you want to hand off ownership of data between goroutines ('share by communicating'). Use sync/atomic (e.g. atomic.Int64.Add) for a single counter or flag where a full mutex would be overkill. All three create the happens-before ordering that removes the race; adding time.Sleep does not.

Why is a race undefined behavior even though my program prints a value?

Because nothing orders the conflicting accesses, the compiler may reorder instructions and the hardware may cache or tear writes, so a read can see a stale value, a half-written value, or a value from a future iteration. The program might appear to work on your machine and corrupt data on another, or under load. That non-determinism is exactly why the standard calls it undefined and why you must remove every race the detector reports.

Related lessons