The Go memory model

Go · Expert · 15 min

Correct lock-free and concurrent code depends on exactly which operations order memory between goroutines.

The Go memory model answers one question: when is a goroutine guaranteed to see a write made by another? Without synchronisation, never — compilers and CPUs reorder and cache memory operations freely, as long as a single goroutine can't tell.

Guarantees come from happens-before edges created by synchronising operations:

  • go f() happens before f starts.
  • A send on a channel happens before the corresponding receive completes; closing a channel happens before a receive that returns because it's closed.
  • For an unbuffered channel, a receive happens before the matching send completes. Generally, the k-th receive on a channel with capacity C happens before the (k+C)-th send completes — which is why a buffered channel works as a semaphore.
  • mu.Unlock() happens before the next mu.Lock() returns. once.Do(f): f's completion happens before any Do returns. wg.Done happens before the Wait it unblocks.
  • sync/atomic operations are sequentially consistent: if an atomic load observes an atomic store, the store happens before the load.

Happens-before is transitive, so writes before a send are visible after the receive.

Since the 2022 revision (Go 1.19), Go promises DRF-SC: a program without data races behaves as if goroutines were interleaved on one core. With a race, guarantees mostly evaporate. Single-word races only expose some value that was written, but races on strings, slices, interfaces or maps can tear multi-word values and corrupt memory.

The classic broken idiom is a plain bool "ready" flag with a busy loop: the reader may never see the update, or see the flag before the data. Use a channel, a mutex, or an atomic.Bool — then both the flag and the data written before it become visible.

Example

package main

import (
	"fmt"
	"sync/atomic"
)

var (
	data  string
	ready atomic.Bool // plain bool here would be a data race
)

func producer() {
	data = "hello"    // (1) ordinary write
	ready.Store(true) // (2) atomic store publishes (1)
}

func main() {
	go producer()
	for !ready.Load() { // (3) observes (2), so (1) happens before (4)
	}
	fmt.Println(data) // (4) guaranteed: hello
}

Remember

  • Visibility across goroutines requires a happens-before edge
  • Channels, mutexes, Once, WaitGroup and atomics create those edges
  • Race-free programs are sequentially consistent; racy ones have no reliable meaning

Watch out for

  • Publishing data with a plain bool flag and a spin loop — the reader may never see it, or see stale data.

Check yourself

  1. Goroutine A writes x = 1 then sends on unbuffered channel c. Goroutine B receives from c then reads x. What does B see?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: go.dev/ref/mem

Builds on

Unlocks

Nothing builds on this yet.