The Memory Model: When a Goroutine Actually Sees Your Write
The rules that decide when one goroutine is guaranteed to observe another's writes: the happens-before relation, how every synchronization primitive in this series establishes it, why unsynchronized access is undefined rather than merely risky, and why the race detector is the enforcement tool. Compiled and run against Go 1.26.5.
Here is a question that sounds trivial and is not. One goroutine writes x = 42. Another goroutine reads x. Is the reader guaranteed to see 42?
Your intuition says yes, obviously, they share the same memory. Your intuition is wrong, and the gap between it and the truth is the single most misunderstood thing about concurrent programming. Without synchronization, there is no guarantee at all that the second goroutine ever observes the first’s write. Not “it usually works.” Not “there is a small race window.” The behavior is undefined: the reader may see the new value, may see the old value forever, and on some multi-word types may see a torn value that was never written by anyone. The Go memory model is the specification that tells you exactly when the guarantee holds, and this chapter is the rulebook underneath every primitive you have used in this series.
Why the naive picture is wrong
The mental model of “memory is one big shared array that every goroutine reads and writes directly” is a fiction that hardware and compilers both conspire to break, for the sake of speed.
The compiler reorders and caches. Nothing stops it from hoisting a read of x out of a loop, so your goroutine spins forever on a value it loaded once. A CPU core keeps writes in its own store buffer before they reach memory other cores can see, and cores can observe each other’s writes in different orders. None of this is a bug; it is how a modern machine reaches the throughput it does, and for single-goroutine code it is invisible because the compiler and CPU preserve the appearance of sequential order for that one goroutine. Across goroutines, that guarantee evaporates. What you get instead is a contract written in terms of one relation.
Happens-before
The memory model is built on a single ordering called happens-before. The rule that matters is short enough to memorize:
A read of a variable is guaranteed to observe a particular write only if that write happens-before the read, and no other write to the same variable intervenes between them.
Within a single goroutine, happens-before is just program order: line 3 happens-before line 4, exactly as written. The hard part is across goroutines, where program order says nothing, because the two goroutines have no inherent order between them. The only way to create a happens-before edge between two goroutines is a synchronization event, and this is the punchline of the whole series: every primitive you have learned exists precisely to manufacture these edges.
- A channel send happens-before the corresponding receive completes. This is the workhorse. The send and the value it carries, plus everything the sender did before it, are visible to whoever receives.
- A channel close happens-before a receive that returns because the channel is closed (the zero-value receive). Closing a
donechannel to broadcast, the pattern from the leaks and context chapters, is a happens-before broadcast. - A mutex
Unlockhappens-before any subsequentLockof the same mutex. Everything you did inside one critical section is visible inside the next one. sync.Once: the return ofonce.Do(f)happens-afterfcompletes, so the initializationfperformed is visible to every caller.sync/atomicoperations are totally ordered and establish happens-before between the atomic write and the atomic read that observes it.- Starting a goroutine with
go f()happens-after thegostatement’s setup, and aWaitGroup’sWaitreturns only after theDonecalls it waited on.
Every one of those is a fact you have used already, restated in the vocabulary the specification uses. When you send on a channel or unlock a mutex, you are not just moving data or guarding a section; you are publishing every write you made before it to the goroutine on the other side of that edge.
What “undefined” really means
The word to take seriously is undefined, not risky. A program with a data race is not a correct program that occasionally hiccups; it is a program the memory model makes no promises about whatsoever. Here is the smallest version, with no synchronization anywhere:
package main
import "fmt"
// done and result are shared, with NO synchronization between the writer
// goroutine and main. There is no happens-before edge, so main is not
// guaranteed to observe either write — reading them here is undefined.
func main() {
var result int
var done bool
go func() {
result = 42 // write 1
done = true // write 2
}()
for !done { // read of done, racing with write 2
}
fmt.Println("result:", result) // read of result, racing with write 1
}
There is no channel, no mutex, no atomic, nothing that creates a happens-before edge between the goroutine’s writes and main’s reads. The loop for !done {} looks like it waits for the goroutine, but the memory model gives main no guarantee it will ever observe done becoming true, and the compiler is free to read done once and spin forever. Even if the loop does exit, there is no guarantee main then sees result == 42 rather than the stale 0, because the two writes can become visible in the other order. On this run it happened to print 42, and that is the trap: it works right up until a compiler upgrade, a different CPU, or a slightly different program shape quietly breaks it. You cannot test your way to confidence about undefined behavior, because a run that passes proves nothing about the next one.
The race detector is the enforcement tool
So how do you catch this, given that a passing run is not evidence? You do not reason your way to clever lock-free code and hope. You use the tool built for exactly this, the race detector from the mutex chapter, which does not care what the output was on this run. It instruments memory accesses and reports any pair of accesses to the same location, from different goroutines, with no happens-before edge between them and at least one a write. That is the operational definition of a data race, and it is precisely the thing the memory model leaves undefined.
$ go run -race .
==================
WARNING: DATA RACE
Write at 0x00c000016108 by goroutine 7:
main.main.func1()
.../c2-13-race/main.go:14 +0x49
Previous read at 0x00c000016108 by main goroutine:
main.main()
.../c2-13-race/main.go:17 +0x106
...
Found 2 data race(s)
exit status 66
The program printed result: 42 and exited with status 66, because -race found two races: one on done, one on result. The detector reported the exact bug the eye missed, on a run where the output looked perfect. This is why the advice from the sync chapter is worth repeating as the closing rule of the whole series: the memory model tells you what is guaranteed, and -race tells you where you violated it. Run your concurrent tests and a representative slice of production under -race. It has essentially no false positives, and it finds the races your intuition swears are not there.
The fix is never “add a sleep” or “it seems to work now.” It is to introduce a real happens-before edge. Swapping the racy flag for a channel does it:
func main() {
var result int
done := make(chan struct{})
go func() {
result = 42 // write happens-before the send
close(done) // close happens-before the receive returns
}()
<-done // receive completes only after the close
fmt.Println("result:", result)
}
$ go run -race .
result: 42
Now close(done) happens-before the <-done that unblocks main, and the write to result happens-before the close, so by transitivity main is guaranteed to see 42. Same program shape, one real synchronization edge, and the detector is silent because there is no longer anything undefined to find.
Final thoughts, and where this series has taken you
Step back and the whole series resolves into one idea. Goroutines gave you cheap concurrent execution. Channels, select, the sync toolkit, context, and the pipeline and worker-pool patterns were the ways to coordinate that execution. This chapter is what they were all secretly for: every one of them exists to establish happens-before, to turn a pile of goroutines racing over shared memory into a program whose observations are actually defined. A channel send is not only a handoff of data; it is a promise about visibility. A mutex does not only prevent two goroutines from being in a section at once; it publishes one’s writes to the next. Once you see the primitives as machinery for manufacturing happens-before edges, the rules stop being a list to memorize and become one idea you can reason from.
Carry three things out of the series. Share memory by communicating over channels when you can, and when you share it directly, guard it with a mutex or an atomic. Never trust a concurrent program because it passed once; undefined behavior is not the same as tested behavior. And run everything with goroutines in it under -race, because it is the one tool that enforces the model instead of trusting your intuition about it.
That is concurrency in Go: not a hard language feature, but a small set of primitives and a single relation that ties them together. The next series in the Go track, Building Real Things in Go, turns from how the language runs to what you build with it: HTTP servers and clients, encoding and decoding JSON, reading and writing files, testing your code properly, and the tooling that ships real programs. You have the engine now. Time to build something that drives on it.
Comments