runtime/rants

Deep divePublished 9 min read

Go sync Package: Every concurrency primitive explained

onGo posts

A deep dive into Go's sync package and atomic operations. Mutex, RWMutex, WaitGroup, Cond, Once, sync.Map, and atomic types explained with real examples and common pitfalls.

In this post

  • Mutex and RWMutex: exclusive access, and when letting readers share the lock pays off
  • WaitGroup and Once: waiting for a batch of goroutines, and initializing something exactly once
  • Cond: waiting for a condition, and why Wait always goes in a for loop
  • sync.Map: the two access patterns it is optimized for, and its type-safety cost
  • sync/atomic: typed atomics, CompareAndSwap, and where atomics stop being enough
  • A table for choosing the right primitive for your access pattern

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

In this post I go through each primitive in Go’s sync package and sync/atomic: what it is for, the pitfalls that come with it, and when to use which.

The mental model: channels aren’t for everything

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. You need lower level tools:

  • 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.

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.

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).

counter.go
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.

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.
config_store.go
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.

Don’t reach for it too early

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.

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.

fetch.go
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
}

The three calls:

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

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.

database.go
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.

Why not a mutex and a flag

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.

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.

queue.go
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
}

Wait goes in a for loop

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.

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,
  • keys that are disjoint across goroutines (each goroutine works with its own set of keys).
cache.go
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.

LoadOrStore

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

cache.go
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.

stats.go
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.

CompareAndSwap

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

stats.go
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.

When to use what

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

If you…UseBut
have shared mutable state and need exclusive access to itsync.MutexStart here. It’s the simplest, most predictable option. You won’t go wrong with it, and for most programs it’s all you need.
find, by profiling, that readers are blocking each other unnecessarily and your workload is heavily read-biasedsync.RWMutexMeasure first. The overhead of RWMutex means it only wins when read contention is genuinely high.
are coordinating the lifecycle of goroutines and need to wait for a batch to completesync.WaitGroupIt’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.
are initializing a shared resource that must be created exactly oncesync.OnceIt’s more correct and more performant than the mutex-and-boolean pattern everyone writes the first time.
need goroutines to block until a complex condition becomes true, and that condition depends on shared state modified by other goroutinessync.CondExhaust channel-based designs first. Channels are easier to reason about and harder to misuse.
have a map with high read volume and keys that stabilize over time (caches, registries, lookup tables)sync.Map, which might outperform a mutex-protected mapStart with the mutex version and only switch if contention shows up in profiles.
are updating a single counter, flag, or pointer and nothing else needs to be consistent with that updateatomic operations, which give you the lowest possible overheadThe 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.

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.

Materials used to write this article

More of what I read in my software-engineering-guide.

About the author

Joel Teodoro

Joel Teodoro

Backend engineer working with Go in production. I build small systems to understand how big ones work, and draw them here.