Skip to content
Codeloom
Go

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.

·6 min read · By Codeloom
Intermediate 10 min read

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 RWMutex is higher than Mutex).

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

  1. Call Add before starting the goroutine, not inside it.
  2. Call Done exactly once per Add (use defer).
  3. Call Wait only after all Add calls 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

ScenarioUse
Protect shared state (reads and writes balanced)sync.Mutex
Protect shared state (reads dominate)sync.RWMutex
Wait for N goroutines to finishsync.WaitGroup
Initialize a resource exactly oncesync.Once
Concurrent map with stable keyssync.Map
Communicate between goroutinesChannels

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.Mutex gives exclusive access. Always defer Unlock().
  • sync.RWMutex allows concurrent reads but exclusive writes. Use when reads vastly outnumber writes.
  • sync.WaitGroup waits for a group of goroutines. Call Add before launching the goroutine.
  • sync.Once and sync.OnceValue handle lazy initialization safely.
  • Combine WaitGroup with a semaphore channel for bounded concurrency.
  • Never copy a struct containing a sync primitive.