The Go memory model is surprisingly simpler than you might think, and it’s not about when memory is written, but about what order operations are guaranteed to be visible to other goroutines.
Let’s watch a goroutine trying to signal another that some data is ready:
package main
import (
"fmt"
"sync"
"time"
)
var data string
var ready bool
func worker() {
// Keep checking until 'ready' is true
for !ready {
time.Sleep(10 * time.Millisecond) // Don't spin too hard
}
// Once ready, read the data
fmt.Println("Worker read:", data)
}
func main() {
go worker()
time.Sleep(50 * time.Millisecond) // Give worker a head start
data = "hello from main"
ready = true // Signal that data is ready
time.Sleep(1 * time.Second) // Keep main alive to see output
}
If you run this, you might see:
Worker read:
Or you might see:
Worker read: hello from main
The problem is that the worker goroutine might see ready as true before it sees the write to data. Or worse, it might see the write to data before it sees ready as true, but the write to data itself might be reordered. The Go memory model exists to give us guarantees about this.
The core concept is the "happens-before" relationship. If event A happens-before event B, then the effects of event A are guaranteed to be visible to event B. This isn’t about strict chronological order, but about guaranteed visibility.
Here are the key happens-before rules in Go:
- Send on a channel happens-before the receive on that channel. This is the primary way to communicate and synchronize in Go. When you send a value on a channel, the write to the channel’s buffer is guaranteed to happen-before the read from the buffer by the receiving goroutine.
- Close on a channel happens-before any receive on that channel that returns a zero value. If a channel is closed, any goroutine attempting to receive from it will eventually get the zero value for the element type. The act of closing the channel synchronizes this.
- Receive on a channel happens-before the completion of that receive. This sounds trivial, but it means that once a receive operation successfully returns a value, that value is guaranteed to be the one that was sent.
- A call to
sync.Mutex.Lockhappens-before any subsequent call tosync.Mutex.Unlockon the same mutex. This is the fundamental guarantee of a mutex: the lock operation synchronizes access. Anything done after aLockand before the correspondingUnlockis protected. - A call to
sync.RWMutex.RLockhappens-before any subsequent call tosync.RWMutex.RUnlockon the same RWMutex. Similar toMutex, but for read locks. - A call to
sync.Once.Dohappens-before any subsequent call tosync.Once.Doon the sameOncevalue. This ensures initialization logic runs exactly once. - An
initfunction finishes before themainfunction starts. - The start of a goroutine happens-before any operation in that goroutine. This means the goroutine’s first operation is guaranteed to happen after the
gostatement. - The completion of a goroutine happens after all operations in that goroutine. This is less useful for synchronization, but it’s part of the model.
Crucially, for memory operations within a single goroutine, the Go memory model does not guarantee any particular ordering between them. The compiler and processor are free to reorder them for optimization. The only way to establish an ordering guarantee between operations in different goroutines is through one of the happens-before relationships, typically involving communication primitives like channels or synchronization primitives like mutexes.
Let’s fix our example using a channel:
package main
import (
"fmt"
"time"
)
var data string
func worker(done chan bool) {
// Block until 'done' receives a signal
<-done
fmt.Println("Worker read:", data)
}
func main() {
done := make(chan bool)
go worker(done)
time.Sleep(50 * time.Millisecond) // Give worker a head start
data = "hello from main"
close(done) // Signal that data is ready and close the channel
time.Sleep(1 * time.Second) // Keep main alive to see output
}
In this corrected version, the close(done) operation happens-before any receive on done that returns a zero value. Since the worker goroutine is blocked on <-done, it will unblock only after done is closed. By the happens-before rule, the write to data that occurred before close(done) is guaranteed to be visible to the worker goroutine after it unblocks.
If you need to protect shared mutable state that is read and written by multiple goroutines, you use sync.Mutex or sync.RWMutex.
package main
import (
"fmt"
"sync"
"time"
)
var data string
var mutex sync.Mutex // Protects access to 'data'
func worker() {
mutex.Lock() // Acquire the lock
fmt.Println("Worker read:", data)
mutex.Unlock() // Release the lock
}
func main() {
go worker()
time.Sleep(50 * time.Millisecond) // Give worker a head start
mutex.Lock() // Acquire the lock
data = "hello from main"
mutex.Unlock() // Release the lock
time.Sleep(1 * time.Second) // Keep main alive to see output
}
Here, the mutex.Lock() in main happens-before the mutex.Unlock() in main. And the mutex.Lock() in worker happens-before its corresponding mutex.Unlock(). More importantly, the Unlock in main happens-before the Lock in worker if they are on the same mutex instance, establishing a happens-before relationship that guarantees the write to data is visible.
The Go memory model doesn’t care about the speed of memory operations or cache coherency in the way some lower-level models do. It’s a higher-level abstraction focused on the ordering of events that are observable between goroutines. The key takeaway is that without an explicit synchronization primitive (like a channel send/receive, mutex lock/unlock, etc.), you cannot rely on the order of operations between goroutines, even if they appear to be in the same program order.
The most common mistake people make is assuming that a write in one goroutine is visible to another goroutine immediately after the write, or that operations within a single goroutine are ordered as written. The memory model explicitly states this is not the case without synchronization.
The next rabbit hole you’ll fall down is understanding how sync.Once works to ensure initialization code runs exactly one time across multiple goroutines.