Skip to content
gohacks.dev
Basics

The Go Memory Model in Plain Language for Developers

The Go memory model says when one goroutine is guaranteed to see another's writes. See happens-before in practice, a race the detector catches and four fixes.

7 min read
Summarize in AI
Flat illustration of one flow fanning out into parallel lanes of goroutines and joining back.

The Go memory model sounds like a topic for people who write compilers, and it is the one piece of theory that decides whether concurrent Go code is correct. It answers a question that looks trivial: if one goroutine writes a variable and another reads it, when is the reader guaranteed to see the new value? The answer is not "after the write happened in time". It is "if there is a chain of synchronization from the write to the read", and without that chain you get no guarantee at all. This article states the rule in plain words, shows a race caught by the detector with real output, and then repairs it four ways. It is part of the Go basics path, sits beside the runtime basics and the garbage collector, and is the theory behind Go concurrency patterns.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64), with the race detector enabled

The rule: happens-before

Within one goroutine, statements behave as if they run in the order you wrote them. Across goroutines there is no such order unless you create one. The Go memory model defines a partial order called happens-before, and one guarantee: a read of a variable may observe a write only if the write happens before the read, or there is no other write that races with it. When neither edge exists between a write and a read, they are concurrent, and the access is a data race. Races are not "slightly wrong" behavior. They are the point where the compiler and CPU are allowed to reorder, cache and combine operations in ways that no ordering of your source lines explains.

The synchronization operations create the edges. The list in the specification, in the form I use it:

OperationEdge it creates
go f()Everything before the go statement happens before f starts
Channel sendThe send happens before the matching receive completes
Channel closeThe close happens before a receive that returns because of it
Unbuffered receiveThe receive happens before the matching send completes
mu.Unlock()Happens before the next mu.Lock() returns
once.Do(f)The return of f happens before any Do returns
wg.Wait()Every wg.Done() happens before Wait returns
sync/atomicAn atomic write happens before an atomic read that observes it

A goroutine's exit has no edge: nothing says that a goroutine finishing makes its writes visible to anyone, so waiting for it takes one of the operations above. That is the reason time.Sleep is never a synchronization tool. Sleeping long enough "works" until the machine is busy.

A race, caught

This is the classic form. A producer sets a value and then a flag. The consumer spins on the flag and then reads the value. It looks right and it is wrong:

main.goGo
package main

import "fmt"

var data int
var ready bool

func producer() {
	data = 42
	ready = true
}

func main() {
	go producer()
	for !ready {
	}
	fmt.Println(data)
}
Text
42
==================
WARNING: DATA RACE
Write at 0x0001042c7088 by goroutine 7:
  main.producer()
      example.test/lab/main.go:10 +0x40

Previous read at 0x0001042c7088 by main goroutine:
  main.main()
      example.test/lab/main.go:15 +0x34

Goroutine 7 (running) created at:
  main.main()
      example.test/lab/main.go:14 +0x28
==================
==================
WARNING: DATA RACE
Read at 0x0001042c7110 by main goroutine:
  main.main()
      example.test/lab/main.go:17 +0x50

Previous write at 0x0001042c7110 by goroutine 7:
  main.producer()
      example.test/lab/main.go:9 +0x28

Goroutine 7 (finished) created at:
...

The race detector reports each racing pair: a write at one line of the producer and a read at one line of main, with the goroutines that made them and where they were created. There are two warnings, one for ready and one for data, and either would be enough to condemn the program. The reasoning the detector is enforcing is the one above. Nothing orders the write of data before the read in main, since there is no channel operation, lock or atomic between them. Memory ordering differs between CPU families, so a race that stays hidden on one can show up on another, and the compiler is also free to hoist the ready load out of the loop and make it spin forever. The fact that the program printed 42 on your machine tells you nothing.

Four fixes

Each fix replaces the unordered flag with a real edge. They are different tools, and each is the right one in some situation.

A channel. Closing a channel is the lightest signal: the close happens before the receive that observes it, so everything the producer wrote before closing is visible afterwards.

main.goGo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

func main() {
	var data int
	done := make(chan struct{})
	go func() {
		data = 42
		close(done)
	}()
	<-done
	fmt.Println(data)

	var mu sync.Mutex
	var wg sync.WaitGroup
	count := 0
	for range 100 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			count++
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println(count)

	var ready atomic.Bool
	var value atomic.Int64
	go func() {
		value.Store(7)
		ready.Store(true)
	}()
	for !ready.Load() {
	}
	fmt.Println(value.Load())

	var once sync.Once
	cfg := ""
	for range 3 {
		wg.Add(1)
		go func() {
			defer wg.Done()
			once.Do(func() { cfg = "loaded" })
			_ = cfg
		}()
	}
	wg.Wait()
	fmt.Println(cfg)
}
Text
42
100
7
loaded

The program ran under the race detector and printed four lines with no warnings. In order: the channel version published data to the main goroutine, so it read 42. The mutex version protected a shared counter that 100 goroutines incremented, reaching exactly 100, as mutexes in Go explain. The atomic version spins on an atomic.Bool, and because Go's atomics are sequentially consistent since Go 1.19, the store to value before ready.Store(true) is visible once ready reads true. And sync.Once guarantees that the one-time initialization of cfg finishes before any Do returns, so every goroutine sees the loaded value, the pattern in the sync Once family.

To choose among them: use a channel when the event is "this is done" or "here is the value", a mutex when several goroutines read and write shared state, an atomic for a single counter or flag, and Once for lazy initialization. For atomics, see atomic operations.

What a race can do

People hold two wrong beliefs about races: that they only corrupt the value being raced on, and that on a word-sized variable the worst case is reading an old value. For a single word like an int, the model does say a racy read observes some previously written value and not garbage. For multiword values the story is worse. A string is a pointer and a length, a slice is a pointer, a length and a capacity, and an interface is two words. A racy write to one can be seen half-done by a reader, producing a string whose length belongs to a different string, and that is a path to memory corruption that Go is otherwise designed to prevent. The concurrent map write that the runtime detects and kills the process for, fatal error: concurrent map writes, is the runtime defending itself against exactly that. The model's conclusion is blunt, and I agree with it: do not write racy programs and then reason about what they might do.

Finding races

The race detector is built into the toolchain, enabled with -race on go test, go run and go build. It instruments memory accesses and tracks happens-before edges at run time, and it reports pairs that raced during that execution. Two properties govern how you use it. It finds races only on paths that actually ran, so it is only as good as your tests, and a race-free run is evidence and not a proof. And it costs memory and time, the race detector article puts it at roughly five to ten times the memory and two to twenty times the CPU, which is why it belongs in CI and development and not in production. Run go test -race ./... on every change that touches goroutines. If a report points at a spot you believe is safe, the detector is almost always right and the belief is the thing to examine.

Where the model is not the problem

Not every concurrency bug is a race. A deadlock, a goroutine leak and a lost update that is correctly locked but logically wrong are all race-free and still wrong, and the detector will say nothing. Races between a goroutine and the outside world, a file, a database row, are not memory races, and the memory model does not cover them. And performance questions, such as whether an atomic or a mutex is faster, are separate: the model is about correctness, and the measurement is yours to make. Single-threaded programs, or programs where every shared value is handed over through a channel, can ignore the whole topic, which is the design that the Go proverbs recommend and the reason the standard advice is to share by communicating.

Share

Keep reading

Get new articles by email

We send one email when a new article is published. No spam.

Unsubscribe in one click. No spam, ever.