sync.Mutex, RWMutex, WaitGroup, atomic, Once
The Go proverb "Don't communicate by sharing memory; share memory by communicating" is the most misquoted line in the language. People hear it as "always use channels". Real production Go uses sync.Mutex constantly, because for protecting a piece of shared state, a mutex is simpler, faster, and easier to reason about than a goroutine plus a channel.
This lesson is the senior view of the sync package: when to reach for which primitive, the bugs that bite you, and the one trick that makes counters wait-free.
Mutex vs channels: the decision rule
The real rule, the one that actually works.
- Protecting a piece of state?
sync.Mutex. - Communicating ownership of a value between goroutines? Channel.
- A bounded queue of work? Channel.
- A counter, a cache, a map of clients? Mutex.
type counter struct {
mu sync.Mutex
n int
}
func (c *counter) inc() {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
}A channel-based "actor goroutine" wrapping a counter would be three times the code and slower. Use the right tool. Senior engineers do not pick channels to look clever.
Senior rule: channels excel at handing ownership of data across boundaries. Mutexes excel at protecting state. Most production code needs both.
The copy-mutex bug
A sync.Mutex is a struct. Copy the struct and you copy the mutex, including its internal state. Now you have two mutexes that think they are one.
type Cache struct {
mu sync.Mutex
m map[string]string
}
func bad(c Cache) { /* c.mu is a copy, locks the wrong thing */ }
func good(c *Cache) { /* c.mu is the original */ }Interview trap: "What is wrong with
func (c Cache) Get(k string)?" Answer: value receiver copies the mutex. The lock taken inside that method protects nothing. Use pointer receivers whenever the struct has a mutex (or anything mutex-like, including async.WaitGroup,sync.Once, etc).
go vet catches the obvious cases and the linter copylocks exists for this exact reason. Always embed the mutex by value inside the struct, always take the struct by pointer.
sync.RWMutex: read-heavy workloads
When reads vastly outnumber writes, sync.Mutex becomes the bottleneck because it serialises everything. sync.RWMutex lets unlimited concurrent readers in, but writers get exclusive access.
type config struct {
mu sync.RWMutex
v map[string]string
}
func (c *config) Get(k string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.v[k]
}
func (c *config) Set(k, v string) {
c.mu.Lock()
defer c.mu.Unlock()
c.v[k] = v
}Use it for things like a feature-flag map that 50,000 goroutines read constantly but the admin updates once a minute. Do not use it when read and write rates are close: the bookkeeping overhead means plain sync.Mutex wins.
sync.WaitGroup: the "wait for N goroutines" pattern
var wg sync.WaitGroup
for _, url := range urls {
wg.Add(1)
go func(u string) {
defer wg.Done()
fetch(u)
}(u)
}
wg.Wait()Three rules and you will never get this wrong. One: call Add before launching the goroutine, never inside it (race condition between Add and Wait). Two: always defer wg.Done() first thing in the goroutine, so a panic still decrements. Three: do not reuse a WaitGroup until Wait has returned. Reusing in flight is undefined behaviour.
sync.Once: idempotent initialisation
Useful for lazy-initialised singletons: a database pool, a connection to Redis, a config loader.
var (
once sync.Once
db *sql.DB
)
func getDB() *sql.DB {
once.Do(func() {
db = sql.Open(...)
})
return db
}Do is called exactly once across all goroutines, ever. If a hundred goroutines race into getDB, ninety-nine of them block until the first one finishes initialising. After that the call is essentially free (an atomic load).
Modern code often prefers sync.OnceFunc, sync.OnceValue, and sync.OnceValues (Go 1.21+) which wrap this pattern more cleanly.
getDB := sync.OnceValue(func() *sql.DB { return sql.Open(...) })sync/atomic: lockless counters
For a single 64-bit value (int64, uint64, pointer), sync/atomic is dramatically faster than a Mutex. The CPU has dedicated instructions for atomic add, load, store, and compare-and-swap; the runtime exposes them directly.
var requests atomic.Int64
requests.Add(1) // atomic increment
n := requests.Load() // atomic read
requests.Store(0) // atomic write
swapped := requests.CompareAndSwap(5, 10) // CASInterview trap: "When is atomic faster than Mutex?" The answer that scores: "For a single 64-bit value where I only do Read, Write, Increment, or compare-and-swap. The moment I need to update two fields together, or update a field based on a computation, I switch to Mutex because atomics on multiple fields are not atomic as a group."
atomic.Value (and the typed atomic.Pointer[T] from Go 1.19+) lets you swap an entire struct pointer atomically. Useful for hot-reloading config: build a new struct, atomic-swap the pointer, every reader sees either the old or the new, never a torn read.
var cfg atomic.Pointer[Config]
cfg.Store(loadConfig())
// readers anywhere:
c := cfg.Load()Picking the right primitive
The mental model.
| You need | Use |
|---|---|
| Protect a map, slice, or multi-field struct | sync.Mutex |
| Same, but reads outnumber writes 10:1 | sync.RWMutex |
| Wait for N goroutines to finish | sync.WaitGroup |
| One-time lazy init | sync.Once / OnceValue |
| A request counter, hit counter, flag | atomic.Int64 / atomic.Bool |
| Hot-swap a whole config struct | atomic.Pointer[T] |
| Hand work to N workers | channel |
| Cancel a tree of goroutines | context.Context |
sync primitives
- sync.Mutex
- Mutual exclusion lock. Lock/Unlock pair. Copy is a bug; always pass by pointer.
- sync.RWMutex
- Reader-writer lock. Multiple concurrent RLocks allowed; Lock is exclusive. Use for read-heavy workloads.
- sync.WaitGroup
- Counter for waiting on N goroutines. Add before launching, Done in the goroutine, Wait in the parent.
- sync.Once
- Runs a function exactly once across all goroutines. Used for idempotent lazy init.
- sync/atomic
- Lockless operations on single integer or pointer values. Faster than Mutex for simple counters.
- compare-and-swap
- Atomic op that sets a value only if it currently equals an expected value. The foundation of lock-free algorithms.
Senior rule: do not pick the fancy primitive. Pick the simplest one that does the job. Mutex first, atomic only when it is provably hot, channels only when you are actually communicating between goroutines.
Common questions
Q.When is sync/atomic faster than sync.Mutex?›
Q.Can I copy a Mutex?›
Watching quietly. Tap me if you want a tip.
Go cannot run natively in a browser. Run copies your code and opens go.dev/play ; paste and click Run there.
Try this (0 of 1 done)
- 1
Predict the output. (What if you remove the mutex? Run with -race to confirm.)
show answer
// already in the editor