Go Interview Prep #4: Synchronization
When goroutines share memory, you need synchronization. Go’s sync package provides the primitives for it. Prefer channels for ownership transfer; use sync when multiple goroutines must touch the same state.
sync.Mutex
A mutual exclusion lock. Only one goroutine can hold it at a time. Use it to protect a critical section.
var (
mu sync.Mutex
counter int
)
func increment() {
mu.Lock()
defer mu.Unlock()
counter++
}
Always use defer mu.Unlock() immediately after a successful Lock() — it protects against panics leaving the mutex locked.
Important: sync.Mutex is not re-entrant. Locking a mutex you already hold deadlocks that goroutine. There is no TryLock recursion helper that makes nested locking safe — redesign the critical sections instead. (TryLock exists since Go 1.18 but only for non-blocking acquisition.)
sync.RWMutex
A reader/writer lock. Multiple goroutines can hold a read lock simultaneously, but only one can hold a write lock — and only when no readers hold theirs.
var mu sync.RWMutex
func read() {
mu.RLock()
defer mu.RUnlock()
// safe to read shared data
}
func write() {
mu.Lock()
defer mu.Unlock()
// exclusive access
}
Use RWMutex when reads dominate and writes are infrequent. The extra bookkeeping costs more than a plain Mutex when writes are common. Do not call Lock while holding RLock on the same mutex from the same goroutine — that can deadlock.
sync.WaitGroup
Waits for a collection of goroutines to finish. Add increments the counter, Done decrements it, Wait blocks until it reaches zero.
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(item Item) {
defer wg.Done()
process(item)
}(item)
}
wg.Wait()
Call Add before the goroutine starts — never inside the goroutine itself (a race can make Wait return too early). Pass loop variables as arguments (or use Go 1.22+ per-iteration semantics deliberately).
sync.Once
Runs a function exactly once, even if called from multiple goroutines concurrently. Classic use: lazy initialization.
var (
once sync.Once
instance *DB
)
func GetDB() *DB {
once.Do(func() {
instance = connect()
})
return instance
}
If Do’s function panics, Once considers the call done — subsequent Do calls are no-ops. Design initialization so a failed setup is handled explicitly if callers need retry.
sync/atomic
For simple numeric operations (load, store, add, compare-and-swap), sync/atomic is faster than a mutex because it uses CPU-level atomic instructions.
// Classic API
var count int64
atomic.AddInt64(&count, 1)
v := atomic.LoadInt64(&count)
// Preferred typed API (Go 1.19+)
var count2 atomic.Int64
count2.Add(1)
v2 := count2.Load()
Use atomics for counters, flags, and simple state machines. For anything involving multiple fields that must stay consistent, use a mutex.
sync.Map
A concurrent-safe map built into the standard library. Optimized for cases where keys are written once and read many times (e.g., caches), or when different goroutines touch disjoint key sets.
var m sync.Map
m.Store("key", "value")
v, ok := m.Load("key")
m.Range(func(k, v any) bool {
fmt.Println(k, v)
return true // return false to stop
})
For most use cases, a plain map with a sync.Mutex (or RWMutex) is simpler, more type-safe, and fast enough.
golang.org/x/sync/errgroup
Not in the standard library, but extremely common in interviews and production: run several goroutines, cancel siblings on first error, and wait:
g, ctx := errgroup.WithContext(context.Background())
g.Go(func() error { return fetchA(ctx) })
g.Go(func() error { return fetchB(ctx) })
if err := g.Wait(); err != nil {
return err
}
Mentioning errgroup shows you know practical concurrency beyond the interview checklist of Mutex/WaitGroup.
The race detector
Run go test -race or go run -race to detect data races at runtime. It instruments memory accesses and reports concurrent unsynchronized reads and writes. Always run it in CI. It finds data races, not all concurrency bugs (deadlocks, logical races, and channel misuse can still slip through).
Common interview questions
Q: What is a data race?
Two goroutines access the same memory location concurrently, at least one writes, and there is no happens-before relationship (no synchronization) between them. The behavior is undefined — wrong answers, corrupted memory, or intermittent bugs.
Q: Mutex vs channel — when to use which?
Use a mutex to protect state (shared variables). Use a channel to communicate (pass ownership of data). Rule of thumb: if you are passing a value, prefer a channel; if you are guarding a shared resource, use a mutex.
Q: What happens if you copy a mutex?
The mutex state is copied, which breaks its invariants (two “locks” that don’t coordinate). Always pass mutexes by pointer and embed them carefully. go vet and the copylocks check catch this.
Q: What is the difference between sync.Mutex and sync.RWMutex?Mutex is exclusive. RWMutex allows concurrent readers, giving better throughput when reads dominate. Prefer Mutex until profiling says otherwise.
Q: Is locking order important?
Yes. If goroutine A locks mu1 then mu2, and B locks mu2 then mu1, you can deadlock. Establish a global lock order and stick to it.
Quick reference
| Primitive | Use case |
|---|---|
sync.Mutex | Exclusive access to shared state |
sync.RWMutex | Many readers, few writers |
sync.WaitGroup | Wait for goroutines to finish |
sync.Once | One-time initialization |
sync/atomic | Simple numeric ops without mutex overhead |
sync.Map | Concurrent map with read-heavy / disjoint keys |
errgroup | Parallel work + first error + cancellation |
-race flag | Detect data races in tests and dev |
