ToolPopToolPop
Go · Lesson 12 of 20

sync.Mutex, RWMutex, WaitGroup, atomic, Once

8 min readUpdated 25 Jun 2026

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.
go
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.

go
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 a sync.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.

go
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

go
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.

go
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.

go
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.

go
var requests atomic.Int64
requests.Add(1)              // atomic increment
n := requests.Load()         // atomic read
requests.Store(0)            // atomic write
swapped := requests.CompareAndSwap(5, 10) // CAS

Interview 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.

go
var cfg atomic.Pointer[Config]
cfg.Store(loadConfig())
// readers anywhere:
c := cfg.Load()

Picking the right primitive

The mental model.

You needUse
Protect a map, slice, or multi-field structsync.Mutex
Same, but reads outnumber writes 10:1sync.RWMutex
Wait for N goroutines to finishsync.WaitGroup
One-time lazy initsync.Once / OnceValue
A request counter, hit counter, flagatomic.Int64 / atomic.Bool
Hot-swap a whole config structatomic.Pointer[T]
Hand work to N workerschannel
Cancel a tree of goroutinescontext.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?
A.For single primitive operations: read, write, increment, compare-and-swap on a single 64-bit value. Anything beyond a single op or a single value, use Mutex. Mutex is also clearer when there is any business logic between the read and the write.
Q.Can I copy a Mutex?
A.No. A Mutex must never be copied after first use. The runtime's `go vet` catches this. The fix: embed *sync.Mutex in your struct as a value (not a pointer), and pass the struct around as a pointer.
Chai0/1 done

Watching quietly. Tap me if you want a tip.

Go Playground

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. 1

    Predict the output. (What if you remove the mutex? Run with -race to confirm.)

    show answer
    // already in the editor