3 min read

Memory models and why they matter for concurrent code

Concurrent code fails in ways that are hard to reproduce and hard to reason about. A lot of those failures come from wrong assumptions about memory visibility, specifically, the assumption that if one goroutine or thread writes a value, other goroutines or threads will see it.

That assumption is not always true. Memory models define when it is.

The Problem: CPUs and Compilers Don't Do What You Think

Modern CPUs don't execute instructions in the order you wrote them. They reorder operations for performance, as long as the reordering doesn't change the outcome for a single thread of execution. Compilers do the same thing. Caches mean that a write from one core may not be immediately visible to another core.

A memory model is the language's formal contract: given this code, under what conditions is a write from one goroutine/thread guaranteed to be visible to another? Without such a contract, the compiler and CPU are free to reorder operations in ways that break your concurrent code, and you have no basis for reasoning about correctness.

The Go Memory Model

Go's memory model is built around the happens-before relationship. If event A happens-before event B, then the effects of A are visible to B. Go guarantees specific happens-before edges:

A goroutine launch happens-before anything in that goroutine. A channel send happens-before the corresponding receive completes. A sync.Mutex unlock happens-before the next lock. A sync.WaitGroup Done happens-before Wait returns.

Anything outside these guarantees is undefined. Consider this:

var x int
var ready bool

go func() {
    x = 42
    ready = true
}()

for !ready {} // spin
fmt.Println(x) // might print 0

There's no happens-before edge between the goroutine writing x and the main goroutine reading it. The compiler or CPU can reorder the writes inside the goroutine, or the main goroutine may see a stale cached value. This data race is undefined behaviour in Go, you cannot reason about what it will print.

The correct version uses a channel or mutex to establish happens-before:

var x int
ch := make(chan struct{})

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

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

The Java Memory Model

Java's memory model is similar in spirit but more complex, largely because Java has more concurrency primitives and a longer history of getting this wrong.

The key guarantee is around the volatile keyword and synchronized blocks. A write to a volatile variable happens-before every subsequent read of that variable. Exiting a synchronized block happens-before every subsequent entry to a synchronized block on the same monitor.

Java also has the happens-before edges you'd expect: thread start/join, object construction before any published reference to it.

The classic Java memory model bug is the double-checked locking pattern, prior to Java 5, the common implementation was broken because the model didn't guarantee that a partially-constructed object wouldn't be visible to another thread. The fix (making the field volatile) requires knowing what the memory model actually guarantees.

The Practical Upshot

You don't need to memorise the formal spec, but you do need to internalise two things. First: shared mutable state requires explicit synchronisation, channels, mutexes, atomic operations. The language won't warn you when you've missed one; the race detector will, but only if the race actually triggers in your test. Second: the race detector is not optional. Run it in CI. Data races are undefined behaviour and they will produce wrong results in ways that are nearly impossible to debug after the fact.

The Go race detector (go test -race) has caught real bugs in production code that looked obviously correct. If you write concurrent Go and you're not running it, you're flying blind.

The Bottom Line

Memory models exist because hardware and compilers make optimisations that are invisible to single-threaded code but catastrophic in concurrent code. Go and Java's models give you the tools to write correct concurrent programs, but only if you use the synchronisation primitives that create the happens-before edges the model guarantees. Anything else is a data race, and data races are bugs waiting for the right conditions to surface.