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.Lock happens-before any subsequent call to sync.Mutex.Unlock on the same mutex. This is the fundamental guarantee of a mutex: the lock operation synchronizes access. Anything done after a Lock and before the corresponding Unlock is protected.
  • A call to sync.RWMutex.RLock happens-before any subsequent call to sync.RWMutex.RUnlock on the same RWMutex. Similar to Mutex, but for read locks.
  • A call to sync.Once.Do happens-before any subsequent call to sync.Once.Do on the same Once value. This ensures initialization logic runs exactly once.
  • An init function finishes before the main function 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 go statement.
  • 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.

Want structured learning?

Take the full Golang course →