Skip to content
gohacks.dev
Concurrency

The Go Memory Model and the Happens Before Rules Explained

Learn the happens-before rules of the Go memory model with runnable examples: channels, mutexes, Once, WaitGroup and atomics, and how to spot a missing edge.

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

Every concurrent Go program makes a promise somewhere that goes "goroutine B will see what goroutine A wrote". Sometimes the promise is true because of a channel, a lock or a WaitGroup. Sometimes it is only true because the author's laptop happens to run things in a convenient order. The Go memory model exists to separate those two cases, and its central idea is a relation called happens before. Once you can name the edge that connects a write to a read, you know the program is correct. If you cannot name one, you have a race.

Tested with Go 1.25.6, macOS on Apple Silicon (arm64)

This article lists the edges Go guarantees and demonstrates each with a program you can run. It is the theory under data races, the race detector and every synchronization primitive in the concurrency patterns cluster. The authoritative text is the Go memory model, which is short and worth reading in full. Everything here ran on Go 1.25.6.

The idea in one paragraph

Within a single goroutine, statements appear to execute in program order. Across goroutines, nothing is ordered unless something orders it. The memory model defines a set of synchronization operations and says that when one of them in goroutine A is observed by a matching one in goroutine B, everything A did before it happens before everything B does after. A read is then guaranteed to see a write if the write happens before the read and no other write intervenes. Without such a chain a read may see an old value, a new value, or (for multi-word types) a mixture, and the compiler may reorder or remove the access.

Note what is missing from that definition: time. "A did it a second earlier" establishes nothing. Neither does time.Sleep. Sleeping before a read might make a race less likely on your machine and still leaves it a race.

Starting a goroutine

The go statement happens before the goroutine's execution begins. Anything you wrote before the go is visible inside the new goroutine, which is why passing data by capturing it in a closure is safe:

main.goGo
package main

import (
	"fmt"
	"sync"
)

func main() {
	config := map[string]string{"region": "eu-west-1"}
	config["tier"] = "prod" // written before the go statement

	var wg sync.WaitGroup
	wg.Go(func() {
		fmt.Println("worker sees:", config["region"], config["tier"])
	})
	wg.Wait()
}
Text
worker sees: eu-west-1 prod

The edge goes only one way. Goroutine exit has no edge to anything: nothing guarantees that writes made just before a goroutine returns are visible to another goroutine, which is why you must signal completion explicitly rather than assuming it.

Channels

Channels provide the edges you will use most, and the memory model lists four rules:

  • A send on a channel happens before the corresponding receive from that channel completes.
  • The closing of a channel happens before a receive that returns because the channel is closed.
  • A receive from an unbuffered channel happens before the send on that channel completes.
  • The k-th receive on a channel with capacity C happens before the (k+C)-th send from that channel completes.

The first rule is what makes passing results over a channel safe. The writer fills a structure and sends a pointer to it; the reader receives and may read the structure without a lock, because the send ordered the writes before the receive:

main.goGo
package main

import "fmt"

type result struct {
	id    int
	words []string
}

func main() {
	ch := make(chan *result)

	go func() {
		r := &result{id: 7}
		r.words = append(r.words, "ordered", "by", "the", "send")
		ch <- r // everything above happens before the receive below completes
	}()

	r := <-ch
	fmt.Println(r.id, r.words)

	// Closing is a signal that carries the same guarantee.
	done := make(chan struct{})
	var message string
	go func() {
		message = "written before close"
		close(done)
	}()
	<-done
	fmt.Println(message)
}
Text
7 [ordered by the send]
written before close

Both prints are guaranteed to show the data. The second half is the pattern behind every done channel: the closer writes some state, closes the channel, and any goroutine that sees the close also sees the state. That is why closing a channel is a better broadcast than setting a boolean.

The third rule is the surprising one. For an unbuffered channel the receive happens before the send completes, which means the sender can rely on the receiver having reached its receive point. It makes an unbuffered channel a rendezvous: when ch <- v returns, the other side has taken the value. A buffered channel gives up that guarantee, since a send can complete with nobody receiving.

The fourth rule is the one that turns a buffered channel into a counting semaphore. If a channel has capacity 3, the first receive happens before the fourth send completes. Here is the limiter built from it, which keeps at most three workers running:

main.goGo
package main

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

func main() {
	sem := make(chan struct{}, 3) // capacity 3: at most 3 holders
	var running, peak atomic.Int32
	var wg sync.WaitGroup

	for range 12 {
		wg.Go(func() {
			sem <- struct{}{} // acquire: blocks when 3 are inside
			n := running.Add(1)
			for {
				p := peak.Load()
				if n <= p || peak.CompareAndSwap(p, n) {
					break
				}
			}
			time.Sleep(5 * time.Millisecond) // hold the slot so the others pile up
			running.Add(-1)
			<-sem // release: happens before a later acquire completes
		})
	}
	wg.Wait()
	fmt.Println("peak concurrent holders:", peak.Load(), "(limit 3)")
}
Text
peak concurrent holders: 3 (limit 3)

The peak never exceeds three, by the fourth rule. The semaphore article builds on this.

Locks

For sync.Mutex and sync.RWMutex, the memory model says that for any call n of Unlock, the call happens before the next Lock that returns. Everything written inside the first critical section is visible inside the next one. That is the entire reason a mutex protects data: it is a chain of edges, one per hand-off. It also tells you what does not work. Reading a protected variable outside the lock has no edge to the writers and races, no matter how brief the read (mutexes cover the practical rules, and RWMutex the read-lock variant).

Once and WaitGroup

sync.Once has a precise edge. The completion of a single call of f from once.Do(f) happens before any call of once.Do(f) returns. Every caller, not only the one that ran f, sees whatever f wrote:

main.goGo
package main

import (
	"fmt"
	"sync"
)

var (
	once   sync.Once
	config map[string]int
)

func load() {
	config = map[string]int{"retries": 3, "timeout": 30}
}

func main() {
	var wg sync.WaitGroup
	for i := range 3 {
		wg.Go(func() {
			once.Do(load)
			fmt.Printf("goroutine %d sees retries=%d\n", i, config["retries"])
		})
	}
	wg.Wait()
	fmt.Println("after Wait, main sees timeout =", config["timeout"])
}
Text
goroutine 0 sees retries=3
goroutine 2 sees retries=3
goroutine 1 sees retries=3
after Wait, main sees timeout = 30

All three goroutines see the fully built map, whichever one ran load. This is the safe replacement for the double-checked locking in why a data race is never benign. WaitGroup has the same shape from the other direction: a call to Done happens before the Wait that it unblocks returns. That is what makes the final line in the program, reading config in main after Wait, safe. The order of the three lines from the goroutines is not fixed, and that is fine: the memory model guarantees visibility, not scheduling order.

Atomics

Since Go 1.19 the memory model states that all sync/atomic operations behave as if they were executed in some sequentially consistent order, and that if the effect of an atomic operation A is observed by atomic operation B, then A happens before B. An atomic store followed by an atomic load that sees the stored value forms an edge, and it carries every earlier write with it. So publishing a pointer with atomic.Pointer.Store after filling the object is safe, while publishing it with a plain assignment is not (typed atomics). The same guarantee does not extend to plain variables that merely sit next to an atomic: only an edge connects them to the atomic, not their proximity.

Spotting the missing edge

Here is a program that looks correct and has no edge between the write and the read. A worker sets a result and exits, and the main goroutine waits a while, then reads it:

main.goGo
package main

import (
	"fmt"
	"time"
)

func main() {
	var result int
	go func() {
		result = 42
	}()
	time.Sleep(50 * time.Millisecond) // "plenty of time", but not an edge
	fmt.Println("result:", result)
}
Text
result: 42
==================
WARNING: DATA RACE
Read at 0x00c00012a018 by main goroutine:
  main.main()
      example.test/lab/main.go:14 +0xd0

Previous write at 0x00c00012a018 by goroutine 7:
  main.main.func1()
      example.test/lab/main.go:11 +0x2c

Goroutine 7 (finished) created at:
  main.main()
      example.test/lab/main.go:10 +0x98
==================
Found 1 data race(s)
exit status 66

It prints 42 on every run on this machine, and the race detector reports a data race anyway, because time.Sleep establishes no relationship between the two goroutines. The race detector is built on exactly the happens-before relation described above, which is why it can flag a race that has never gone wrong. The repair is to add the edge: wait on a channel or a WaitGroup, or send the value back.

The check I run in review is mechanical. For each shared variable, find a write and a read in different goroutines, then name the chain of edges from the write to the read: a send and its receive, an unlock and the next lock, a Done and its Wait, an atomic store and the load that saw it. If the chain exists, the pair is fine. If I cannot write it down, I ask for a primitive. The sync package documentation repeats the edges for each primitive in its own words. Most designs get simpler once the answer is "pass it over a channel" (share memory by communicating), because the channel makes the edge visible in the code.

Also inBasics
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.