Back to Notes

Go Race Conditions & Memory Model

Race Conditions & the Race Detector

What: A data race = two goroutines access the same memory location concurrently, at least one is a write, with no synchronization edge between them. Go declares racy programs to have undefined behavior — not "sometimes stale reads," genuinely undefined (torn multiword values, impossible states).

Syntax/Signature: go test -race ./..., go run -race, go build -race.

// RACY: check-then-act is not atomic even if each op were.
if _, ok := cache[k]; !ok {   // goroutine A and B both see !ok
	cache[k] = expensive(k)   // concurrent map write → runtime throw
}
// FIX: mutex around the whole check-then-act, or singleflight for dedup.

How it works under the hood: -race compiles in ThreadSanitizer (TSan): every memory access is instrumented and checked against a vector-clock model of happens-before built from observed sync operations (channel ops, mutex ops, atomics — see [[Go Channels]] and [[Go Sync Primitives]]). It reports real races on executed paths with ~zero false positives — but cannot see races on paths that didn't run, and races between accesses more than ~4 sync events apart on the same address can be missed (finite shadow history). Cost: ~5–10× CPU, ~5–10× memory. Race on a Go map is separately detected by the runtime's own write-flag check ("concurrent map writes") even without -race, but that check is best-effort.

Interview angle: "Race condition vs data race?" — data race is the memory-level phenomenon above; race condition is the broader logical class (check-then-act, TOCTOU) — you can have a race condition with zero data races (two atomic ops interleaving badly). Senior answer distinguishes them.

Gotchas / common wrong answers:

  • "The race detector proves my code race-free" — it only certifies executed interleavings.
  • "Benign data race" — does not exist in Go's model; the compiler may miscompile racy code.
  • Believing word-sized reads are always atomic and therefore fine — even if a read isn't torn, without a happens-before edge you may never observe the write.

Trade-offs / when NOT to use: Run -race in CI on the full test suite and in staging/canary under real load; too slow for most production. It changes timing, which can mask or expose bugs — that's a feature in tests.

The Go Memory Model & Happens-Before

What: The contract defining when a read is guaranteed to observe a write across goroutines: only through happens-before edges created by synchronization. Formalized/revised in Go 1.19 (DRF-SC: data-race-free programs behave sequentially consistently).

Syntax/Signature: The edges to memorize:

  • go statement happens-before the goroutine starts.
  • Channel send happens-before the corresponding receive completes.
  • Channel close happens-before a receive that returns zero-because-closed.
  • For unbuffered: receive happens-before the send completes (bidirectional).
  • For cap-C buffered: k-th receive happens-before (k+C)-th send completes.
  • mu.Unlock happens-before any subsequent mu.Lock (n-th Unlock → (n+1)-th Lock).
  • once.Do(f): f returns happens-before any Do returns.
  • Atomic operations behave sequentially consistent.
  • A goroutine's exit has NO happens-before edge with anything (without other sync).
var a string
var done = make(chan struct{})
func setup() { a = "hello"; close(done) } // write to a HB close
func main()  { go setup(); <-done; print(a) } // recv HB print → guaranteed "hello"
// Replace `done` with a plain bool flag and the program is racy:
// print may see "", forever, or the compiler may hoist the flag check.

How it works under the hood: The model constrains both hardware reordering (compiler emits memory barriers at sync points) and compiler optimizations (no invented writes; racy reads can't be used to justify impossible values). Without an edge, the compiler may legally cache a variable in a register across your polling loop — this is why busy-waiting on a plain bool can spin forever.

Interview angle: "Goroutine sets a flag, another loops reading it — what happens?" (may never terminate). "Why does close(done) 'publish' all prior writes?" (HB edge). This is where Senior candidates are separated from Mid.

Gotchas / common wrong answers:

  • "x86 is strongly ordered so it works in practice" — the compiler still reorders/caches; the model is about Go, not your CPU.
  • Thinking time.Sleep creates synchronization — it creates timing, never ordering guarantees.
  • Quoting the model's own advice wrongly: the doc literally says "if you must read the rest of this document to understand your program's behavior, you are being too clever. Don't be clever." Use channels/mutexes; reserve atomics reasoning for reviewed hot paths.

Trade-offs / when NOT to use: N/A — but in interviews, lead with "I structure code so I never rely on subtle ordering" then prove you know the edges anyway.

Interview Drills

  1. One goroutine does data = 42; flagDone = true; another loops for !flagDone {} then reads data. Both are plain (non-atomic) variables. What can happen and why? — Three legal outcomes: (a) works by luck, (b) reader sees flagDone == true but stale data (no ordering between the two writes as observed cross-goroutine), (c) reader spins forever — with no synchronization the compiler may hoist flagDone into a register and never reload it. It's a data race, so behavior is undefined by the Go memory model. Fix: establish a happens-before edge — close(done) + receive, mutex, or atomic.Bool for the flag (Go atomics are sequentially consistent, so the data write becomes visible too). time.Sleep in the loop changes timing, never correctness.
  2. Is a "race condition" the same thing as a "data race"? — No — a data race is the specific memory-level phenomenon (unsynchronized concurrent access, at least one write); a race condition is the broader logical category of bugs where outcome depends on timing (check-then-act, TOCTOU). Two goroutines can have a genuine race condition (a logically wrong outcome from bad interleaving) while every individual memory access is itself properly synchronized — zero data races, still a bug.
  3. Why can't -race be treated as proof that a program is race-free? — It only instruments and checks the interleavings that actually execute during a given run — a race hiding behind a rarely-taken code path or a timing window that didn't occur during that specific test execution won't be flagged, even though the underlying race genuinely exists in the code.

Cross-links

[[Go Sync Primitives]] · [[Go Channels]] · [[Go Goroutines & Scheduling]]