Mutexes & Sync Primitives
Mutex and RWMutex, WaitGroup, Once, and goroutine-safe map access.
13 questions
JuniorTheoryVery commonWhat does sync.Mutex provide and how is it used correctly?
What does sync.Mutex provide and how is it used correctly?
sync.Mutex gives mutual exclusion: only one goroutine holds the lock at a time, protecting a critical section. Call Lock before the section and Unlock after — usually via defer. The zero value is an unlocked, ready mutex.
Common mistakes
- ✗Forgetting to Unlock on an early return path — defer Unlock right after Lock
- ✗Copying a struct that embeds a sync.Mutex after first use, which copies lock state
- ✗Assuming a Mutex is reentrant — a goroutine that re-Locks its own mutex deadlocks
Follow-up questions
- →Why must a sync.Mutex never be copied after its first use?
- →What happens if a goroutine that did not Lock the mutex calls Unlock?
JuniorTheoryVery commonWhat is sync.WaitGroup for and what is the Add/Done/Wait contract?
What is sync.WaitGroup for and what is the Add/Done/Wait contract?
sync.WaitGroup waits for a set of goroutines to finish. Add(n) raises an internal counter, each goroutine calls Done to decrement it, and Wait blocks until the counter reaches zero. Add must run before the goroutine it counts is started.
Common mistakes
- ✗Calling Add inside the goroutine instead of before it, racing with Wait
- ✗Forgetting defer wg.Done(), so Wait blocks forever if the goroutine returns early
- ✗Passing a WaitGroup by value to a goroutine, so Done updates a copy
Follow-up questions
- →What happens if the WaitGroup counter is driven below zero?
- →Can a single WaitGroup be reused for a second batch of goroutines?
MiddleTheoryVery commonWhen is sync.RWMutex better than sync.Mutex, and what is the cost?
When is sync.RWMutex better than sync.Mutex, and what is the cost?
sync.RWMutex allows many concurrent RLock readers OR one Lock writer. It wins for read-heavy workloads where readers vastly outnumber writers. The cost is a heavier lock — slower than sync.Mutex under low contention, and a pending writer blocks new readers.
Common mistakes
- ✗Reaching for RWMutex by default — under low contention it is slower than a plain Mutex
- ✗Believing writers can run in parallel — Lock is fully exclusive, like a Mutex
- ✗Thinking readers can starve a writer — a pending writer blocks newly arriving readers
Follow-up questions
- →Why does a pending writer block newly arriving readers rather than waiting them out?
- →How would you measure whether RWMutex actually beats Mutex for your workload?
JuniorTheoryCommonWhat does the standard sync package provide?
What does the standard sync package provide?
sync provides low-level concurrency primitives: Mutex and RWMutex for mutual exclusion, WaitGroup to wait for a group of goroutines, Once for one-time init, Cond for condition waits, Map for a concurrent map, and Pool to reuse temporaries. The sync/atomic subpackage adds lock-free atomic operations.
Common mistakes
- ✗Thinking
syncis where you create goroutines and channels — those are language built-ins - ✗Believing
sync.Mapis always faster than a plain map guarded by aMutex - ✗Confusing
sync.Pool(temporary-object reuse) with a database connection pool
Follow-up questions
- →When is
sync.Mapactually faster than aMutex-guarded map? - →What is
sync.Poolfor, and when are its objects reclaimed?
MiddleTheoryCommonWhat is the difference between a data race and a deadlock, and how do you prevent each?
What is the difference between a data race and a deadlock, and how do you prevent each?
A data race is two goroutines accessing the same memory concurrently with at least one write and no synchronization, giving an undefined result; catch it with the -race detector and fix it with a sync.Mutex, channel, or atomic. A deadlock is goroutines each blocked waiting on a resource another holds, so none progress; prevent it with consistent lock ordering and context timeouts.
Common mistakes
- ✗Treating a data race as a deadlock — they are unrelated failures with different fixes
- ✗Assuming the
-racedetector reports deadlocks; it only catches unsynchronized memory access - ✗Inconsistent lock ordering across goroutines, the classic recipe for a deadlock
Follow-up questions
- →Why does the
-racedetector find data races but never report a deadlock? - →How does consistent lock ordering prevent a deadlock between two mutexes?
MiddleTheoryCommonHow does sync.Once guarantee one-time execution?
How does sync.Once guarantee one-time execution?
sync.Once.Do(f) runs f exactly once across all callers. It uses an atomic done flag for a cheap fast path, and a mutex for the slow path: the first caller runs f under the lock, concurrent callers block until it finishes, and later calls see the flag and return immediately.
Common mistakes
- ✗Thinking Do returns before f completes — concurrent callers block until f finishes
- ✗Assuming Once is per-goroutine rather than shared across all callers of one value
- ✗Believing the atomic flag alone suffices — the slow path needs a mutex to be correct
Follow-up questions
- →Why does sync.Once need both an atomic flag and a mutex rather than just one?
- →What happens to other Do callers if the function passed to Do panics?
MiddleTheoryCommonHow does sync.Map work, and when is it more appropriate than a mutex-guarded map?
How does sync.Map work, and when is it more appropriate than a mutex-guarded map?
sync.Map keeps two internal maps — a mostly-read read map served without a lock via atomics, and a dirty map under a Mutex for writes. Reads of keys already present avoid locking entirely. It pays off only for read-mostly or write-once-read-many workloads; for general mixed read/write traffic a plain map under an RWMutex is faster.
Common mistakes
- ✗Using sync.Map as a default for write-heavy maps where an RWMutex map is faster
- ✗Thinking sync.Map is merely an RWMutex-wrapped plain map
- ✗Believing writes to sync.Map are lock-free
Follow-up questions
- →Why can promotion of the
dirtymap make a burst of writes temporarily slower? - →How does the
readmap get refreshed from thedirtymap over time?
MiddleDebuggingOccasionalWhy does writing to a built-in map from many goroutines crash, and how do you fix it?
Why does writing to a built-in map from many goroutines crash, and how do you fix it?
Built-in maps are not safe for concurrent use. Concurrent writes trip the runtime's guard and abort with fatal error: concurrent map writes — even a concurrent read alongside a write can crash. Fix: guard every access with a sync.Mutex (or RWMutex), or use sync.Map for concurrent workloads.
Common mistakes
- ✗Assuming a built-in map is safe for concurrent writes if the keys differ
- ✗Thinking it is a recoverable data race rather than a fatal runtime error
- ✗Believing pre-sizing the map removes the need for synchronization
Follow-up questions
- →When is
sync.Mappreferable to aMutex-guarded plain map? - →Why can even a concurrent read with a write crash, not just two writes?
MiddleDebuggingOccasionalWhy is calling wg.Add(1) inside each goroutine a bug, and where should it go?
Why is calling wg.Add(1) inside each goroutine a bug, and where should it go?
wg.Add(1) runs inside the goroutine, but the scheduler may not start any goroutine before main reaches wg.Wait(). If Wait sees a zero counter it returns at once and the program can exit before the goroutines run. Fix: call wg.Add(1) in the loop before launching each goroutine.
Common mistakes
- ✗Calling
wg.Add(1)inside the goroutine instead of before launching it - ✗Assuming the scheduler runs goroutines before
mainreacheswg.Wait() - ✗Thinking the bug is the deferred
Donerather than the lateAdd
Follow-up questions
- →Why does adding to the WaitGroup before
goestablish the needed happens-before withWait? - →What does the
Add/Waitdocumentation say about callingAddconcurrently?
SeniorDebuggingOccasionalCode review: a high-load in-memory cache using sync.Mutex — find the concurrency bugs
Code review: a high-load in-memory cache using sync.Mutex — find the concurrency bugs
The sync.Mutex is a local variable, so every call locks its own copy and the shared cache is never protected — concurrent calls race and crash with fatal error: concurrent map writes. Also value = cache[key] overwrites the argument, so the create path stores "", and unlocking between read and write makes get-or-create non-atomic. Fix: one shared lock — an RWMutex since reads dominate — held across the whole check-then-set, and stop clobbering value.
Common mistakes
- ✗Not noticing the mutex is a local variable, so it synchronizes nothing across goroutines
- ✗Missing that
value = cache[key]overwrites the argument, so the create path stores an empty string - ✗Treating the separate lock for read and for write as if the get-or-create were atomic
Follow-up questions
- →Why does taking an
RLockfor the read then aLockfor the write still let two callers both create a value? - →How would
sync.Mapor asingleflightgroup change this design?
SeniorDebuggingOccasionalWhy can this code deadlock when A holds an RLock and calls B, which takes the write Lock?
Why can this code deadlock when A holds an RLock and calls B, which takes the write Lock?
Go's RWMutex is not reentrant. A holds a read lock and calls B, which requests the write Lock. If another goroutine is already waiting for Lock, the runtime blocks new readers to avoid writer starvation — so B waits on a read lock that A still holds. The docs forbid recursive read locking; fix by locking once at the top level.
Common mistakes
- ✗Assuming
sync.RWMutexis reentrant — acquiring it recursively can deadlock - ✗Thinking the deadlock is unconditional rather than triggered by a concurrent waiting writer
- ✗Taking a lock inside a nested call instead of once at the top of the call chain
Follow-up questions
- →Why does the RWMutex block new readers once a writer is waiting?
- →How would you restructure
AandBso only one of them takes the lock?
SeniorDebuggingOccasionalWhy won't this conversion-rate server compile, and is its RWMutex discipline correct?
Why won't this conversion-rate server compile, and is its RWMutex discipline correct?
mu &sync.RWMutex{} is missing := — write mu := &sync.RWMutex{}. The lock discipline is otherwise sound: the updater reassigns rates under Lock, handlers read it under RLock, so every access is guarded. Keep the != http.ErrServerClosed check on ListenAndServe.
Common mistakes
- ✗Assuming the missing
:=is a typo for=—muis undeclared, so only:=compiles - ✗Believing reassigning the map under Lock races with RLock readers — the lock serializes both
- ✗Treating a clean
ListenAndServeshutdown as an error — it returnshttp.ErrServerClosed
Follow-up questions
- →Why is reassigning the
ratesmap underLocksafe, but mutating it underRLockwould not be? - →What goes wrong if the background updater used
RLockinstead ofLockto swaprates?
SeniorCodeOccasionalConcurrency-safe in-memory TTL cache for User
Concurrency-safe in-memory TTL cache for User
Store entries in a map keyed by User.ID, each with an expiry timestamp, guarded by a sync.RWMutex. Set writes the value and now+ttl under Lock; Get takes RLock and treats an entry whose expiry is past as a miss. A background cleaner goroutine sweeps expired keys so untouched entries don't linger; a Close method stops it from leaking.
Common mistakes
- ✗Returning a stale entry on
Getbecause the expiry timestamp is never checked on read - ✗Reading or writing the map without the lock, assuming Go maps are safe under concurrency
- ✗Never evicting expired keys, so the map grows unbounded even though
Getreports misses
Follow-up questions
- →How would you run the cleaner periodically without leaking its goroutine when the cache is gone?
- →Why prefer
RWMutexoverMutexhere, and when would that choice stop paying off?