Memory Model and Happens-Before

Updated

September 8, 2026

Memory Model and Happens-Before

Overview

Go’s memory model answers: when is a write in goroutine A guaranteed visible to a read in goroutine B? Without a happens-before edge, “it works on my machine” is data-race undefined behavior — even if the race detector is quiet today under lucky scheduling.

Reference: Go Memory Model (normative).

Diagram: Happens-before edges

flow:
  [B] --HB--> [C]
  [C]
       |
       v
  [D]

Without an edge, concurrent write+read is a data race.

Data Race Definition

A data race is concurrent access to a variable where at least one access is a write and the accesses are not synchronized by the memory model.

go test -race ./...
go run -race .

If -race fires, fix synchronization. Do not “make it volatile” by folklore.

Happens-Before (Core Edges)

Informally, if event X happens before Y, then Y sees all effects of X.

Important edges:

Edge Guarantee
Program order in one G Earlier statements HB later (single goroutine)
go statement Start of new G happens after the go statement executes
Channel send → receive Send HB corresponding receive completion
Channel close → receive zero Close HB receive that sees closed
Mutex unlock → later lock Unlock HB subsequent Lock acquisition
RWMutex Analogous rules for RLock/RUnlock
sync.Once Do completion HB later Do returns
sync.WaitGroup Done HB Wait return after counter hits 0
Atomic ops Synchronizing atomics establish order per model

If you share memory, you almost always want one of: mutex, channel ownership transfer, or atomic with a clear protocol.

Incorrect: Sharing Without Sync

// DATA RACE
var a int
go func() { a = 1 }()
fmt.Println(a) // may print 0 forever, or 1, or worse under compiler reordering assumptions

Correct Patterns

Mutex

var mu sync.Mutex
var a int

// writer
mu.Lock()
a = 1
mu.Unlock()

// reader
mu.Lock()
v := a
mu.Unlock()

Unlock happens before the next Lock; reader sees the write.

Channel hand-off (share by communicating)

ch := make(chan int, 1)
go func() { ch <- 1 }()
v := <-ch // receive happens after send; sees 1

Atomic flag

var ready atomic.Bool
var data int

// producer
data = 42
ready.Store(true) // release-style in practice for this pattern

// consumer
if ready.Load() {
    _ = data // safe if this is the only protocol and documented
}

Prefer mutexes for multi-field invariants; atomics for counters/flags with simple protocols.

Init and Package-Level

All package init functions and variable initialization complete before main starts. Within a package, initialization order is constrained by dependency; across packages, import graph order applies. Do not rely on init order for subtle cross-package races — make dependencies explicit.

Compiler and Hardware Reordering

Without synchronization, compilers and CPUs may reorder memory operations. The memory model is the contract that remains true; your mental model of “statements run in source order across cores” is false.

Experiment

go mod init example
go run -race .
package main

import (
    "fmt"
    "sync"
)

func racy() int {
    var a int
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        a = 1
    }()
    v := a // race with write
    wg.Wait()
    return v
}

func synced() int {
    var mu sync.Mutex
    var a int
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        defer wg.Done()
        mu.Lock()
        a = 1
        mu.Unlock()
    }()
    wg.Wait()
    mu.Lock()
    v := a
    mu.Unlock()
    return v
}

func main() {
    fmt.Println("synced", synced())
    // Uncomment to see race detector:
    // fmt.Println("racy", racy())
    _ = racy
}

What to notice: -race instruments memory accesses; fixed code establishes unlock→lock or wait→read edges.

Try next: Build a single-producer single-consumer ring with atomics only; then rewrite with a buffered channel and compare clarity.