Skip to main content

Go sync Package: Mutexes, RWMutex, WaitGroups, and More

Published: May 8, 2026 Updated: August 29, 2026 Larry Qu 7 min read

The sync package provides the lower-level synchronization building blocks in Go. Channels handle communication between goroutines; the sync package handles protecting shared state. Both have their place — the Go proverb “share memory by communicating” is a preference, not an absolute rule. When you have shared data (a cache, a counter, a map) that multiple goroutines read and write, a mutex is often simpler and more efficient than routing all access through a channel.

This guide covers each primitive in the package, when to reach for it, and the subtle mistakes that cause races and deadlocks.

For context see Go goroutines, Go channels, and Go concurrency performance tuning.

sync.Mutex — Mutual Exclusion

A Mutex ensures only one goroutine executes a critical section at a time. All others block at Lock() until the holder calls Unlock().

Embed the mutex directly in the struct it protects — this makes it clear what the mutex guards:

type SafeCounter struct {
    mu    sync.Mutex
    value int
}

func (c *SafeCounter) Increment() {
    c.mu.Lock()
    defer c.mu.Unlock()  // always use defer — unlocks even on panic
    c.value++
}

func (c *SafeCounter) Get() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.value
}

defer c.mu.Unlock() is the idiomatic pattern. If you call Unlock() explicitly, any early return or panic leaves the mutex locked forever.

Keep critical sections small. The mutex should protect only the memory access, not the surrounding computation. A common mistake is holding the lock during I/O or expensive work:

// ❌ Lock held during a network call — all other goroutines wait
func (c *Cache) fetchAndStore(key string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    result := http.Get(key)  // slow — everyone else is blocked
    c.data[key] = result
}

// ✅ Only lock during the map write
func (c *Cache) fetchAndStore(key string) {
    result := http.Get(key)  // outside the lock
    c.mu.Lock()
    c.data[key] = result
    c.mu.Unlock()
}

Never copy a Mutex. Passing a struct containing a mutex by value copies the mutex’s internal state, which breaks it. Always pass a pointer: func process(c *Cache), not func process(c Cache). The go vet tool flags this.

sync.RWMutex — Optimizing Read-Heavy Workloads

An RWMutex distinguishes readers from writers. Multiple goroutines can hold a read lock simultaneously (RLock/RUnlock). A write lock (Lock/Unlock) is exclusive — it waits for all readers to release, then blocks new readers until the write completes.

Use RWMutex when reads dominate. A shared configuration object that’s written once at startup and read thousands of times per second is the ideal case:

type Config struct {
    mu   sync.RWMutex
    data map[string]string
}

// Read: many goroutines can hold this simultaneously
func (c *Config) Get(key string) string {
    c.mu.RLock()
    defer c.mu.RUnlock()
    return c.data[key]
}

// Write: exclusive — waits for all readers, blocks new ones
func (c *Config) Set(key, value string) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.data[key] = value
}

RWMutex is slower than Mutex for write-heavy workloads because of the extra bookkeeping. Profile before assuming it’s faster — if writes are frequent, a plain Mutex usually wins.

sync.WaitGroup — Waiting for a Group of Goroutines

WaitGroup is a counter that blocks Wait() until it reaches zero. It’s the standard way to wait for a dynamic number of goroutines to complete.

func processItems(items []string) {
    var wg sync.WaitGroup

    for _, item := range items {
        wg.Add(1)
        go func(s string) {
            defer wg.Done()
            process(s)
        }(item)
    }

    wg.Wait()  // blocks until all goroutines call Done()
}

Three rules to avoid subtle bugs:

  1. Call wg.Add(n) before starting the goroutine, not inside it. If the goroutine runs and calls Done() before Add increments the counter, Wait can return too early.
  2. Always use defer wg.Done() to ensure the counter decrements even if the function panics.
  3. Never copy a WaitGroup. Pass a pointer.

When goroutines can fail and you need to collect errors, pair WaitGroup with a buffered error channel:

func runAll(tasks []Task) error {
    var wg sync.WaitGroup
    errc := make(chan error, len(tasks))  // buffered so goroutines don't block

    for _, t := range tasks {
        wg.Add(1)
        go func(task Task) {
            defer wg.Done()
            if err := task.Run(); err != nil {
                errc <- err
            }
        }(t)
    }

    wg.Wait()
    close(errc)

    for err := range errc {
        return err  // return first error
    }
    return nil
}

sync.Once — One-Time Initialization

Once guarantees that a function runs exactly once, regardless of how many goroutines call it concurrently. It’s the correct way to implement lazy singleton initialization:

type DB struct {
    pool *sql.DB
}

var (
    dbInstance *DB
    dbOnce     sync.Once
)

func GetDB() *DB {
    dbOnce.Do(func() {
        pool, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
        if err != nil {
            log.Fatalf("db init: %v", err)
        }
        dbInstance = &DB{pool: pool}
    })
    return dbInstance
}

Even if 100 goroutines call GetDB() simultaneously before initialization is complete, Do blocks all of them until the function finishes, then returns. Subsequent calls return instantly without executing the function again.

Note: if the function passed to Do panics, Once considers it done — future calls will not re-run the function. If your initialization can fail non-fatally, you need a different pattern (a mutex plus an initialized flag).

sync.Pool — Reducing Allocator Pressure

Pool is a cache of temporary objects that can be reused to reduce GC pressure. The key property: objects in the pool may be collected by the GC at any time — Pool does not guarantee retention. Use it for expensive-to-allocate objects that are short-lived and fungible (buffers, byte slices, structs used for one operation then discarded).

var bufPool = sync.Pool{
    New: func() any {
        return make([]byte, 0, 64*1024)  // 64 KB initial capacity
    },
}

func processRequest(r io.Reader) ([]byte, error) {
    buf := bufPool.Get().([]byte)
    defer func() {
        buf = buf[:0]     // reset length, keep capacity
        bufPool.Put(buf)  // return to pool
    }()

    return io.ReadAll(r)  // in real code, read into buf
}

sync.Pool is how Go’s standard library achieves high throughput in packages like fmt and encoding/json — each fmt.Fprintf call borrows a buffer rather than allocating one. The New function is called when the pool is empty; returning the object to the pool with Put makes it available for the next borrower.

Reset the object before returning it — reset the slice length to zero, clear fields in a struct, etc. Returning a dirty object is a common source of subtle bugs.

sync.Cond — Condition Variables

Cond lets goroutines wait for a condition to become true and be woken when it changes. It’s less commonly needed than the other primitives — channels usually express the same pattern more clearly — but it’s the right tool when multiple goroutines need to be woken on a single state change.

// A bounded queue that blocks producers when full and consumers when empty
type BoundedQueue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    items []int
    cap   int
}

func NewBoundedQueue(cap int) *BoundedQueue {
    q := &BoundedQueue{cap: cap}
    q.cond = sync.NewCond(&q.mu)
    return q
}

func (q *BoundedQueue) Push(v int) {
    q.mu.Lock()
    for len(q.items) == q.cap {
        q.cond.Wait()  // release lock, sleep, reacquire on wake
    }
    q.items = append(q.items, v)
    q.cond.Broadcast()  // wake all waiters (both producers and consumers)
    q.mu.Unlock()
}

func (q *BoundedQueue) Pop() int {
    q.mu.Lock()
    for len(q.items) == 0 {
        q.cond.Wait()
    }
    v := q.items[0]
    q.items = q.items[1:]
    q.cond.Broadcast()
    q.mu.Unlock()
    return v
}

Wait() atomically releases the mutex and suspends the goroutine. When another goroutine calls Signal() or Broadcast(), the waiting goroutine reacquires the mutex and returns from Wait(). The for loop re-checks the condition because spurious wakeups can occur and because multiple goroutines may wake simultaneously.

Detecting Races: The Race Detector

Go ships a built-in data race detector. Run any test or binary with -race to enable it:

go test -race ./...
go run -race main.go

The race detector instruments memory accesses at runtime. When two goroutines access the same variable concurrently and at least one is a write without synchronization, it prints a detailed report with goroutine stack traces. It has ~5–10x runtime overhead — appropriate for tests and staging, too slow for production.

Every concurrent Go program should pass go test -race. A race is a bug even if the program appears to produce correct output, because the Go memory model gives no guarantees about the visibility of unsynchronized writes.

Choosing the Right Primitive

Situation Use
Protecting a shared data structure sync.Mutex
Read-heavy data structure, rare writes sync.RWMutex
Wait for N goroutines to complete sync.WaitGroup
One-time initialization sync.Once
Reuse expensive temporary objects sync.Pool
Wait for a condition to change sync.Cond
Simple integer counter sync/atomic
Communicating values between goroutines chan

Summary

  • Embed sync.Mutex in the struct it guards; always defer Unlock()
  • Use sync.RWMutex for read-heavy structures — profile first to confirm it helps
  • sync.WaitGroup: call Add before go, always defer Done(); never copy
  • sync.Once is the correct way to implement lazy singletons — it handles concurrent initialization safely
  • sync.Pool reduces GC pressure for short-lived, reusable objects; reset before returning
  • Always run go test -race — a data race is a bug even when output looks correct

Resources

Comments

👍 Was this article helpful?