Back to Notes

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 -copylocks catches it.
  • Assuming RWMutex always beats Mutex — its bookkeeping is pricier; with short critical sections or few cores, plain Mutex often 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:

  • Add inside the goroutine (race with Wait).
  • 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 inside f — 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:

  • if instead of for around Wait — the #1 Cond bug.
  • Calling Wait without holding the lock — panic.
  • No context/cancellation support — a Wait can't be interrupted; you must Broadcast on shutdown and check a closed flag (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 Load then Store — lost updates; use Add or 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

  1. 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 holds RLock and a writer is queued waiting, that goroutine's second RLock call blocks behind the pending writer, which itself is waiting for the first RLock to release — a self-deadlock.
  2. Why can't you safely make two related fields atomic separately (e.g., atomic.Int64 for a balance and atomic.Bool for 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.
  3. Implement a thread-safe lazy-initialized singleton, and explain why a plain if initialized { return } check doesn't work. — Use sync.OnceValue(initFunc). A plain boolean check is a data race: without a happens-before edge, one goroutine's write to initialized isn'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]]