Go Sync Primitives: Mutex, RWMutex, WaitGroup, and Once
A practical guide to Go sync primitives including Mutex, RWMutex, WaitGroup, Once, and when to choose each over channels.
What you'll learn
- ✓When and how to use sync.Mutex vs sync.RWMutex
- ✓Coordinating goroutines with sync.WaitGroup
- ✓Lazy initialization with sync.Once and sync.OnceValue
Prerequisites
- •Basic Go knowledge
- •Understanding of goroutines
Go’s channels are great for communication, but sometimes you just need to protect shared state. The sync package provides low-level primitives for exactly that. This guide covers each one with production patterns.
sync.Mutex: Exclusive Access
A Mutex (mutual exclusion lock) ensures only one goroutine accesses a critical section at a time.
type Counter struct {
mu sync.Mutex
count int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.count++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.count
}
Common Mistakes
Copying a mutex. Never copy a struct that contains a mutex. Pass by pointer instead.
// Bad: copies the mutex
func process(c Counter) { ... }
// Good: pass by pointer
func process(c *Counter) { ... }
Forgetting defer. If the code between Lock and Unlock can panic, the mutex stays locked forever. Always use defer.
Locking inside a loop unnecessarily. If you hold the lock for the entire batch, other goroutines starve.
// Bad: holds lock for entire loop
func (c *Counter) AddAll(values []int) {
c.mu.Lock()
defer c.mu.Unlock()
for _, v := range values {
c.count += v
}
}
// Better for fairness: lock per operation (if contention matters)
func (c *Counter) AddAll(values []int) {
for _, v := range values {
c.mu.Lock()
c.count += v
c.mu.Unlock()
}
}
sync.RWMutex: Multiple Readers, One Writer
When reads vastly outnumber writes, RWMutex allows concurrent reads while still ensuring exclusive writes.
type UserCache struct {
mu sync.RWMutex
users map[string]*User
}
func NewUserCache() *UserCache {
return &UserCache{users: make(map[string]*User)}
}
func (c *UserCache) Get(id string) (*User, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
u, ok := c.users[id]
return u, ok
}
func (c *UserCache) Set(id string, u *User) {
c.mu.Lock()
defer c.mu.Unlock()
c.users[id] = u
}
func (c *UserCache) GetAll() []*User {
c.mu.RLock()
defer c.mu.RUnlock()
result := make([]*User, 0, len(c.users))
for _, u := range c.users {
result = append(result, u)
}
return result
}
When to Use RWMutex vs Mutex
Use RWMutex when:
- Read operations significantly outnumber writes (10:1 or more).
- Read operations are not trivially fast (e.g., iterating a map).
Use plain Mutex when:
- Reads and writes are roughly balanced.
- The critical section is very short (the overhead of
RWMutexis higher thanMutex).
sync.WaitGroup: Waiting for Goroutines
WaitGroup blocks until a set of goroutines finish.
func processItems(items []Item) error {
var wg sync.WaitGroup
errCh := make(chan error, len(items))
for _, item := range items {
wg.Add(1)
go func(it Item) {
defer wg.Done()
if err := process(it); err != nil {
errCh <- fmt.Errorf("process item %s: %w", it.ID, err)
}
}(item)
}
wg.Wait()
close(errCh)
for err := range errCh {
return err // return first error
}
return nil
}
WaitGroup Rules
- Call
Addbefore starting the goroutine, not inside it. - Call
Doneexactly once perAdd(usedefer). - Call
Waitonly after allAddcalls have been made.
// Wrong: Add inside the goroutine creates a race
for _, item := range items {
go func(it Item) {
wg.Add(1) // race: Wait might run before this
defer wg.Done()
process(it)
}(item)
}
wg.Wait()
Bounded Concurrency with WaitGroup
Combine a WaitGroup with a semaphore channel to limit parallelism.
func processWithLimit(items []Item, maxWorkers int) {
var wg sync.WaitGroup
sem := make(chan struct{}, maxWorkers)
for _, item := range items {
wg.Add(1)
sem <- struct{}{} // blocks if maxWorkers goroutines are running
go func(it Item) {
defer wg.Done()
defer func() { <-sem }()
process(it)
}(item)
}
wg.Wait()
}
sync.Once: Initialize Exactly Once
sync.Once guarantees a function runs exactly once, even when called from multiple goroutines. This is the standard pattern for lazy initialization.
type DBPool struct {
once sync.Once
pool *sql.DB
err error
}
func (d *DBPool) Get() (*sql.DB, error) {
d.once.Do(func() {
d.pool, d.err = sql.Open("postgres", os.Getenv("DATABASE_URL"))
if d.err != nil {
return
}
d.err = d.pool.Ping()
})
return d.pool, d.err
}
Every call to Get returns the same pool. The connection is created on first access, and subsequent calls skip the initialization.
sync.OnceValue (Go 1.21+)
OnceValue simplifies the pattern when you just need to compute a value once.
var getConfig = sync.OnceValue(func() *Config {
cfg, err := loadConfig("config.yaml")
if err != nil {
panic(fmt.Sprintf("load config: %v", err))
}
return cfg
})
func main() {
cfg := getConfig() // loaded once, cached forever
fmt.Println(cfg.Port)
}
There is also sync.OnceValues for functions that return two values:
var getDB = sync.OnceValues(func() (*sql.DB, error) {
return sql.Open("postgres", os.Getenv("DATABASE_URL"))
})
sync.Map: Concurrent Map
sync.Map is a map safe for concurrent use without additional locking. Use it when keys are stable (written once, read many times) or when disjoint goroutines access disjoint keys.
var cache sync.Map
func GetOrLoad(key string) (interface{}, error) {
if val, ok := cache.Load(key); ok {
return val, nil
}
val, err := expensiveLoad(key)
if err != nil {
return nil, err
}
actual, _ := cache.LoadOrStore(key, val)
return actual, nil
}
For most use cases, a regular map with RWMutex is simpler and faster. Prefer sync.Map only when its specific access patterns match yours.
sync.Cond: Signaling Waiters
sync.Cond lets goroutines wait for a condition to become true. It is rarely needed, but useful for producer-consumer patterns where channels are not a fit.
type Queue struct {
mu sync.Mutex
cond *sync.Cond
items []string
}
func NewQueue() *Queue {
q := &Queue{}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *Queue) Push(item string) {
q.mu.Lock()
q.items = append(q.items, item)
q.mu.Unlock()
q.cond.Signal() // wake one waiter
}
func (q *Queue) Pop() string {
q.mu.Lock()
defer q.mu.Unlock()
for len(q.items) == 0 {
q.cond.Wait() // releases lock, waits, re-acquires lock
}
item := q.items[0]
q.items = q.items[1:]
return item
}
Choosing the Right Primitive
| Scenario | Use |
|---|---|
| Protect shared state (reads and writes balanced) | sync.Mutex |
| Protect shared state (reads dominate) | sync.RWMutex |
| Wait for N goroutines to finish | sync.WaitGroup |
| Initialize a resource exactly once | sync.Once |
| Concurrent map with stable keys | sync.Map |
| Communicate between goroutines | Channels |
The Go proverb says “share memory by communicating.” Use channels when goroutines need to coordinate. Use mutexes when they just need to protect data.
Summary
sync.Mutexgives exclusive access. Alwaysdefer Unlock().sync.RWMutexallows concurrent reads but exclusive writes. Use when reads vastly outnumber writes.sync.WaitGroupwaits for a group of goroutines. CallAddbefore launching the goroutine.sync.Onceandsync.OnceValuehandle lazy initialization safely.- Combine WaitGroup with a semaphore channel for bounded concurrency.
- Never copy a struct containing a sync primitive.
Related articles
- Go Go Concurrency: Fan-Out, Pipeline, and Worker Pool
Master advanced Go concurrency patterns including fan-out/fan-in, pipelines, and worker pools with practical examples and production-ready code.
- Go Go Channel and Select Statement Patterns
Master Go channels and select statements with patterns for fan-out, fan-in, timeouts, cancellation, and pipeline composition.
- Go Race Detector and Debugging Concurrent Go Code
Use Go's race detector to find and fix data races, plus tools and patterns for debugging concurrent programs effectively.
- Go Go Concurrency Patterns
Master Go concurrency — goroutines, channels, select, WaitGroups, mutexes, and common patterns like fan-out/fan-in and worker pools.