Explain the Go memory model — what guarantees do channels and mutexes give you?

6 minadvancedgomemory-modelconcurrencyadvanced

Quick Answer

The Go memory model defines when a write in one goroutine is guaranteed to be visible to a read in another goroutine — this is called a happens-before relationship, and without one, the compiler and CPU are free to reorder or cache operations in ways that break naive concurrent code. Channel operations and mutex operations both establish happens-before edges: a send on a channel happens-before the corresponding receive completes, and unlocking a mutex happens-before a subsequent lock of that same mutex by another goroutine. In practice, this means safely sharing data between goroutines requires going through a channel or a mutex (or an equivalent sync/atomic primitive) — touching shared memory with plain reads/writes and no synchronization is a data race, even if it "seems to work" in testing.

Detailed Answer

This is one of the more subtle Go topics, but the practical takeaway is simple: don't share data across goroutines without going through a synchronization primitive.

Why "it works in my test" isn't proof

var ready bool
var data int

go func() {
    data = 42
    ready = true   // no synchronization with the reader
}()

for !ready {}      // BUG: data race, and the compiler may cache "ready" in a register forever
fmt.Println(data)

Without a happens-before relationship between the write to ready/data and the read of them, the compiler is allowed to assume nothing else changes ready, and might optimize the for !ready {} loop into an infinite loop that never re-reads memory. This isn't a hypothetical: it's a real category of bug the race detector exists specifically to catch.

What actually establishes happens-before

// Channel send/receive
ch := make(chan int)
go func() {
    data := 42
    ch <- data     // send happens-before the matching receive completes
}()
v := <-ch          // guaranteed to observe everything written before the send
// Mutex lock/unlock
var mu sync.Mutex
var data int

go func() {
    mu.Lock()
    data = 42
    mu.Unlock()    // unlock happens-before a later goroutine's Lock() on the same mutex
}()

mu.Lock()
fmt.Println(data)  // guaranteed to see 42, since this Lock() happens-after the other goroutine's Unlock()
mu.Unlock()

The practical rule

Never read or write a variable from more than one goroutine unless every access goes through a channel, a mutex, or the sync/atomic package. "It passed my tests" doesn't mean a race-free program. Always run go test -race (or build with -race) to have the runtime's race detector actually check for unsynchronized concurrent access.

Related Resources