Memory Model and Happens-Before
Memory Model and Happens-Before
Overview
Go’s memory model answers: when is a write in goroutine A guaranteed visible to a read in goroutine B? Without a happens-before edge, “it works on my machine” is data-race undefined behavior — even if the race detector is quiet today under lucky scheduling.
Reference: Go Memory Model (normative).
Diagram: Happens-before edges
flow:
[B] --HB--> [C]
[C]
|
v
[D]
Without an edge, concurrent write+read is a data race.
Data Race Definition
A data race is concurrent access to a variable where at least one access is a write and the accesses are not synchronized by the memory model.
go test -race ./...
go run -race .If -race fires, fix synchronization. Do not “make it volatile” by folklore.
Happens-Before (Core Edges)
Informally, if event X happens before Y, then Y sees all effects of X.
Important edges:
| Edge | Guarantee |
|---|---|
| Program order in one G | Earlier statements HB later (single goroutine) |
go statement |
Start of new G happens after the go statement executes |
| Channel send → receive | Send HB corresponding receive completion |
| Channel close → receive zero | Close HB receive that sees closed |
Mutex unlock → later lock |
Unlock HB subsequent Lock acquisition |
RWMutex |
Analogous rules for RLock/RUnlock |
sync.Once |
Do completion HB later Do returns |
sync.WaitGroup |
Done HB Wait return after counter hits 0 |
| Atomic ops | Synchronizing atomics establish order per model |
If you share memory, you almost always want one of: mutex, channel ownership transfer, or atomic with a clear protocol.
Incorrect: Sharing Without Sync
// DATA RACE
var a int
go func() { a = 1 }()
fmt.Println(a) // may print 0 forever, or 1, or worse under compiler reordering assumptionsCorrect Patterns
Mutex
var mu sync.Mutex
var a int
// writer
mu.Lock()
a = 1
mu.Unlock()
// reader
mu.Lock()
v := a
mu.Unlock()Unlock happens before the next Lock; reader sees the write.
Atomic flag
var ready atomic.Bool
var data int
// producer
data = 42
ready.Store(true) // release-style in practice for this pattern
// consumer
if ready.Load() {
_ = data // safe if this is the only protocol and documented
}Prefer mutexes for multi-field invariants; atomics for counters/flags with simple protocols.
Init and Package-Level
All package init functions and variable initialization complete before main starts. Within a package, initialization order is constrained by dependency; across packages, import graph order applies. Do not rely on init order for subtle cross-package races — make dependencies explicit.
Compiler and Hardware Reordering
Without synchronization, compilers and CPUs may reorder memory operations. The memory model is the contract that remains true; your mental model of “statements run in source order across cores” is false.
Experiment
go mod init example
go run -race .package main
import (
"fmt"
"sync"
)
func racy() int {
var a int
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
a = 1
}()
v := a // race with write
wg.Wait()
return v
}
func synced() int {
var mu sync.Mutex
var a int
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
a = 1
mu.Unlock()
}()
wg.Wait()
mu.Lock()
v := a
mu.Unlock()
return v
}
func main() {
fmt.Println("synced", synced())
// Uncomment to see race detector:
// fmt.Println("racy", racy())
_ = racy
}What to notice: -race instruments memory accesses; fixed code establishes unlock→lock or wait→read edges.
Try next: Build a single-producer single-consumer ring with atomics only; then rewrite with a buffered channel and compare clarity.