Go Memory Model & Sync Guarantees
Go's memory model defines when one goroutine is guaranteed to observe writes made by another. The rule is short, synchronize using channels, sync primitives, or sync/atomic, otherwise it's a data race.
Diagram
What it is
Go's memory model is a small set of rules about which writes one goroutine is guaranteed to see when reading from another. It is much shorter than Java's because Go's design pushes developers toward synchronization primitives that already establish happens-before, channels, mutexes, atomics, rather than expecting reasoning about reordering directly.
Why it matters
Every concurrent Go bug is one of two things: forgot to synchronize, or wrong primitive for the job. The race detector catches the first; a clear mental model of happens-before catches the second. When an interviewer asks "is this code racy?" the real question is whether the missing happens-before edge is visible.
How it works
The four happens-before edges that matter in Go
- Program order inside a single goroutine.
- Channel: a send on a channel happens-before the corresponding receive completes. The reverse holds for unbuffered channels, the receive happens-before the send completes.
- Mutex:
Unlock(m)happens-before the nextLock(m)returns. - Atomics: an atomic store happens-before any atomic load that observes it (since Go 1.19).
That's the full toolkit. Everything else, sync.Once, WaitGroup, errgroup, context.Context cancellation, is built on these four primitives.
"Don't communicate by sharing memory; share memory by communicating" The most-quoted line in Go culture isn't an aesthetic preference, it's a memory model strategy. Channels carry both the data and the happens-before edge in one operation. Accidental publication without synchronization isn't possible, because the channel send is the synchronization.
How Java and Python compare
Java's memory model is broader and more permissive: it has to support volatile, final, lock acquisition, and explicit memory barriers (VarHandle.acquire/release). The result is more flexibility but also more rope.
Python has no formal memory model. The GIL provides accidental atomicity for individual bytecode operations, but visibility across threads is implementation-defined. The portable advice is the same as Go's "share by communicating": use queue.Queue, which synchronizes internally.
When to reach for what
- One-shot signal "I'm done":
close(done)channel. - Multiple values, bounded buffer: buffered channel.
- Hot counter:
atomic.Int64. - Protecting a struct:
sync.Mutex. - Read-heavy shared state:
sync.RWMutex. - Lazy init:
sync.Once.
Always run -race in CI
Most Go data races don't fire in unit tests, they need real load and timing. Run the race detector continuously in CI and against integration tests. The 5–10× runtime cost is worth catching production bugs before they ship.
Primitives by language
- sync.Mutex / sync.RWMutex
- chan T (channel send/receive)
- sync/atomic
- sync.Once / sync.WaitGroup / sync.Cond
Implementation
No synchronization, so the Go memory model says nothing about what reader observes. The race detector (go run -race) flags this immediately. On x86 it might 'work' by accident; on ARM it likely won't.
1 var (
2 ready bool
3 value int
4 )
5
6 func writer() {
7 value = 42
8 ready = true
9 }
10
11 func reader() {
12 for !ready { /* spin */ }
13 fmt.Println(value) // may print 0
14 }atomic.Bool.Store is a release; Load is an acquire. Anything written before the store is visible to a reader that observes true. Idiomatic when one variable carries the synchronization signal.
1 import "sync/atomic"
2
3 var (
4 ready atomic.Bool
5 value int
6 )
7
8 func writer() {
9 value = 42
10 ready.Store(true) // release
11 }
12
13 func reader() {
14 for !ready.Load() { /* spin */ } // acquire
15 fmt.Println(value) // guaranteed 42
16 }close(done) is a happens-before edge for every receiver. Receivers see all writes that happened before the close. This is Go's preferred coordination style for one-shot signals.
1 func main() {
2 done := make(chan struct{})
3 var value int
4
5 go func() {
6 value = 42
7 close(done) // signals: writes are visible
8 }()
9
10 <-done // happens-after close
11 fmt.Println(value) // guaranteed 42
12 }Unlock happens-before the next Lock. Wrap a struct's mutable fields in a mutex and access them only while holding it.
1 type SafeCounter struct {
2 mu sync.Mutex
3 value int
4 }
5
6 func (c *SafeCounter) Inc() {
7 c.mu.Lock()
8 defer c.mu.Unlock()
9 c.value++
10 }
11
12 func (c *SafeCounter) Get() int {
13 c.mu.Lock()
14 defer c.mu.Unlock()
15 return c.value
16 }Once.Do is a memory barrier. The function runs exactly once; every other caller blocks until it finishes, then sees the fully-constructed result. No DCL gymnastics needed.
1 var (
2 instance *Singleton
3 once sync.Once
4 )
5
6 func Get() *Singleton {
7 once.Do(func() {
8 instance = &Singleton{ /* expensive setup */ }
9 })
10 return instance
11 }Key points
- •Go's mantra: 'Don't communicate by sharing memory; share memory by communicating'
- •A send on a channel happens-before the corresponding receive completes
- •A receive from an unbuffered channel happens-before the send completes
- •Mutex.Unlock() happens-before the next Mutex.Lock() returns
- •sync/atomic loads/stores establish happens-before (since Go 1.19)
- •go run -race detects data races at runtime; run it in CI
Tradeoffs
| Option | Pros | Cons | When to use |
|---|---|---|---|
| Channels |
|
| Producer-consumer, fan-out, signaling completion, ownership transfer |
| sync.Mutex |
|
| Protecting a struct's fields, counters, maps |
| sync/atomic |
|
| Hot counters, status flags, lock-free data structures |
Follow-up questions
▸Why does Go say 'don't communicate by sharing memory'?
▸Is reading a bool atomic in Go?
▸What does the race detector actually do?
▸Difference between unbuffered and buffered channel send semantics?
▸Can sync.Mutex be reentrant?
Gotchas
- !Copying a struct that contains a sync.Mutex breaks it; go vet will warn
- !Closing a channel twice panics; sending to a closed channel panics
- !select with no default + no ready case blocks forever, common cause of deadlock
- !Goroutine leaked on channel send: writer blocks forever if no one reads and no buffer
- !atomic operations require the value's address, atomic.AddInt64(&x, 1), not (x, 1)
Common pitfalls
- Skipping -race in CI; most data races only manifest under load
- Using sync.Map as a default; it's optimized for write-once-read-many. Regular map+mutex is faster otherwise
Practice problems
Compare sync.Mutex vs atomic.Int64, show contention difference
Multiple workers consuming from one channel, results merged into one
APIs worth memorising
- Go: sync.{Mutex, RWMutex, Once, WaitGroup, Cond, Map}, sync/atomic.{Int32, Int64, Bool, Pointer, Value}, context.Context
- Java: synchronized, java.util.concurrent.atomic.*, VarHandle (for comparison)
- Python: threading.{Lock, RLock, Event, Condition}, queue.Queue
Every Go service. The Kubernetes scheduler, Docker, etcd, and Prometheus all rely on careful application of the Go memory model.