I think of a concurrent Go program as a busy office with a shared whiteboard. Several people can work at the same time, but they need a clear rule for reading shared state, changing it and waiting for one another.
Each synchronization tool solves a different part of that problem.
Mutex: the talking stick
A mutex gives one goroutine exclusive access to shared state while it checks or changes an invariant.
mu.Lock()balance -= amounttransactions++mu.Unlock()
Use it when several fields must change together or when the operation contains multiple steps. The protected state and its rules are more important than the number of variables.
Atomic: the wall counter
An atomic operation reads or changes one value as an indivisible operation. It works well for independent counters, flags, pointers and state transitions.
var requests atomic.Int64 requests.Add(1)current := requests.Load()
Atomics also provide operations such as Swap and CompareAndSwap. They are low-level tools, not automatically a faster replacement for a mutex.
If correctness depends on several values or a read-check-write sequence, a mutex is usually clearer. Atomics compose only when the complete invariant can be represented safely by their operations.
WaitGroup: the completion counter
A sync.WaitGroup waits for a known collection of goroutines to finish.
var wg sync.WaitGroup for _, job := range jobs { wg.Add(1) go func() { defer wg.Done() process(job) }()} wg.Wait()
It answers “has all the work finished?” It does not protect shared data, carry results or cancel goroutines.
Cond: the state-change bell
A sync.Cond lets goroutines wait until shared state may have changed. The condition itself lives in normal variables protected by the associated lock.
var mu sync.Mutexcond := sync.NewCond(&mu)ready := false // Waiting goroutine.mu.Lock()for !ready { cond.Wait()}mu.Unlock() // Signalling goroutine.mu.Lock()ready = truecond.Broadcast()mu.Unlock()
Wait atomically unlocks the mutex and parks the goroutine. It locks the mutex again before returning.
The condition must always be checked in a loop. A goroutine can wake after another goroutine has already changed the state again. Signal wakes one waiter and Broadcast wakes all waiters.
For a one-time event, closing a channel is often simpler. A condition variable is useful when many goroutines repeatedly wait for changes to shared state.
Once: the one-time gate
sync.Once ensures that one function runs at most once, even when several goroutines call it concurrently.
var once sync.Once once.Do(loadConfiguration)
It is useful for one-time initialization. If the function panics, Once still considers the call complete and does not retry it.
Map: the specialized shared notebook
sync.Map is a concurrent map optimized mainly for two access patterns:
- A key is written once and read many times.
- Goroutines mostly work on disjoint sets of keys.
For most application state, a typed map[K]V protected by a mutex is easier to understand and makes multi-value invariants explicit.
Choosing quickly
| Need | Start with |
|---|---|
| Protect related shared state | sync.Mutex |
| Update one independent counter or flag | sync/atomic |
| Wait for a group of tasks to finish | sync.WaitGroup |
| Wait for a repeatable state change | sync.Cond |
| Run initialization at most once | sync.Once |
| Share a map with one of its specialized access patterns | sync.Map |
| Transfer ownership or communicate values | Channel |
What I want to remember
Choose the primitive that makes the invariant easiest to explain. Start with a mutex when state changes together, use atomics for genuinely independent values and use waiting primitives only for the lifecycle they represent.
Synchronization code is easier to trust when the reason for each primitive is visible from the surrounding code.