Go sync Package: Every concurrency primitive explained

10 min read

Go sync Package: Every concurrency primitive explained

Concurrency is not parallelism, but both will ruin your week if you get them wrong.

Go makes it dangerously easy to spawn goroutines. A simple go keyword and suddenly you have thousands of concurrent execution paths sharing memory. The language gives you channels as the idiomatic coordination mechanism, and they work beautifully for message passing. But channels are not the answer to everything. When two goroutines need to read and write the same variable, when you need to wait for a group of tasks to finish, or when you want to initialize something exactly once, you need lower level tools.

That's where the sync package comes in. It's Go's toolbox for shared memory concurrency. Every primitive in it exists because someone, somewhere, had a data race in production and needed a precise, minimal tool to fix it.

Let's go through each one.

sync.Mutex: The foundation

A Mutex is a mutual exclusion lock. It solves the most fundamental concurrency problem: two goroutines trying to modify the same data at the same time.

Without a mutex, you get a data race. With a data race, you get corrupted state, impossible bugs, and Go's race detector screaming at you (if you're lucky enough to have it enabled).

type SafeCounter struct {
    mu    sync.Mutex
    count int
}
 
func (c *SafeCounter) Increment() {
    c.mu.Lock()
    c.count++
    c.mu.Unlock()
}
 
func (c *SafeCounter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

The pattern is simple: lock before accessing shared state, unlock when you're done. Use defer c.mu.Unlock() when the function has multiple return paths so you don't accidentally forget to unlock.

The most common mistake with Mutex is copying it. A sync.Mutex must never be copied after first use. If you pass your struct by value instead of by pointer, Go silently copies the mutex and both copies think they own the lock independently. The race detector won't always catch this. Use go vet to detect it.

Another classic pitfall: holding the lock longer than necessary. If your critical section does network I/O or disk access while holding a mutex, every other goroutine blocks waiting for that slow operation. Keep the locked section as small as possible.

sync.RWMutex: When reads dominate

If your workload is 95% reads and 5% writes, a regular Mutex forces all those readers to wait for each other even though reading doesn't modify state. That's wasteful.

RWMutex allows multiple concurrent readers OR a single writer. Readers call RLock()/RUnlock(), writers call Lock()/Unlock(). When a writer holds the lock, all readers block. When readers hold the lock, writers block. Readers never block other readers.

type ConfigStore struct {
    mu     sync.RWMutex
    values map[string]string
}
 
func (s *ConfigStore) Get(key string) (string, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    v, ok := s.values[key]
    return v, ok
}
 
func (s *ConfigStore) Set(key, value string) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.values[key] = value
}

This is a classic use case: a configuration map that's read frequently and updated rarely. Every Get can proceed concurrently with other Get calls, and only Set requires exclusive access.

The gotcha here is reaching for RWMutex too early. If your reads are fast (a map lookup, an integer read) and contention is low, a plain Mutex is actually faster because RWMutex has more internal bookkeeping. Profile before optimizing. Use RWMutex when you have measured contention and confirmed that reads significantly outnumber writes.

There's also a subtle starvation issue. Go's RWMutex gives priority to writers: once a writer is waiting, new readers will block too. This prevents writer starvation but means a burst of write attempts can temporarily block all reads.

sync.WaitGroup: Waiting for goroutines

You've spawned ten goroutines. How do you know when they're all done?

You could use a channel and count messages. But sync.WaitGroup does exactly this with less ceremony.

func fetchAll(urls []string) []Result {
    var wg sync.WaitGroup
    results := make([]Result, len(urls))
 
    for i, url := range urls {
        wg.Add(1)
        go func(i int, url string) {
            defer wg.Done()
            results[i] = fetch(url)
        }(i, url)
    }
 
    wg.Wait()
    return results
}

Call Add(n) before launching goroutines, Done() when each one finishes, and Wait() to block until the counter reaches zero.

The most frequent bug: calling Add inside the goroutine instead of before it. If the scheduler runs Wait() before the goroutine calls Add, the wait group thinks there's nothing to wait for and returns immediately. Always call Add in the launching goroutine, before the go keyword.

Another mistake is reusing a WaitGroup before Wait returns. The documentation explicitly says you must not call Add while Wait is running. If you need to coordinate multiple rounds of goroutines, use a fresh WaitGroup for each round.

sync.Once: Initialize exactly once

Some things should happen only once: opening a database connection, loading configuration, computing an expensive value. You could use a mutex and a boolean flag, but sync.Once is built for this.

var (
    instance *Database
    once     sync.Once
)
 
func GetDatabase() *Database {
    once.Do(func() {
        instance = connectToDatabase()
    })
    return instance
}

No matter how many goroutines call GetDatabase simultaneously, connectToDatabase runs exactly once. All other callers block until it finishes, then they all get the same instance.

What makes sync.Once better than a mutex with a flag is that after the first call, subsequent calls have essentially zero overhead. It uses an atomic check internally, so the fast path (already initialized) doesn't acquire any lock.

One thing that catches people off guard: if the function passed to Do panics, Once still considers it "done". The function will never be called again, even though it failed. If initialization can fail, use sync.OnceValue (Go 1.21+) or handle the error yourself inside the function and store it somewhere accessible.

sync.Cond: Waiting for conditions

sync.Cond is probably the least understood primitive in the package. It lets goroutines wait until a specific condition becomes true, with another goroutine signaling when things change.

Think of it as a waiting room. Goroutines sit and wait. When the condition they care about changes, someone calls Signal (wake one) or Broadcast (wake all).

type Queue struct {
    mu    sync.Mutex
    cond  *sync.Cond
    items []int
}
 
func NewQueue() *Queue {
    q := &Queue{}
    q.cond = sync.NewCond(&q.mu)
    return q
}
 
func (q *Queue) Push(item int) {
    q.mu.Lock()
    q.items = append(q.items, item)
    q.mu.Unlock()
    q.cond.Signal()
}
 
func (q *Queue) Pop() int {
    q.mu.Lock()
    for len(q.items) == 0 {
        q.cond.Wait()
    }
    item := q.items[0]
    q.items = q.items[1:]
    q.mu.Unlock()
    return item
}

The critical detail: Wait must be called inside a for loop, never an if. This is because of spurious wakeups. The goroutine might wake up even though nothing signaled it, or another goroutine might have consumed the item between the signal and the wakeup. The loop rechecks the condition every time.

Also, Wait atomically releases the lock and suspends the goroutine. When it wakes up, it reacquires the lock before returning. This is important because it means you're always holding the lock when you check the condition.

In practice, you'll rarely need sync.Cond. Channels handle most signaling patterns more cleanly. But for scenarios where multiple goroutines wait for the same condition (like a bounded buffer or a barrier), Cond with Broadcast is the right tool.

sync.Map: A concurrent map (with caveats)

Go maps are not safe for concurrent use. If two goroutines read and write a map simultaneously, your program crashes. The standard solution is a Mutex protecting a regular map, like the ConfigStore example above.

sync.Map offers a lock-free alternative, but it's not a general purpose replacement. It's optimized for two specific patterns: keys that are written once and read many times, and keys that are disjoint across goroutines (each goroutine works with its own set of keys).

var cache sync.Map
 
func GetUser(id string) (*User, error) {
    if v, ok := cache.Load(id); ok {
        return v.(*User), nil
    }
 
    user, err := fetchUserFromDB(id)
    if err != nil {
        return nil, err
    }
 
    cache.Store(id, user)
    return user, nil
}

The API uses interface{} (or any) for both keys and values, which means you lose type safety. Every Load requires a type assertion. This is the biggest ergonomic downside.

For most use cases, a sync.RWMutex protecting a regular map is both simpler and faster. The Go team themselves say this in the documentation. Reach for sync.Map only when profiling shows that lock contention on your regular map is a bottleneck, and your access pattern matches one of the two patterns above.

The LoadOrStore method is useful when you want to avoid the race between checking if a key exists and storing it:

actual, loaded := cache.LoadOrStore(id, newUser)
if loaded {
    // Someone else stored it first, use their value
    return actual.(*User), nil
}

sync/atomic: Lock-free operations

Sometimes a mutex is too heavy. If all you need is to increment a counter or flip a boolean, atomic operations let you do it without locks. They compile down to single CPU instructions that are guaranteed to be indivisible.

Go 1.19 introduced typed atomic wrappers that are much nicer than the old function-based API.

type ServerStats struct {
    requestCount atomic.Int64
    isHealthy    atomic.Bool
    lastConfig   atomic.Value
}
 
func (s *ServerStats) HandleRequest() {
    s.requestCount.Add(1)
}
 
func (s *ServerStats) GetRequestCount() int64 {
    return s.requestCount.Load()
}
 
func (s *ServerStats) SetHealthy(healthy bool) {
    s.isHealthy.Store(healthy)
}
 
func (s *ServerStats) UpdateConfig(cfg Config) {
    s.lastConfig.Store(cfg)
}

atomic.Int64 and atomic.Bool are self-explanatory: thread-safe integers and booleans. atomic.Value can store any value but it must always store the same concrete type. If you Store a Config struct the first time, every subsequent Store must also be a Config. Storing a different type panics.

The CompareAndSwap (CAS) method is the building block for lock-free algorithms:

func (s *ServerStats) SetHealthyIfCurrently(expected, new bool) bool {
    return s.isHealthy.CompareAndSwap(expected, new)
}

This atomically checks if the current value equals expected and, only if it does, sets it to new. It returns whether the swap happened. CAS is how you build things like lock-free queues and counters without mutexes.

The pitfall with atomics is trying to protect complex operations with them. An atomic Add is safe. Two atomic operations that need to be consistent with each other are not. If you need to atomically update a counter and a timestamp together, you need a mutex. Atomics protect single values, not groups of values.

When to use what

Choosing the right primitive comes down to understanding your access pattern.

If you have shared mutable state and need exclusive access to it, start with sync.Mutex. It's the simplest, most predictable option. You won't go wrong with it, and for most programs it's all you need.

If profiling reveals that readers are blocking each other unnecessarily and your workload is heavily read-biased, upgrade to sync.RWMutex. But measure first. The overhead of RWMutex means it only wins when read contention is genuinely high.

If you're coordinating the lifecycle of goroutines and need to wait for a batch to complete, sync.WaitGroup is the direct answer. It's simpler than channels for pure "wait for completion" patterns. If you also need the results from those goroutines, consider errgroup.Group from the golang.org/x/sync package, which combines waiting with error collection.

If you're initializing a shared resource that must be created exactly once, use sync.Once. It's more correct and more performant than the mutex-and-boolean pattern everyone writes the first time.

If you need goroutines to block until a complex condition becomes true, and that condition depends on shared state modified by other goroutines, sync.Cond fits. But exhaust channel-based designs first. Channels are easier to reason about and harder to misuse.

If you have a map with high read volume and keys that stabilize over time (caches, registries, lookup tables), sync.Map might outperform a mutex-protected map. But start with the mutex version and only switch if contention shows up in profiles.

If you're updating a single counter, flag, or pointer and nothing else needs to be consistent with that update, atomic operations give you the lowest possible overhead. The moment you need two values to stay in sync, go back to a mutex.

The general rule: start simple, measure, then optimize. A sync.Mutex that's "good enough" is better than a lock-free algorithm that's subtly broken. The race detector (go test -race) is your best friend through all of this. Run it in CI, run it in development, run it always.

TL;DR
  • sync.Mutex for exclusive access to shared state. Keep critical sections small.
  • sync.RWMutex when reads vastly outnumber writes and you've measured the contention.
  • sync.WaitGroup to wait for a batch of goroutines to finish. Call Add before go.
  • sync.Once for one-time initialization. Zero overhead after the first call.
  • sync.Cond for waiting on complex conditions. Always use a for loop with Wait.
  • sync.Map only when access patterns match: stable keys or disjoint key sets.
  • atomic types for single-value updates without locks. Don't use them for compound operations.