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:
gostatement happens-before the goroutine starts.- Channel send happens-before the corresponding receive completes.
- Channel
closehappens-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.Unlockhappens-before any subsequentmu.Lock(n-th Unlock → (n+1)-th Lock).once.Do(f):freturns happens-before anyDoreturns.- 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.Sleepcreates 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
- One goroutine does
data = 42; flagDone = true; another loopsfor !flagDone {}then readsdata. Both are plain (non-atomic) variables. What can happen and why? — Three legal outcomes: (a) works by luck, (b) reader seesflagDone == truebut staledata(no ordering between the two writes as observed cross-goroutine), (c) reader spins forever — with no synchronization the compiler may hoistflagDoneinto 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, oratomic.Boolfor the flag (Go atomics are sequentially consistent, so thedatawrite becomes visible too).time.Sleepin the loop changes timing, never correctness. - 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.
- Why can't
-racebe 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]]