Mutexes & Sync Primitives
"Do not communicate by sharing memory; share memory by communicating" is the famous Go advice, but it does not make the standard library's mutexes pointless. A channel is an excellent tool for transferring ownership of data, but guarding a counter or a cache in a hot loop is cheaper with a plain sync.Mutex. A serious Go developer commands both styles and knows when each fits.
The hard part of the sync package is not the API — Lock/Unlock, Add/Done/Wait, Do look trivial. The hard part is that these primitives are unforgiving. A struct holding a mutex, copied after first use, silently copies lock state. A WaitGroup counter driven below zero crashes the program with a panic. A sync.Mutex is not reentrant — re-Lock on the same goroutine is a deadlock. And a concurrent write to a built-in map is not a race you can "sometimes catch" but a fatal error that aborts the process and is not caught by recover. This topic dissects synchronization layer by layer — from the internals of a mutex to goroutine-safe map access. Atomic operations and the memory model live in a separate topic, Atomics & the Memory Model.
Topic Map
- The sync package — which primitive for which job:
atomicfor a counter,Mutexfor an invariant,RWMutexfor read-heavy, plusOnce,WaitGroup,Map,Pool,Cond. - sync.Mutex — mutual exclusion, the zero value as a ready mutex, non-reentrancy, and the starvation mode that ensures fairness.
- sync.RWMutex — many readers OR one writer, the win on read-heavy workloads, and writer-starvation protection.
- sync.WaitGroup — waiting for a set of goroutines, the
Add/Done/Waitcontract, and whyAddmust precede the goroutine launch. - sync.Once — the one-time-execution guarantee via an atomic fast path and a mutex-guarded slow path.
- Concurrent map access — a built-in
mapis not goroutine-safe: a concurrent write aborts the process with afatal error; guard with a mutex orsync.Map. - sync.Map — two internal maps (
readlock-free anddirtyunder a mutex); wins only on read-mostly workloads.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
Copying a struct holding a sync.Mutex after first use | Lock state is copied; go vet catches it, but the output is not always read |
Treating sync.Mutex as reentrant | Re-Lock of your own mutex on the same goroutine — deadlock |
Reaching for RWMutex by default | Under low contention it is slower than a plain sync.Mutex |
Calling Add inside an already started goroutine | Race with Wait; the counter may not yet count the goroutine |
Passing a WaitGroup by value to a goroutine | Done updates a copy; Wait blocks forever |
Driving the WaitGroup counter below zero with an extra Done | panic — the program crashes |
Writing to a built-in map from multiple goroutines | fatal error: concurrent map writes — the process dies, recover does not catch it |
Reaching for sync.Map on ordinary mixed traffic | It wins only on read-mostly; otherwise a map under an RWMutex is faster |
Interview Relevance
Synchronization is a mandatory topic in any serious Go interview. A candidate usually knows channels; the sync package separates someone who can reason about the correctness of concurrent code from someone who memorised go func(){}().
What interviewers check:
- What
sync.Mutexprovides, why it must not be copied, and why it is not reentrant. - When
sync.RWMutexbeatssync.Mutexand what it costs. - The
Add/Done/Waitcontract and whyAddmust run before the goroutine launch. - How
sync.Onceguarantees a single run and why it needs both an atomic and a mutex. - Why a concurrent write to a built-in
mapaborts the process and how to guard it. - When
sync.Mapis justified and why amapunder anRWMutexis faster on mixed traffic.
A typical wrong answer: "you can safely read and write a built-in map from different goroutines as long as the keys differ." That triggers a discussion that a concurrent map write is detected by the runtime and aborts the process with a fatal error regardless of keys, and the guard is a mutex or sync.Map.