The Go Memory Model Is Not What You Think

Most Go developers never read the memory model spec. Here's what you're missing — and why data races are scarier than you imagine.

Most Go developers I know treat concurrency like this: slap a mutex on shared state, use channels where possible, run the race detector in CI, ship it. That’s not wrong — but it means a lot of people are writing concurrent Go without understanding the rules of the game.

I spent a few hours last month re-reading the Go memory model spec after hitting a subtle bug in production. Here’s what surprised me.

The guarantee is weaker than you think

Go’s memory model defines happens-before relationships. The key rule:

A read r of a variable v is allowed to observe a write w to v if there is no other write w' to v that happens after w but before r.

“Allowed to observe” — not “guaranteed to observe.” Without an explicit synchronisation point between a write and a read, the compiler and CPU can reorder, cache, or elide operations entirely.

The classic mistake:

go
var ready bool
var data int

func producer() {
    data = 42
    ready = true  // ← no happens-before with the reader
}

func consumer() {
    for !ready {}
    fmt.Println(data) // may print 0!
}

This code has a data race. The race detector will catch it. But here’s the thing — even without the race detector firing, on architectures with weak memory ordering (ARM, RISC-V), the CPU is allowed to reorder the stores, so consumer might see ready == true but data == 0.

What creates happens-before?

The spec lists these synchronisation operations:

  • sync.Mutex lock/unlock
  • sync/atomic operations (as of Go 1.19, with explicit memory_order semantics)
  • Channel send/receive
  • sync.WaitGroup Done/Wait
  • sync.Once Do
  • go statement starting a goroutine

Channel operations are the most idiomatic. A send on a channel happens-before the corresponding receive completes:

go main.gogo
ch := make(chan struct{})
data := 0

go func() {
    data = 42
    ch <- struct{}{} // send happens-before receive completes
}()

<-ch
fmt.Println(data) // guaranteed to print 42

The buffered channel trap

Here’s where people get burned:

go main.gogo
ch := make(chan struct{}, 1) // buffered!
data := 0

go func() {
    data = 42
    ch <- struct{}{} // send completes immediately into buffer
}()

<-ch
fmt.Println(data) // NOT guaranteed

For buffered channels, the spec says: the kth receive from a channel with capacity C happens-before the (k+C)th send completes. For an unbuffered channel this simplifies to “send blocks until receive”, which creates the ordering. For a buffered channel, the send can complete before anyone reads, so there’s no happens-before from that send to the subsequent receive.

The fix is using an unbuffered channel, a mutex, or sync/atomic.

sync/atomic is not a silver bullet

Pre-Go 1.19, sync/atomic operations were sequentially consistent by accident (the x86 TSO memory model made it work). Post-1.19, the spec formally guarantees sequential consistency for atomic operations — but only within a single atomic variable.

Two different atomic variables still have no ordering guarantee between them:

go
var flag1, flag2 atomic.Bool

// goroutine 1
flag1.Store(true)
flag2.Store(true)

// goroutine 2
if flag2.Load() {
    // flag1.Load() might still be false here
}

If you need ordering between two atomic variables, you need a mutex or you need to redesign your data structure.

Practical takeaways

Use the race detector always in tests:

go test -race ./...

Prefer channels over shared memory for communication between goroutines. If you’re using a mutex + condition variable, consider whether a channel expresses the intent better.

Don’t rely on sync/atomic for complex multi-variable invariants. Use sync.Mutex and make the critical section explicit.

Read the spec. It’s short. The 2022 revision added machine-word access guarantees and formalised atomic semantics. It’s worth an afternoon.

The memory model is one of those things where you can write correct Go for years without thinking about it — until one day in production you can’t.

By PatrickChoDev

You might also like