Go Sync Primitives
sync.Mutex & sync.RWMutex
What: Mutual exclusion locks. RWMutex allows unlimited concurrent readers or one writer.
Syntax/Signature: mu.Lock()/Unlock(); rw.RLock()/RUnlock(); both have TryLock (1.18, rarely appropriate). Zero value ready to use; must not be copied after first use.
type Counter struct {
mu sync.Mutex
m map[string]int
}
func (c *Counter) Inc(k string) {
c.mu.Lock()
defer c.mu.Unlock()
c.m[k]++
}
How it works under the hood: Fast path is a single atomic CAS on a state word. Under contention, waiters park on a runtime semaphore (futex-backed on Linux). Two modes: normal (new arrivals can barge, spinning briefly — good throughput) and starvation — if a waiter waits >1 ms, the mutex flips to FIFO handoff so it can't be starved (added Go 1.9). RWMutex layers reader counts on a mutex; a pending writer blocks new readers to avoid writer starvation — which is also why RWMutex is not reentrant-safe for readers: RLock → (other goroutine) Lock pending → same goroutine RLock again = deadlock.
Interview angle: "Mutex vs channel — when?" Canonical: mutex for protecting state, channels for transferring ownership/communication (see [[Go Channels]]). "Is Go's Mutex reentrant?" — No, by design; recursive locking deadlocks and Go considers reentrancy a design smell.
Gotchas / common wrong answers:
- Copying a struct containing a mutex (e.g., value receiver on a method that locks) — two independent locks;
go vet -copylockscatches it. - Assuming
RWMutexalways beatsMutex— its bookkeeping is pricier; with short critical sections or few cores, plainMutexoften wins. Measure. - Locking around I/O or a callback — holds the lock across unbounded time; keep critical sections tiny, copy data out, then do I/O.
Trade-offs / when NOT to use: For a read-mostly value swapped wholesale, atomic.Pointer[T] beats RWMutex. sync.Map only wins for append-only/disjoint-key caches — a plain map+Mutex is the right default answer.
sync.WaitGroup
What: A counter for waiting on a set of goroutines: Add(n) before spawn, Done() in each, Wait() blocks until zero.
Syntax/Signature: var wg sync.WaitGroup; wg.Add(1); go func(){ defer wg.Done(); ... }(); wg.Wait().
var wg sync.WaitGroup
results := make([]Result, len(jobs))
for i, j := range jobs {
wg.Add(1)
go func() {
defer wg.Done()
results[i] = process(j) // disjoint indices: no race, no lock needed
}()
}
wg.Wait()
How it works under the hood: An atomic 64-bit word packing counter + waiter count, plus a runtime semaphore. Done is Add(-1); when the counter hits zero it releases all waiters. Calling Add positive concurrently with Wait (counter possibly at zero) is a documented misuse and can panic ("WaitGroup misuse") — hence the rule: Add in the spawning goroutine, before go.
Interview angle: "Spot the bug": go func(){ wg.Add(1); ... }() — Wait can run before Add executes and return early. Also "WaitGroup vs errgroup?" — WaitGroup has no error propagation or cancellation; the moment goroutines can fail, reach for errgroup (see [[Go Concurrency Patterns]]).
Gotchas / common wrong answers:
Addinside the goroutine (race withWait).- Passing WaitGroup by value into a function — copies it; pass
*sync.WaitGroup(vet catches). - Counter going negative (extra
Done) — immediate panic.
Trade-offs / when NOT to use: No results, no errors, no cancel — it's purely "wait for N". Needing any of those means errgroup or channels. (Go 1.25 added wg.Go(f) folding Add/Done; know it exists, but at 1.22 write the explicit form.)
sync.Once, OnceFunc, OnceValue
What: Guarantees a function runs exactly once across goroutines, with all callers blocking until the first call completes; 1.21 added sync.OnceFunc/OnceValue/OnceValues wrappers.
Syntax/Signature: var once sync.Once; once.Do(f); getConfig := sync.OnceValue(loadConfig) → func() Config.
var getPool = sync.OnceValue(func() *pgxpool.Pool {
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil { panic(err) } // Once gives no retry: panic or use OnceValues with error
return pool
})
How it works under the hood: An atomic done flag + mutex. Fast path: atomic load of done, return. Slow path: lock, re-check (double-checked locking done correctly — the atomic store of done happens only after f returns, giving the happens-before edge that makes f's writes visible to every subsequent caller). If f panics, done is still set — future Do calls do nothing; the failure is permanent.
Interview angle: "Implement a thread-safe lazy singleton" — the expected answer is sync.Once/OnceValue, and the follow-up is "why doesn't a plain if initialized check work?" (data race + visibility: memory model gives no ordering without sync — see [[Go Race Conditions & Memory Model]]).
Gotchas / common wrong answers:
- Home-rolled double-checked locking with a plain bool — race; with atomics it's just reimplementing Once worse.
- Expecting Once to retry after error/panic — it won't; if init can fail transiently, cache
(val, err)deliberately or use a mutex with explicit retry logic. - Calling
once.Do(f)recursively from insidef— deadlock.
Trade-offs / when NOT to use: Package-level init() is simpler when eager init is fine; Once is for lazy or dependency-ordered init. One Once per fact — reusing one Once for two different fs runs only the first.
sync.Cond
What: A condition variable: goroutines Wait for an arbitrary predicate over shared state; changers call Signal (wake one) or Broadcast (wake all). Bound to a Locker.
Syntax/Signature: c := sync.NewCond(&mu); c.Wait() (must hold lock; atomically unlocks, parks, relocks on wake); c.Signal(); c.Broadcast().
type Queue struct {
mu sync.Mutex
cond *sync.Cond
items []int
closed bool
}
func NewQueue() *Queue { q := &Queue{}; q.cond = sync.NewCond(&q.mu); return q }
func (q *Queue) Pop() (int, bool) {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 && !q.closed { // loop, never if
q.cond.Wait()
}
if len(q.items) == 0 { return 0, false }
v := q.items[0]; q.items = q.items[1:]
return v, true
}
func (q *Queue) Push(v int) {
q.mu.Lock(); q.items = append(q.items, v); q.mu.Unlock()
q.cond.Signal()
}
How it works under the hood: Wait appends the goroutine to a FIFO notify list, releases the lock, parks; Signal/Broadcast bump a ticket counter and unpark waiter(s), which then re-acquire the lock before returning. The predicate must be re-checked in a loop because between wake-up and lock re-acquisition another goroutine may have consumed the state.
Interview angle: "When would you use Cond over a channel?" — when N waiters wait on a predicate over mutable state and you need Broadcast with re-check semantics (bounded queue full/empty, resettable state). Channels can't broadcast repeatedly; Cond can.
Gotchas / common wrong answers:
ifinstead offoraroundWait— the #1 Cond bug.- Calling
Waitwithout holding the lock — panic. - No context/cancellation support — a
Waitcan't be interrupted; you mustBroadcaston shutdown and check aclosedflag (as above).
Trade-offs / when NOT to use: Rarely needed; most designs are cleaner with channels or close-as-broadcast. Its lack of cancellation makes it a poor fit for request-scoped code. But interviewers use it to test depth — know it cold.
sync/atomic — Lock-Free Primitives
What: Atomic loads/stores/adds/CAS/swaps on machine words; since 1.19 the typed API (atomic.Int64, atomic.Bool, atomic.Pointer[T]) is the idiom.
Syntax/Signature: var n atomic.Int64; n.Add(1); n.Load(); var cfg atomic.Pointer[Config]; cfg.Store(&c); c := cfg.Load(); n.CompareAndSwap(old, new).
type Server struct {
cfg atomic.Pointer[Config] // read-mostly hot path, lock-free reads
}
func (s *Server) Reload(c *Config) { s.cfg.Store(c) }
func (s *Server) Handle() {
c := s.cfg.Load() // consistent snapshot; never mutate *c after Store
_ = c
}
How it works under the hood: Compiles to single hardware instructions (LOCK XADD/CMPXCHG on amd64; LL/SC or LSE on arm64). Per the Go 1.19-revised memory model, Go atomics are sequentially consistent (stronger than C++ acquire/release): an atomic store happens-before any atomic load that observes it, so publishing a pointer via atomic.Pointer safely publishes the pointed-to data written before the store.
Interview angle: "Counter shared by 100 goroutines — mutex or atomic?" — atomic for a single word; mutex the moment the invariant spans multiple variables (atomics on two vars ≠ atomic pair). "What does CAS give you?" — the building block for lock-free algorithms and optimistic retry loops.
Gotchas / common wrong answers:
- Mixing atomic and non-atomic access to the same variable — still a data race.
- Read-modify-write via
LoadthenStore— lost updates; useAddor a CAS loop. - Treating the pattern "copy-on-write via atomic.Pointer" as safe while callers mutate the shared snapshot — snapshots must be immutable.
Trade-offs / when NOT to use: Atomics are for narrow, measured hot paths (counters, flags, config snapshots). Multi-field invariants, anything needing blocking, or code others must maintain → mutex. Premature lock-free is a review-time liability.
Interview Drills
- Why is
sync.RWMutex"not reentrant-safe for readers" even though multiple readers can normally hold it concurrently? — A pending writer blocks new readers to avoid writer starvation — so if the same goroutine already holdsRLockand a writer is queued waiting, that goroutine's secondRLockcall blocks behind the pending writer, which itself is waiting for the firstRLockto release — a self-deadlock. - Why can't you safely make two related fields atomic separately (e.g.,
atomic.Int64for a balance andatomic.Boolfor a "frozen" flag) instead of using a mutex? — Each atomic operation is individually consistent, but there's no atomicity across the two — a reader could observe the balance updated but the flag not yet, or vice versa, seeing an impossible intermediate combined state. Atomics protect single words; a mutex is needed the moment an invariant spans multiple fields. - Implement a thread-safe lazy-initialized singleton, and explain why a plain
if initialized { return }check doesn't work. — Usesync.OnceValue(initFunc). A plain boolean check is a data race: without a happens-before edge, one goroutine's write toinitializedisn't guaranteed visible to another, and the initialization logic itself could run concurrently from two goroutines before either observes the flag as true.
Cross-links
[[Go Channels]] · [[Go Race Conditions & Memory Model]] · [[Go Concurrency Patterns]] · [[Caching & Redis]]