The Go Memory Model in Practice: Happens-Before Rules for Everyday Concurrency
The Go memory model opens by telling you not to read it: “If you must read the rest of this document to understand the behavior of your program, you are being too clever. Don’t be clever.”
Fine advice. The document is short, and its rules apply every time you write go func(), send on a channel, or call mu.Unlock(). You’re using them whether or not you’ve read them. The difference is whether a code review ends with “this probably works” or “this works because the send is synchronized before the receive completes.”
So here are those rules, in the form you’d use them while reading someone else’s concurrent code.
The guarantee you want: DRF-SC
Go’s central guarantee is that a data-race-free program executes as if all its goroutines were multiplexed onto a single processor. The memory model calls this DRF-SC: data-race-free programs execute in a sequentially consistent manner.
A data race is a write to a memory location happening concurrently with another read or write to that same location, unless every access involved is an atomic operation from sync/atomic.
With no data races, you can model your program as a plain interleaving of statements. Once a race is present, that sequential-consistency guarantee no longer applies. Go still places limited constraints on racy executions — for example, a single-word read must observe a value that was actually written — but those constraints are not a sound basis for concurrent code. As we’ll see, races on multiword values can be much worse.
For ordinary shared memory, that reduces the review task to establishing a happens-before edge between the write and the read.
Where happens-before edges come from
Happens-before is the transitive closure of two relations. First, sequenced before: the ordering inside one goroutine, which comes from the language spec’s control flow and expression evaluation rules. Second, synchronized before: the ordering created when a synchronizing read observes a synchronizing write. Channel operations, mutex operations, atomics, sync.Once.
Transitivity does most of the work. You almost never need an edge directly between the two statements you care about. You need a chain: write → send (sequenced), send → receive (synchronized), receive → read (sequenced). That chain is why the next example works.
Goroutine creation gives you an edge; goroutine exit does not
The go statement is synchronized before the goroutine starts running. Everything the parent did before go f() is visible inside f:
var a string
func hello() {
a = "hello, world"
go func() {
print(a) // guaranteed to see "hello, world"
}()
}
The reverse doesn’t hold. The exit of a goroutine is synchronized before nothing at all.
var a string
func hello() {
go func() { a = "hello" }()
print(a) // race; no guarantee, and the compiler may delete the go statement entirely
}
Read that comment again. The compiler is permitted to delete the go statement. That’s why a fire-and-forget goroutine whose effects must be observed needs an explicit synchronization mechanism: a channel, a WaitGroup, a mutex, or an atomic operation, for example. There is no implicit join.
Channels: know which side is guaranteed
Four rules cover channel synchronization:
- A send is synchronized before the corresponding receive completes.
- Closing a channel is synchronized before a receive that returns the zero value because the channel is closed.
- On an unbuffered channel, a receive is synchronized before the corresponding send completes.
- The kth receive on a channel with capacity C is synchronized before the *(k+C)*th send completes.
The third one is the one people get backwards. On an unbuffered channel the sender learns something too:
var c = make(chan int) // unbuffered
var a string
func f() {
a = "hello, world"
<-c // receive
}
func main() {
go f()
c <- 0 // send completes only after the receive
print(a) // guaranteed "hello, world"
}
Change one character, make(chan int, 1), and the guarantee evaporates. The send now completes into the buffer before f has necessarily run. The program can print an empty string. Buffering a channel “for performance” is a memory-model change, not just a scheduling change.
The fourth rule is the formal basis for the buffered-channel semaphore: send to acquire, receive to release, capacity is your concurrency limit. For the mechanics of channel operations themselves, see Go channels: send, receive, close, and range without surprises.
Mutexes and the TryLock gotcha
For a sync.Mutex or sync.RWMutex l, and n < m: call n of l.Unlock() is synchronized before call m of l.Lock() returns. One sentence, and it’s enough to justify every “guard the struct with a mutex” pattern you’ve ever written.
It also makes a mutex usable as a one-shot signal, which is legal even though it reads oddly:
var l sync.Mutex
var a string
func f() {
a = "hello, world"
l.Unlock()
}
func main() {
l.Lock()
go f()
l.Lock() // returns only after f's Unlock
print(a) // guaranteed
}
RWMutex has the matching rule: some Unlock is synchronized before an RLock returns, and the corresponding RUnlock is synchronized before the next Lock returns.
Then there’s TryLock. A failed TryLock has no synchronizing effect whatsoever, and the memory model permits it to return false even when the mutex is unlocked. You cannot infer anything about another goroutine’s state from a failure. In a retry loop, only the call that eventually succeeds behaves like Lock; the earlier failures contribute no ordering.
Atomics are sequentially consistent, and that’s what makes pointer publishing safe
Every atomic operation in a program behaves as though executed in some sequentially consistent order. If atomic operation B observes the effect of atomic operation A, then A is synchronized before B. Same semantics as C++ seq_cst atomics, same as Java’s volatile.
Strong enough to publish an immutable object without a lock:
package main
import (
"fmt"
"sync/atomic"
)
type Config struct {
Endpoint string
Timeout int
}
var current atomic.Pointer[Config]
// Called by a background reloader goroutine.
func reload(c *Config) {
// Fields are written before the atomic store, so the store is
// sequenced after them and readers that Load this pointer see them.
current.Store(c)
}
// Called concurrently by many request-handling goroutines.
func endpoint() string {
c := current.Load()
if c == nil {
return ""
}
return c.Endpoint
}
func main() {
reload(&Config{Endpoint: "https://api.example.com", Timeout: 5})
fmt.Println(endpoint())
}
Two properties make this correct, and both matter. When Load observes the pointer written by Store, that pair supplies the synchronized-before edge, so the earlier writes to the Config fields are visible to the reader. And the published Config is never mutated; reload always receives a fresh one. Mutate c.Timeout concurrently after the store and you’ve added a plain data race. Publishing a pointer atomically does not make later mutations to the object safe.
Initialization: init, imports, and sync.Once
Initialization gets its own edges, and they’re generous. If package p imports package q, completion of q’s init functions happens before any of p’s init functions start. And completion of all init functions is synchronized before main.main starts.
Package-level variables set during initialization are therefore safe to read from main and from goroutines started later, provided they are not then mutated concurrently without synchronization.
For lazy initialization, sync.Once gives you the guarantee you’d hope for: completion of the single call to f() is synchronized before the return of any call to once.Do(f). Every caller sees everything f wrote, including the callers that blocked and never ran f themselves.
The idioms that look fine and aren’t
Double-checked locking with a plain bool. Checking if !done before calling once.Do(setup) is a race. Observing the write to done does not imply observing the writes setup performed before it. The reader can see done == true and still print an empty string.
Busy waiting on a plain bool:
var a string
var done bool
func setup() {
a = "hello, world"
done = true
}
func main() {
go setup()
for !done {
}
print(a) // may print ""
}
Two separate failures live in that loop. Observing done doesn’t imply observing a. And with no synchronization at all between the two goroutines, nothing guarantees main ever observes the write to done, so the loop may spin forever. Use an atomic.Bool and read a only after its Load returns true, or close a channel after assigning a and wait for that close. Either establishes the missing ordering.
Publishing a pointer with a plain write. The subtle version: set t.msg, then g = t, while the reader spins until g != nil. Even after the reader sees a non-nil g, nothing guarantees it sees the initialized g.msg. atomic.Pointer exists precisely for this.
Races on multiword values can corrupt memory, not just return stale data
Reads and writes of values larger than a machine word may be implemented as several word-sized operations in unspecified order. Strings and interface values are commonly represented by two-word descriptors, while slices use a multiword descriptor. Maps and other runtime data structures also rely on internally consistent metadata.
Race on one of those and you can get a value that never corresponded to any single write: a pointer from one value paired with a length or type from another. That isn’t merely a stale read. The memory model warns that these inconsistent values can lead to arbitrary memory corruption.
Which is the concrete answer to “it’s only a config string, a torn read is harmless.”
A review checklist
Reading shared state in a review, ask one question per variable: what is the happens-before edge between the write and the read?
If you can name it out loud, “the send on results is synchronized before the receive in the collector,” you’re finished. If the answer is “the goroutine runs first in practice” or “it’s a single word, so it’s atomic anyway,” you’ve found a bug and you’re arguing with the hardware about it.
Then run the race detector under load that resembles production. It only reports races it observes, so a clean run proves you didn’t hit one this time, nothing more. When it does fire, take the report seriously. Go permits an implementation to report a race and terminate the program, while the race detector’s default is to continue after the first report (halt_on_error=0) and exit unsuccessfully. Set GORACE="halt_on_error=1" if you want it to stop at the first detected race.