sync.Mutex, RWMutex & WaitGroup
The sync package supplies mutexes for shared state and WaitGroup for waiting on goroutine batches.
Search across all documentation pages
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.
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.
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:
RWMutex when reads dominate and writes are rare.WaitGroup at fork/join boundaries without passing a done channel.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:
Mutex guards writes in Inc.RWMutex read lock in Value allows concurrent readers.WaitGroup waits for all increment goroutines.Mutex.Lock blocks until available; Unlock releases.RWMutex.RLock blocks only if a writer holds the lock; writers block all readers.WaitGroup counter increments with Add, decrements with Done, Wait blocks at zero.Mutex or WaitGroup duplicates internal state - use pointers or embed in structs passed by pointer.| Primitive | Readers | Writers | Overhead |
|---|---|---|---|
Mutex | Exclusive | Exclusive | Lower |
RWMutex | Concurrent | Exclusive | Higher reader scalability |
| 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 |
// 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()
}()Unlock - Deadlock. Fix: defer mu.Unlock() immediately after Lock.WaitGroup.Add after Wait - Race. Fix: all Add before Wait, or use a barrier channel.Mutex. Fix: benchmark; often plain Mutex wins.| 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 |
Mutex + int is fine for in-process counters.
Channels add overhead unless the counter is part of a pipeline stage.
Yes - defer c.mu.RUnlock() after RLock mirrors write locks.
No - reader locks have bookkeeping cost.
Benchmark your access pattern; write-heavy code may prefer Mutex.
Wait panics if Add drives counter negative.
Match each Add(1) with exactly one Done.
Yes after Wait returns and no goroutines still call Done.
Wait until Wait completes before a new batch of Add.
Go mutexes are not strictly FIFO fair but avoid starvation in practice.
Do not rely on lock ordering for correctness.
Embed unexported mu sync.Mutex in types you control.
Exported mutexes tempt callers to lock incorrectly.
Race detector verifies lock covers conflicting accesses.
Missing locks still report races even if tests pass sometimes.
Methods that lock should use pointer receivers so all callers share one mutex.
WaitGroup is simpler for join N workers.
Channels excel when sending results or shutdown signals too.
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).
Reviewed by Chris St. John·Last updated Jul 18, 2026