Day 28 — Memory model lite

Updated

July 30, 2026

Day 28 — Memory model lite

Stage III · ~3h (theory-heavy)
Goal: Build a working happens-before intuition for Go’s memory model—enough to reason about races, atomics, channel sync, and mutexes without becoming a formal methods researcher.

Note

You do not need to recite the full Go memory model document. You need to know when a read is guaranteed to observe a write.

Why this day exists

Without a model:

  • “It worked on my laptop” becomes architecture
  • time.Sleep becomes synchronization
  • Atomics get sprinkled randomly
  • Channel closes feel magical

With a model:

  • You can document why a program is race-free
  • You can design simpler ownership
  • Race detector results make sense

Theory 1 — Sequential consistency within a goroutine

Inside one goroutine, reads and writes behave as if executed in program order (compiler/CPU reorderings are constrained so you cannot observe them in a single G without concurrency).

The hard part is cross-goroutine visibility.


Theory 2 — Happens-before (HB)

If event A happens before event B, then B is guaranteed to observe the effects of A (for the relevant memory).

Go establishes HB through synchronization events, including:

Synchronization HB edge (simplified)
Mutex Unlock → Lock Unlock HB subsequent Lock
Channel send → receive Send completes HB receive completes
Channel close → receive zero Close HB receive that sees closed
sync.Once Do Do’s f() HB return of any Do
sync.WaitGroup Done → Wait Done HB Wait return
Atomic ops Provide ordering depending on type (see docs)
Goroutine start go f initiation HB start of f

If two accesses to the same location race (no HB either way) and one is a write → data race.


Theory 3 — Initialization guarantees

  • Package-level variable initialization completes before init functions
  • All init complete before main
  • A package’s imported dependencies initialize first

You can rely on:

var global = compute() // finished before main

You cannot rely on unsynchronized writes from other goroutines without HB.


Theory 4 — Incorrect “synchronization”

These do not establish a portable HB for your data:

// NOT synchronization for shared data
time.Sleep(time.Millisecond)
runtime.Gosched()
fmt.Println("debug") // may seem to "fix" races via timing

They may change interleaving probability; they do not make a race correct.


Theory 5 — Channel as memory barrier (practical)

// G1
data = 42
ch <- struct{}{}

// G2
<-ch
fmt.Println(data) // guaranteed to see 42

Send/receive pair synchronizes. Similarly, closing and ranging establishes ordering for published data before close.

Mutex pattern

// writer
mu.Lock()
data = 42
mu.Unlock()

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

Unlock HB next Lock ⇒ reader sees writer’s updates.


Theory 6 — Atomics and flags

var ready atomic.Bool
var data int

// G1
data = 42
ready.Store(true) // publish

// G2
if ready.Load() {
    fmt.Println(data) // sees 42 under atomic's ordering for this pattern
}

Use atomics deliberately; for multi-field structs prefer:

type snap struct{ /* immutable fields */ }
var p atomic.Pointer[snap]
p.Store(&snap{...}) // publish immutable snapshot

Readers Load pointer and use fields without further locks if truly immutable.


Theory 7 — Documenting synchronization

In code reviews, leave a breadcrumb:

// Sync: data is written only before ch is closed; readers range ch.
// Sync: mu protects m and n together.
// Sync: ready atomic publishes immutable cfg pointer.

If you cannot write one sentence naming the HB edge, the design is probably wrong.


Worked example — broken then proven

Broken:

var a, b int

func f() {
    a = 1
    b = 1
}

func g() {
    for b == 0 {
        runtime.Gosched()
    }
    fmt.Println(a) // may print 0: race + no HB
}

Fixed with channel:

done := make(chan struct{})
go func() {
    a = 1
    b = 1
    close(done)
}()
<-done
fmt.Println(a, b) // 1 1

Worked example — WaitGroup as publication

var data int
var wg sync.WaitGroup
wg.Add(1)
go func() {
    defer wg.Done()
    data = 7
}()
wg.Wait()
fmt.Println(data) // 7 — Done HB Wait

Lab 1 — Annotate old code

Workspace: ~/lab/90daysofx/01-go/day28

  1. Reopen Day 19–27 labs.
  2. Add // Sync: comments on every shared variable.
  3. If you cannot annotate, rewrite until you can.
go test -race ./...

Lab 2 — Prove one race you fixed

Write doc/race-story.md (or journal section) with:

  1. The racy snippet
  2. Race detector excerpt
  3. The HB edge you introduced
  4. Why sleep was not an acceptable fix

Lab 3 — Publication patterns matrix

Implement three versions of “publish config to readers”:

Version Mechanism
A Mutex around config struct
B Channel close after write
C atomic.Pointer to immutable config

Table tests: many readers, occasional writers. Race-clean. Journal tradeoffs (latency, API shape).


Lab 4 — Read the official model (targeted)

Open https://go.dev/ref/mem. Skim:

  • Introduction
  • Synchronization (channels, mutexes)
  • Incorrect synchronization

Write five bullet takeaways in your own words—no paste.


Common gotchas

Gotcha Fix
Double-checked locking without atomics/fences Use Once or proper atomic publish
Reading fields after free-unlock copy mistakes Keep lock scope correct
Believing race detector is the memory model Detector implements checks; model is the law
“Single writer so no sync needed” for readers Readers still need HB with writer
Over-using atomics for complex state Mutex or channel ownership

Checkpoint

  • Defines happens-before in plain language
  • Lists ≥4 Go primitives that create HB edges
  • Documented Sync comments on a real lab
  • Wrote a race-fix story with evidence
  • Compared three publication patterns
  • All day28 tests pass with -race

Commit

git add .
git commit -m "day28: memory model lite + sync annotations + race story"

Write three personal gotchas before continuing.


Tomorrow

Day 29 — Concurrency integration: combine pipeline + context + tests + race-clean design into one mini-tool.