sync.Mutex, RWMutex & WaitGroup
The sync package supplies mutexes for shared state and WaitGroup for waiting on goroutine batches.
Use them when channel pipelines would obscure a simple critical section or completion barrier.
Summary
sync.Mutex serializes read/write access to shared data.
sync.RWMutex allows many concurrent readers but exclusive writers.
sync.WaitGroup counts outstanding goroutines; Wait blocks until the count hits zero.
Mutexes must not be copied after first use; WaitGroup requires matching Add/Done pairs.
Recipe
Quick-reference recipe card - copy-paste ready.
var (
mu sync.Mutex
cache map[string]string
wg sync.WaitGroup
)
func load(key string) {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
defer mu.Unlock()
cache[key] = fetch(key)
}()
}
wg.Wait()When to reach for this:
- Protect maps, slices, and structs mutated by multiple goroutines.
RWMutexwhen reads dominate and writes are rare.WaitGroupat fork/join boundaries without passing a done channel.- Prefer channels when transferring ownership beats sharing a mutable struct.
Working Example
package main
import (
"fmt"
"sync"
"time"
)
type SafeCounter struct {
mu sync.RWMutex
n int
}
func (c *SafeCounter) Inc() {
c.mu.Lock()
c.n++
c.mu.Unlock()
}
func (c *SafeCounter) Value() int {
c.mu.RLock()
defer c.mu.RUnlock()
return c.n
}
func main() {
var c SafeCounter
var wg sync.WaitGroup
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := 0; j < 100; j++ {
c.Inc()
time.Sleep(time.Microsecond)
}
}()
}
wg.Wait()
fmt.Println(c.Value())
}What this demonstrates:
Mutexguards writes inInc.RWMutexread lock inValueallows concurrent readers.WaitGroupwaits for all increment goroutines.- Methods on a struct keep lock discipline localized.
Deep Dive
How It Works
Mutex.Lockblocks until available;Unlockreleases.RWMutex.RLockblocks only if a writer holds the lock; writers block all readers.WaitGroupcounter increments withAdd, decrements withDone,Waitblocks at zero.- Lock/unlock establish happens-before edges for protected memory.
- Copying
MutexorWaitGroupduplicates internal state - use pointers or embed in structs passed by pointer.
Mutex vs RWMutex
| Primitive | Readers | Writers | Overhead |
|---|---|---|---|
Mutex | Exclusive | Exclusive | Lower |
RWMutex | Concurrent | Exclusive | Higher reader scalability |
WaitGroup Rules
| Call | Contract |
|---|---|
Add(n) | Before starting goroutines (or inside goroutine before work) |
Done() | Once per Add(1) - idiomatic defer wg.Done() |
Wait() | After all Add calls; blocks until counter zero |
Go Notes
// Wrong: copy WaitGroup
wg2 := wg // broken
// Wrong: Add after Wait returns
go func() { wg.Add(1) }() // race with Wait
// Right: Add before go
wg.Add(1)
go func() {
defer wg.Done()
}()Gotchas
- Forgotten
Unlock- Deadlock. Fix:defer mu.Unlock()immediately afterLock. - Lock ordering cycles - Goroutine A locks mu1 then mu2, B reverses - deadlock. Fix: global lock order or one mutex.
WaitGroup.AddafterWait- Race. Fix: allAddbeforeWait, or use a barrier channel.- RWMutex for write-heavy workloads - Readers still block writers; can be slower than
Mutex. Fix: benchmark; often plainMutexwins. - Holding lock during I/O - Blocks all waiters. Fix: copy data under lock, release, then I/O.
Alternatives
| Alternative | Use When | Don't Use When |
|---|---|---|
sync.Mutex | General shared struct | Read-heavy with rare writes (try RWMutex) |
sync/atomic | Simple counters/flags | Complex invariants needing multiple fields |
| Channel handoff | Ownership transfer | Hot shared cache with many readers |
errgroup | Tasks + first error | Simple join without errors |
FAQs
Mutex or channel for a counter?
Mutex + int is fine for in-process counters.
Channels add overhead unless the counter is part of a pipeline stage.
Can I defer RUnlock?
Yes - defer c.mu.RUnlock() after RLock mirrors write locks.
Is RWMutex always faster for reads?
No - reader locks have bookkeeping cost.
Benchmark your access pattern; write-heavy code may prefer Mutex.
What if WaitGroup counter goes negative?
Wait panics if Add drives counter negative.
Match each Add(1) with exactly one Done.
Can WaitGroup be reused?
Yes after Wait returns and no goroutines still call Done.
Wait until Wait completes before a new batch of Add.
Are mutexes fair?
Go mutexes are not strictly FIFO fair but avoid starvation in practice.
Do not rely on lock ordering for correctness.
Should I embed Mutex in exported structs?
Embed unexported mu sync.Mutex in types you control.
Exported mutexes tempt callers to lock incorrectly.
How does this interact with -race?
Race detector verifies lock covers conflicting accesses.
Missing locks still report races even if tests pass sometimes.
Pointer receiver required?
Methods that lock should use pointer receivers so all callers share one mutex.
WaitGroup vs done channel?
WaitGroup is simpler for join N workers.
Channels excel when sending results or shutdown signals too.
Related
- Concurrency Basics - mutex and WaitGroup intro
- sync.Once, Cond, Pool & sync.Map - other sync primitives
- Race Detector: Running and Interpreting Output - verify locking
- Concurrency in Go: Goroutines and Channels First - channels vs locks
- Concurrency Fundamentals Best Practices - lock ordering habits
Stack versions: This page was written for Go 1.26.x (Green Tea GC default, go fix modernizers - verify patch at build), chi (latest - verify at build), gin (latest - verify at build), echo (latest - verify at build), google.golang.org/grpc (latest - verify at build), sigs.k8s.io/controller-runtime (latest - verify at build), kubebuilder (latest - verify at build), tinygo (latest - verify board targets at build), wazero (latest - verify at build), and golangci-lint (latest - verify linter set at build).