Lesson 20. The Memory Model and Races
Mission link: Reasoning about concurrency from the memory model instead of from experiment is what lets you say a design is wrong before it has failed in production.
Primary source: The Go Memory Model, The Go Authors
Prerequisites: Lesson 19
Warm-up
- ▢ Why must a lock never be held across a network call?
Check
Because a slow dependency becomes a total stall: every goroutine wanting the lock queues behind one remote call. Copy what you need, unlock, then make the call.
- ▢ What does
errgroup.WithContextadd over aWaitGroup?
Check
The first error cancels the shared context so the other goroutines stop, and Wait returns that error. A WaitGroup only counts.
Know this
A data race is: two goroutines access the same memory location, at least one access is a write, and there is no synchronisation ordering them.
All three parts matter. Concurrent reads are fine. Writes to different variables are fine. A write and a read with a happens-before edge between them are fine, and providing that edge is what every primitive in Lesson 19 is for.
A race is undefined behaviour
The instinct to resist is that a racy counter "loses some increments" or that a racy read "gets the old value or the new one". Neither is guaranteed. The memory model says a program with a data race has no defined behaviour, and in practice the compiler and CPU are allowed to:
- reorder reads and writes, because nothing said they were ordered;
- keep a variable in a register across a loop, so an update from another goroutine is never observed and the loop never ends;
- tear a multi-word value, a slice header or an interface, so a reader sees a new pointer with an old length.
That last one is why a raced interface or slice can produce a segmentation fault rather than a wrong answer. "It has worked so far" is a statement about one compiler version on one architecture under one load.
Happens-before, briefly
The memory model defines when one goroutine is guaranteed to observe another's writes. The edges you actually use:
| This… | …happens before this |
|---|---|
| A send on a channel | the corresponding receive completes |
| A receive from an unbuffered channel | the corresponding send completes |
| Closing a channel | a receive that returns because it is closed |
mu.Unlock() | a subsequent mu.Lock() returning |
once.Do(f) returning in one goroutine | every other Do on that Once returning |
wg.Done() calls reaching zero | wg.Wait() returning |
| An atomic write | a subsequent atomic read of that value |
Everything the writing goroutine did before the edge is visible to the reader after it. That is the guarantee, and it is why sending a pointer over a channel safely transfers everything it points to.
sync/atomic operations are sequentially consistent, documented as such since the 2022 revision of the memory model. So an atomic.Bool flag genuinely does establish ordering, unlike a plain bool, where the compiler may never reread it at all.
The race detector
go test -race ./...
It is a dynamic analysis built on ThreadSanitizer. Two properties define how to use it:
- No false positives. When it reports a race, there is a race. Do not argue with it.
- Only what executes. It sees the accesses that actually happened in that run. Code paths not taken, and interleavings that did not occur, are invisible. A clean run is evidence, not proof.
Cost is real, roughly 2 to 20 times slower and 5 to 10 times more memory, so it belongs in CI and in load tests against a staging build rather than in production. Because coverage is the limiting factor, a race detector run over a test suite that exercises concurrency is worth far more than one over unit tests that run everything on one goroutine.
Making concurrent tests deterministic
testing/synctest, generally available since Go 1.25, runs a test inside a bubble where the time package is virtualised: the clock jumps forward instantly when every goroutine in the bubble is blocked, and synctest.Wait blocks until they all are.
func TestTimeout(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// a one-hour timeout resolves immediately, deterministically
})
}
This removes the two worst habits in concurrent tests: time.Sleep to "let it settle", and generous timeouts that turn a real failure into a flake.
Practice
- ▢ Two goroutines each run
counter++a thousand times with no synchronisation. What is the final value?
Check
Undefined. Not "somewhere between 1000 and 2000", which is a mental model of an interleaved read-modify-write and it assumes an ordering the language does not provide.
In practice you will often see a number in that range, which is exactly what makes the bug survive review. The correct statement is that the program has no defined behaviour, and the correct fix is an atomic.Int64 or a mutex.
- ▢ A goroutine spins on
for !done {}wheredoneis a plainboolset by another goroutine. Why might it never exit even afterdoneis set?
Check
Nothing orders the write against the read, so the compiler is free to load done once into a register and reuse it, leaving the loop testing a value that can never change.
Fix with an atomic.Bool, or better, with a channel closed to signal completion. This is the concrete reason "it is only a bool, a race cannot hurt" is wrong.
-
▢
go test -race ./...passes. What have you established?- a) The code under test contains no data races
- b) No race occurred in the paths that ran
- c) The detector found races but tolerated them
- d) The code is safe for concurrent use everywhere
Check
b) No race occurred in the paths that ran.
The detector has no false positives, so a report is always real, but it observes only executed accesses in one interleaving. Claims a and d overstate a dynamic tool, and c misdescribes it: it never tolerates a race it sees.
The practical consequence is that improving concurrent test coverage improves the detector's reach more than rerunning it does.
- ▢ You send a
*Configover a channel to another goroutine, which reads its fields. Is that a race?
Check
No, provided the sender does not touch it afterwards. The send happens before the receive completes, so everything the sender wrote to that struct before sending is visible to the receiver.
It becomes a race the moment the sender keeps the pointer and writes to it again. Sending a pointer transfers ownership by convention, and the convention is the only thing enforcing it: the compiler will not stop you writing to it.
- ▢ Interleaving Lesson 4: a service crashes with
fatal error: concurrent map writesin production, and-racefinds nothing in CI. What do you conclude?
Check
That the tests never exercise the concurrent path, not that the race is absent. The runtime's map detector fired on real traffic; the race detector had no such interleaving to observe.
Next step is a test that hits the map from several goroutines under -race, which will find it in seconds. The production crash already told you where to look, since the fatal error names the goroutines' stacks.
Real-world reps
- [ ] Write the two-goroutine counter with no synchronisation, run it under
-race, and read the report. Note that it names both stacks and the variable. - [ ] Write the
for !done {}spin loop and run it with optimisations on. If it exits, try it with more work in the loop, then fix it with a channel and stop relying on luck. - [ ] Tomorrow: check whether
-raceruns in your CI. If it does not, that is a one-line change with a larger payoff than most refactors.
Going further
- The Go Memory Model
- Introducing the Go Race Detector, The Go Blog
- Testing concurrent code with testing/synctest, The Go Blog
- Concurrency Patterns
- Resources
Not landing? Reread the primary source at the top, since this lesson compresses it and compression is where understanding leaks. Check the glossary for any term that felt slippery.
If the lesson itself is unclear rather than the material, that is a defect: open an issue.