Day 28 — Memory model lite
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.
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.Sleepbecomes 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
initfunctions
- All
initcomplete beforemain
- A package’s imported dependencies initialize first
You can rely on:
var global = compute() // finished before mainYou 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 timingThey 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 42Send/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 snapshotReaders 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 1Worked 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 WaitLab 1 — Annotate old code
Workspace: ~/lab/90daysofx/01-go/day28
- Reopen Day 19–27 labs.
- Add
// Sync:comments on every shared variable.
- 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:
- The racy snippet
- Race detector excerpt
- The HB edge you introduced
- 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.