← All weekly notes

3 min read

A mental model for Go synchronization

A compact guide to choosing between mutexes, atomics, wait groups, condition variables, Once and sync.Map.

Go · Concurrency · Synchronization

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

NeedStart with
Protect related shared statesync.Mutex
Update one independent counter or flagsync/atomic
Wait for a group of tasks to finishsync.WaitGroup
Wait for a repeatable state changesync.Cond
Run initialization at most oncesync.Once
Share a map with one of its specialized access patternssync.Map
Transfer ownership or communicate valuesChannel

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.

Further reading