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
On this page
- The mental model: channels aren’t for everything
- sync.Mutex: The foundation
- sync.RWMutex: When reads dominate
- Don’t reach for it too early
- sync.WaitGroup: Waiting for goroutines
- sync.Once: Initialize exactly once
- Why not a mutex and a flag
- sync.Cond: Waiting for conditions
- Wait goes in a for loop
- sync.Map: A concurrent map (with caveats)
- LoadOrStore
- sync/atomic: Lock-free operations
- CompareAndSwap
- When to use what
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).
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 callLock()/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.
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.
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.
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.
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).
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:
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.
CompareAndSwap
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.
When to use what
Choosing the right primitive comes down to understanding your access pattern.
| If you… | Use | But |
|---|---|---|
| have shared mutable state and need exclusive access to it | sync.Mutex | Start 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-biased | sync.RWMutex | Measure 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 complete | sync.WaitGroup | 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. |
| are initializing a shared resource that must be created exactly once | sync.Once | It’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 goroutines | sync.Cond | Exhaust 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 map | Start 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 update | atomic operations, which 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.
TL;DR
sync.Mutexfor exclusive access to shared state. Keep critical sections small.sync.RWMutexwhen reads vastly outnumber writes and you’ve measured the contention.sync.WaitGroupto wait for a batch of goroutines to finish. CallAddbeforego.sync.Oncefor one-time initialization. Zero overhead after the first call.sync.Condfor waiting on complex conditions. Always use aforloop withWait.sync.Maponly when access patterns match: stable keys or disjoint key sets.atomictypes for single-value updates without locks. Don’t use them for compound operations.
Materials used to write this article
- Package sync documentation — The WaitGroup and sync.Map documentation the post refers to
- Package errgroup (golang.org/x/sync) — errgroup.Group, mentioned in When to use what
More of what I read in my software-engineering-guide.
Related posts
Deep dive
singleflight: How Go Collapses Duplicate Calls Under a Thundering Herd
When a hot cache key expires, N concurrent requests all miss and slam the same expensive query at once. golang.org/x/sync/singleflight collapses them into a single execution. Here's the problem it solves and exactly how it works inside.
8 min read
Deep dive
The context Package: How Go Manages Cancellation Across Goroutines
From done channels to context trees. How Go's context package solves cancellation propagation, deadline management, and request-scoped data across concurrent call graphs.
onGo posts
16 min read
Build log
A dispatcher for delivery riders: one routing problem a minute
Every minute, every open order against every rider on duty, solved as one vehicle routing problem with VROOM. How the round works, why the solver's answer is only a proposal, and why the legs are diffed instead of rebuilt.
17 min read