all_lessons/Go/11 · When to share memorylesson 12 / 18

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.

The thesis, here
Go gives you two concurrency primitives that overlap, and a less opinionated language would just hand you both and wish you luck. Go's "complexity must be earned" shows up as a strong default — communicate — plus a small, blunt set of locking primitives for the cases the default fits badly, plus a tool that makes the cost of getting it wrong visible: the race detector. The transferable habit is question 2 of the five — who owns this data, and how does it move? A race is what happens when nobody answered it. Reaching for a mutex is answering "this state is shared and stays put, so I will protect it"; reaching for a channel is answering "this state moves, so I will hand it off." The skill is not knowing the lock API; it is knowing which answer the problem actually has.
Linear position
Prerequisite: Lesson 09 gave you channels and CSP — the idea that a value sent on a channel transfers ownership, so only one goroutine touches it at a time. Lesson 10 gave you 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.
The plan
Four moves. (1) Define a data race from the bottom up and show why it is undefined behavior, not just a wrong answer. (2) The blunt instrument: 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.

Why "it worked on my machine" proves nothing
A race may be invisible for a million runs and then corrupt memory in production under a different CPU, compiler version, or load. The interleaving that triggers it is a function of scheduling you do not control. This is exactly why Go ships a detector (§4) rather than relying on testing: you cannot test a race away, you can only make the data dependency disappear.

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.

Deadlock
Two goroutines each hold one lock and wait for the other. Avoid by always acquiring multiple locks in the same global order, and by keeping critical sections so small they hold one lock at a time.
Holding a lock during I/O
A lock held across a slow call (HTTP, disk, a channel send) serializes everyone behind the slowest operation. Copy out what you need, unlock, then do the slow work.
Forgetting defer
An early return 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.
A lock that protects nothing clear
If you can't name exactly which fields a mutex guards, you will eventually touch one of them without it. Pair each lock with its data, in the same struct, by convention and comment.

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.

Reach for an atomic when
  • 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.
Reach for a mutex when
  • 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.

goroutine A goroutine B ----------- ----------- x = 42 \ mu.Unlock() ----\---- happens-before ----\ \--> mu.Lock() read x // sees 42, guaranteed no Unlock/Lock edge => read x may see 0, 42, or garbage (a race)

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 problemReach forWhy
A value passes from one goroutine to anotherchanneltransfers ownership; no shared access exists to race on (L09)
Stages of a pipeline, fan-in / fan-outchannels + selectcomposition and backpressure are what channels are for (L10)
Wait for N workers to finishWaitGrouppurpose-built; Add before go
Initialize a shared resource onceOnceexactly-once with a happens-before guarantee
A single shared counter / flag / swappable pointeratomicone word, lock-free, never blocks
An invariant across several fields of a shared structMutexonly a lock can make a multi-field update atomic
Read-mostly shared map/config, writes rareRWMutex (measure first)concurrent readers — but a plain Mutex often wins for short sections

Checkpoint exercise

Try it
Take the broken counter from §1 and fix it three ways: (1) wrap 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.

Takeaway
"Share memory by communicating" is Go's default, not its only move: when state must stay in one place and be touched by several goroutines, you protect it with a lock instead of moving it through a channel. A data race — concurrent access with at least one write and no synchronization — is undefined behavior, not just a wrong number, because the compiler and CPU reorder memory and can tear multi-word values; you cannot test it away, so Go ships the -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