When to share memory: mutexes, atomics, and the race detector
Lesson 09's slogan — "share memory by communicating" — is a default, not a law. There is a second tool: when two goroutines must touch the same piece of state in place, you protect it with a lock instead of moving it through a channel. This lesson is about earning that exception honestly: what a data race actually is at the hardware level, the four tools in sync and sync/atomic, the one-paragraph memory model that says when a write becomes visible, and the decision rule — channels orchestrate work; mutexes protect state — that tells you which to reach for.
select, pipelines, and context for orchestrating and cancelling that work. Lesson 02's value semantics and Lesson 03's heap/pointer model are the substrate underneath everything here.New capability: recognize a data race precisely, choose between a channel and a mutex for a given piece of state, use
Mutex/RWMutex/WaitGroup/Once and atomic correctly, and run the -race detector to prove you got it right.sync.Mutex and RWMutex, with the copy-and-defer rules. (3) The lighter tools: WaitGroup, Once, and lock-free atomic. (4) The memory model in one paragraph, the -race detector, and the decision rule that chooses lock vs channel.1 · What a data race actually is
Start with the bug, because the tools only make sense as answers to it. A data race is two goroutines accessing the same memory location concurrently, where at least one access is a write, and there is no synchronization ordering the two. The classic demonstration is a shared counter:
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() { defer wg.Done(); count++ }()
}
wg.Wait()
fmt.Println(count) // almost never 1000
The reason it is wrong is that count++ is not one operation. The compiler turns it into three: load count into a register, add one, store it back. Two goroutines can both load 7, both compute 8, and both store 8 — one increment is lost. That much is intuitive. The part people underestimate is that in Go (as in C, C++, Java, Rust) a data race is not merely a wrong number — it is undefined behavior. The Go memory model explicitly says a program with a data race may observe any ordering, and because the compiler and the CPU both reorder memory operations for speed, a race can corrupt a multi-word value (like a slice header or an interface, which are 2–3 machine words — see Lessons 02 and 05) so that you read a pointer from one assignment and a length from another. That is how a race becomes a crash, not just a bad count.
2 · The blunt instrument: sync.Mutex
When state must stay in one place and be touched by several goroutines, you make the touching mutually exclusive: at most one goroutine holds the lock at a time, and everyone else waits. A sync.Mutex has exactly two methods, Lock and Unlock, and its zero value is an unlocked, ready-to-use mutex — so you never initialize it (Lesson 02's "the zero value is usable" again).
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // runs on every return path, including panics
c.n++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}
Three rules carry almost all the correctness. (a) Pair the lock with the data and keep the section small — embed the Mutex in the struct it protects, hold it only while touching that state, and never make a network call or a channel send under a lock (that turns a fast lock into a slow one and invites deadlock). (b) Always defer c.mu.Unlock() so an early return or a panic cannot leave the lock held forever. (c) Never copy a Mutex after first use — copying it duplicates its internal state and the two copies no longer exclude each other. This is the concrete reason the methods above take a pointer receiver: a value receiver would copy the whole Counter, mutex and all, on every call (go vet flags this automatically).
RWMutex is the same idea split in two: many readers or one writer. Use RLock/RUnlock around reads and Lock/Unlock around writes. It only pays off when reads vastly outnumber writes and the critical section is long enough that the extra bookkeeping (an RWMutex is larger and its read path does more atomic work than a plain Mutex) is worth it — for a short read of a single field, a plain Mutex is usually faster. Measure before reaching for it; the default is Mutex.
deferreturn or panic on a manually-unlocked path leaks the lock; the next Lock blocks forever. defer Unlock() immediately after Lock() is the idiom for a reason.3 · The lighter tools: WaitGroup, Once, and atomics
Not every coordination problem is "protect a mutable field." Three smaller tools cover the most common others, and the last one lets you skip the lock entirely for single values.
sync.WaitGroup answers "wait until N goroutines finish" — you saw it in §1. Add(n) before you launch, Done() (usually deferred) inside each goroutine, Wait() blocks until the counter hits zero. The one trap: call Add before the go statement, never inside the new goroutine, or Wait can return before the goroutine even started counting.
sync.Once answers "run this exactly once, no matter how many goroutines race to do it" — lazy initialization of a shared resource. once.Do(f) guarantees f runs once and that every caller sees its effects before Do returns:
var (
once sync.Once
client *http.Client
)
func Client() *http.Client {
once.Do(func() { client = &http.Client{Timeout: 5 * time.Second} })
return client
}
sync/atomic is the lock-free path. The CPU offers single instructions that read-modify-write one machine word atomically — load, store, add, and compare-and-swap (CAS). For a single counter or flag, the typed wrappers (atomic.Int64, atomic.Bool, atomic.Pointer[T], added in Go 1.19) replace a whole mutex with no lock at all:
var hits atomic.Int64
func record() { hits.Add(1) } // one atomic instruction, no blocking
func total() int64 { return hits.Load() }
The contrast in cost is real: an uncontended atomic add is a few nanoseconds and never blocks the goroutine, whereas a mutex's Lock/Unlock pair, while also cheap when uncontended, can park the goroutine and hand the OS thread to the scheduler when contended. But atomics only protect one word at a time. The moment your invariant spans two fields — "len and the backing array must agree," "balance and the transaction log must update together" — atomics cannot express it and you need a mutex (or a channel). The rule: atomics for a single counter/flag/pointer; a mutex for an invariant across several fields.
- The shared state is one machine word: a counter, a flag, a pointer you swap.
- You want to never block — a metrics tick on a hot path.
- The operation is a single load, store, add, or compare-and-swap.
- An invariant spans multiple fields that must change together.
- The critical section is more than one read-modify-write.
- Clarity matters more than the last few nanoseconds — the default.
4 · The memory model, the detector, and the decision rule
The memory model in one paragraph. The question a memory model answers is: when goroutine A writes x and goroutine B reads it, is B guaranteed to see the write? Go's answer is built on the happens-before relation. Within a single goroutine, statements happen in program order. Across goroutines, you get a happens-before guarantee only through an explicit synchronization event: a send on a channel happens-before the corresponding receive completes (Lesson 09); an Unlock happens-before the next Lock of the same mutex; the body of a once.Do happens-before any Do returns; a WaitGroup's Done calls happen-before the Wait they release. If two accesses are not ordered by some such edge and at least one is a write, that is a data race and the result is undefined. So every synchronization primitive in this lesson does two jobs at once: it provides mutual exclusion and it publishes the writes made under it to the next goroutine that synchronizes. That second job is why you cannot replace a mutex with "just be careful" — the careful version has no happens-before edge, so the reader may see a stale or torn value even if the timing happens to be right.
The race detector. Because you cannot test a race away, Go instruments the binary to catch one in the act. Build or test with -race:
go test -race ./...
go run -race ./cmd/server
The detector watches every memory access at runtime and records the happens-before edges; when it sees two accesses to the same address with no edge between them and at least one write, it prints both stacks and the goroutines involved. It has no false positives — every report is a real race — but it only finds races on code paths that actually execute, so it is as good as your test coverage. It costs roughly 5–10× CPU and 5–10× memory, so you run it in CI and on staging, not in production. It is the single most valuable concurrency tool Go ships; treat a clean -race run as part of "done."
The decision rule. Now choose. The slogan is channels orchestrate work; mutexes protect state. If the natural description of your problem is "this value moves from a producer to a consumer," or "these stages form a pipeline," or "wait for the first of several results / a cancellation" — that is communication, and a channel (Lessons 09–10) keeps ownership unambiguous with no lock at all. If the natural description is "many goroutines read and update the same in-place structure — a cache, a connection pool, a counter, a config that reloads" — that is shared state, and a mutex (or an atomic, if it is one word) is simpler and faster than threading every read through a channel. When both fit, prefer the channel: it answers question 2 — who owns this data? — by construction, whereas a lock leaves ownership to discipline you must maintain forever.
| The shape of the problem | Reach for | Why |
|---|---|---|
| A value passes from one goroutine to another | channel | transfers ownership; no shared access exists to race on (L09) |
| Stages of a pipeline, fan-in / fan-out | channels + select | composition and backpressure are what channels are for (L10) |
| Wait for N workers to finish | WaitGroup | purpose-built; Add before go |
| Initialize a shared resource once | Once | exactly-once with a happens-before guarantee |
| A single shared counter / flag / swappable pointer | atomic | one word, lock-free, never blocks |
| An invariant across several fields of a shared struct | Mutex | only a lock can make a multi-field update atomic |
| Read-mostly shared map/config, writes rare | RWMutex (measure first) | concurrent readers — but a plain Mutex often wins for short sections |
Checkpoint exercise
count in a struct with a sync.Mutex and a pointer-receiver Inc; (2) replace it with a single atomic.Int64; (3) launch one "owner" goroutine that holds count as a local variable and receives increment requests on a channel, so nothing is shared at all. Run each under go run -race and confirm the original reports a race and all three are clean. Then answer question 2 of the five for each version out loud: who owns count, and how does it move? — and notice that version 3 is the only one where the answer is "exactly one goroutine, and it never moves," which is why it needs no lock. That is the difference between protecting shared state and not sharing it.Where this points next
You now have the complete concurrency toolkit — goroutines (08), channels (09), select/context (10), and locks/atomics (11) — and the judgment to choose among them. But every program above lived in one file, in one package, with everything visible to everything. That stops working the moment a second engineer joins: you need boundaries that say what is public and what is private, an import graph that can't tangle into cycles, and a way to depend on other people's code without inheriting their churn. Lesson 12 takes the concurrency primitives you just learned and asks the programming-in-the-large question about them — how do you package this so a team can use it without reading every line? — turning the sync package itself into an example of a clean, minimal exported surface.
-race detector (no false positives, ~5–10× cost, run it in CI). The tools: Mutex/RWMutex for an invariant across fields (pair the lock with its data, always defer Unlock, never copy it), WaitGroup to wait for N goroutines (Add before go), Once for exactly-once init, and lock-free atomic for a single word. Underneath them all is one idea — the happens-before relation — which is why a synchronization primitive both excludes and publishes writes. The decision rule is the transferable habit, question 2 made operational: channels orchestrate work; mutexes protect state — and when both fit, prefer the channel, because it answers "who owns this data?" by construction instead of by discipline.Interview prompts
- Define a data race precisely, and explain why it is worse than just a wrong answer. (§1 — concurrent access to one location, at least one a write, with no synchronization ordering them; it is undefined behavior, because compiler/CPU reordering can tear a multi-word value like a slice header or interface, turning a race into corruption or a crash, not merely a bad count.)
- Why do the
Countermethods in §2 take a pointer receiver? (§2 — a value receiver copies the whole struct including theMutexon every call; a copied mutex no longer excludes the original, so the lock protects nothing.go vetflags copying a mutex after use.) - When does
RWMutexbeat a plainMutex, and when does it lose? (§2 — it wins when reads vastly outnumber writes and critical sections are long enough to amortize its heavier read path; for short reads of a single field a plainMutexis usually faster, so measure and default toMutex.) - When is an atomic enough and when do you need a mutex? (§3 — an atomic protects exactly one machine word with a single lock-free instruction; the moment an invariant spans two or more fields that must change together, only a mutex (or channel) can make the update atomic.)
- State the Go memory model in terms of happens-before, and name two edges. (§4 — a read sees a write only if some synchronization event orders write-before-read; edges include a channel send before its receive, an
Unlockbefore the nextLock, aonce.Dobody beforeDoreturns, and aWaitGroupDonebefore theWaitit releases.) - Why can't you test a data race away, and what do you do instead? (§1, §4 — the triggering interleaving depends on scheduling you don't control, so a race can hide for a million runs; instead build with
-race, which records happens-before edges at runtime, has no false positives, costs ~5–10×, and runs in CI/staging.) - Give the rule for choosing a channel vs a mutex, and the tiebreaker. (§4 — channels orchestrate work (a value that moves, a pipeline, waiting on the first result); mutexes protect state (an in-place structure many goroutines read and update). When both fit, prefer the channel because it answers "who owns this?" by construction rather than by discipline.)