AlgoMaster Logo

sync Package

Last Updated: May 22, 2026

High Priority
11 min read

The sync package is Go's toolkit for coordinating goroutines that share memory. It bundles the low-level primitives, mutexes, condition variables, one-time initialization, object pools, wait groups, and a specialized concurrent map, that the rest of the language and most of the standard library build on. This chapter surveys what each type does, when its zero value is ready to use, and where the sharp edges are.

The Package Landscape

sync exports nine top-level identifiers and a handful of helpers. They split into three groups based on what problem they solve.

TypeGroupZero value ready?Most common use
MutexMutual exclusionYesProtect a shared variable from concurrent writes
RWMutexMutual exclusionYesMany readers, few writers
LockerMutual exclusionInterfaceAnything with Lock() / Unlock()
WaitGroupCoordinationYesWait for a set of goroutines to finish
OnceCoordinationYesRun an initializer exactly once
CondCoordinationNo (needs a Locker)Wait for a condition to become true
MapConcurrent dataYesA map that's safe to use from multiple goroutines
PoolConcurrent dataYesReuse short-lived objects to cut GC pressure
OnceFunc, OnceValue, OnceValuesHelpers (Go 1.21+)N/A (functions)Type-safe wrappers around Once

Most of these are zero-value usable. You write var mu sync.Mutex and start calling mu.Lock(). There's no constructor, no NewMutex. Go favors types you can declare without ceremony, and the sync package follows that convention.

What isn't in the table is anything that creates a goroutine. sync doesn't spawn work. It coordinates work other code has already started. If you need a goroutine, you write go f() yourself.

The diagram shows how the three groups fit together. Goroutines touch shared state, and sync provides three different ways to make that safe: locks for mutual exclusion, coordination primitives for timing, and concurrent containers when the data structure itself wants to be shared.

Mutex and RWMutex (Quick Recap)

sync.Mutex provides plain mutual exclusion. One goroutine at a time can hold the lock. The API is two methods, Lock() and Unlock(), plus TryLock() added in Go 1.18.

sync.RWMutex is the same idea but distinguishes read locks from write locks. Many goroutines can hold the read lock at once, but a write lock excludes everything else. The API adds RLock() and RUnlock() to the regular Lock() / Unlock() pair.

MethodWhat it blocksUse when
Lock()All readers and writersMutating shared state
Unlock()Releases write lockPaired with Lock()
RLock()Other writers (readers can stack)Reading shared state
RUnlock()Releases one read lockPaired with RLock()
TryLock()Returns false if lockedAvoid blocking; rare in practice

Treat this section as the API surface, not the playbook.

WaitGroup (Quick Recap)

sync.WaitGroup waits for a collection of goroutines to finish. The pattern is three methods: Add(n) to declare how many you're waiting on, Done() (which the goroutines call when they're finished, often via defer), and Wait() to block until the count hits zero.

Go 1.25 added wg.Go(func()), which combines Add(1) and defer Done() into one call. On earlier versions, the explicit Add/Done pattern shown above is the standard form. Either way, the rule that trips most learners is that Add must happen before the parent calls Wait, never inside the spawned goroutine. If Add(1) runs after Wait has already seen the counter at zero, Wait returns immediately and the goroutine runs unsupervised.

Once and the OnceFunc Helpers

sync.Once guarantees that a function runs exactly once across all goroutines that call it, no matter how many of them race. Every later call blocks until the first call returns, then becomes a no-op.

load runs once, even though three goroutines race to call Price. The other two callers block until load returns, then read the populated map.

Go 1.21 added three helpers that wrap Once in a type-safe form without requiring a struct field.

HelperWraps a function thatReturns
sync.OnceFunc(f)Has no return valueA func() that runs f once
sync.OnceValue(f)Returns one valueA func() T that caches the value
sync.OnceValues(f)Returns two valuesA func() (T1, T2) that caches both

loadCatalog is a closure that does the work on its first invocation and returns the cached result on every later one. If the wrapped function panics, the panic gets cached too: every subsequent call re-panics with the same value. That's a deliberate design, you don't want a flaky initializer to silently succeed the second time.

Pool (Quick Recap)

sync.Pool is a per-CPU cache of reusable objects. You Put something into it when you're done, and Get to fetch something back. The pool may discard cached objects at any garbage collection cycle, so you can't use it to store anything you actually need to keep.

Two rules to keep in mind:

  1. Objects in the pool can vanish between `Put` and `Get`. The pool is a cache hint, not a guarantee. If the GC decides to reclaim, Get calls New and returns a fresh object.
  2. Always reset the object before putting it back. A bytes.Buffer you forgot to reset will hand its leftover contents to the next caller.

Pooling helps when you have large, short-lived allocations in hot paths, and doesn't help for small structs or anything you need to keep beyond the next GC cycle.

sync.Map: When Two Maps Are Faster Than One

A plain map[K]V is not safe for concurrent use. The runtime detects concurrent writes and panics with fatal error: concurrent map writes. Wrapping a map in a sync.Mutex (or RWMutex) is the obvious fix, and for most workloads it's the right one.

sync.Map exists for the workloads where it isn't. It's a concurrent map type added in Go 1.9, tuned for two specific patterns:

  1. Keys are written once and read many times after that (a stable cache).
  2. Multiple goroutines work on disjoint sets of keys, so they rarely contend on the same entry.

For those patterns, sync.Map is faster than map + Mutex because it sidesteps the mutex on the read path most of the time. For anything else, including write-heavy workloads or workloads where many goroutines hit the same keys, the locked plain map usually wins.

The Two-Map Design

Internally, sync.Map keeps two maps: a read map that's optimized for lock-free reads, and a dirty map that absorbs writes. The implementation moves entries between them based on access patterns.

A Load call first checks the read map atomically with no locking. If the key is there, it returns immediately. If it isn't, sync.Map takes a lock, checks the dirty map, and bumps a miss counter. Once the miss counter crosses a threshold, the dirty map gets promoted to become the new read map and a fresh dirty map gets allocated. That promotion is what makes reads of stable keys nearly lock-free over time.

The cost shows up on the write side. Stores into keys that already live in the read map can sometimes happen atomically, but stores of brand-new keys always have to lock and update the dirty map, and occasionally trigger a full copy from read into dirty. Heavy writing with lots of new keys is the worst case.

The API

sync.Map's API doesn't look like a regular map. There's no m[k] syntax, no len, no range keyword. Everything is methods.

MethodBehavior
Load(k)Returns (value, ok). The ok is false if the key is absent.
Store(k, v)Sets the key.
LoadOrStore(k, v)If the key exists, returns the current value. If not, stores v and returns it. The loaded bool tells you which happened.
LoadAndDelete(k)Atomically returns the current value and deletes the key.
Delete(k)Removes the key.
Range(f)Iterates. Stops if f returns false.
CompareAndSwap(k, old, new)Sets new only if the current value equals old. (Go 1.20+)
CompareAndDelete(k, old)Deletes only if the current value equals old. (Go 1.20+)
Swap(k, v)Replaces the value and returns the previous one. (Go 1.20+)

Here's a session cache built on sync.Map. Customer sessions are written once at login and then read on every request.

Load returns any, so you have to type-assert it back to the concrete type. That's the price of sync.Map not being generic in the standard library. Range takes a function that returns bool; return true to keep iterating, false to stop. The iteration order is unspecified, same as a regular map.

sync.Map vs map + Mutex

This comparison determines which one to use.

Propertysync.Mapmap + sync.Mutex
Type safetyany keys and values, manual assertsStatic types, no asserts
len()Not available; you have to Range and countlen(m) is O(1)
Read performance under contentionExcellent (lock-free fast path)Limited by mutex
Write performanceWorse, especially for new keysPredictable
Memory overheadHigher (two internal maps + atomics)One map plus the mutex
Iteration orderUnspecifiedUnspecified
Good fitStable keys, many readers, disjoint writersEverything else

A rough benchmark on a workload of 1M operations with mixed read/write ratios looks like this on a typical x86 machine:

Workloadmap+RWMutexsync.MapWinner
99% read, 1% write, same keys250 ns/op40 ns/opsync.Map
50% read, 50% write, mixed keys180 ns/op320 ns/opmap+RWMutex
100% write, all new keys150 ns/op450 ns/opmap+RWMutex

Numbers vary by hardware and contention level, but the pattern is consistent: sync.Map is faster for stable, read-heavy access and slower for write-heavy or churn-heavy workloads. The official documentation says it plainly: most code should use a plain map with a mutex first, and only switch to sync.Map after measurement says it helps.

CompareAndSwap on sync.Map

Go 1.20 added compare-and-swap on sync.Map, which is useful when you want to update a value only if it hasn't changed since you read it. A classic case is updating a stock count after a customer adds an item to the cart.

The CAS loop handles the case where two goroutines read the same value, both compute current - 1, and race to write it back. Only one CAS succeeds; the other one re-reads and tries again. This is the same pattern as atomic.CompareAndSwapInt32, lifted to map entries.

sync.Cond: Waiting for a Condition

sync.Cond is the rarest type in the package. It's a condition variable: a way for a goroutine to wait until some predicate becomes true, then wake up. The classic use is producer-consumer where consumers should block when there's nothing to consume.

Most Go code uses a channel instead, and most of the time that's correct. Cond is appropriate when the wake-up logic is complex: when multiple waiters need to check different conditions, or when the predicate isn't a simple "is there something in the queue" question. The standard library uses Cond inside pipe, archive/tar, and the net/http server.

A sync.Cond wraps a sync.Locker (the interface satisfied by *sync.Mutex and *sync.RWMutex). It has three methods.

MethodWhat it does
Wait()Atomically unlocks the locker, blocks until signaled, then relocks.
Signal()Wakes one goroutine waiting on the cond, if any.
Broadcast()Wakes all goroutines waiting on the cond.

Here's an order-processing queue where worker goroutines wait until orders arrive.

A few things make this work:

  • Wait() is called inside a loop, not an if. A waiter can wake up spuriously, or wake up only to find that another goroutine already drained the queue. Re-checking the predicate is mandatory.
  • Wait() releases the mutex while it blocks and reacquires it before returning. That's why you can hold the mutex around the loop without deadlocking the senders.
  • Signal() and Broadcast() don't have to be called with the mutex held, but in practice you almost always do, because the state change that justifies the signal lives inside the critical section anyway.
  • Close() uses Broadcast() so every waiter wakes up to see closed == true and returns. If you used Signal(), only one worker would wake up and the rest would block forever.

The sequence diagram shows the dance. The consumer holds the mutex, calls Wait, which drops the mutex and parks the goroutine. The producer locks, pushes, signals, unlocks. The consumer wakes up holding the mutex again and can safely inspect the queue.

When Not to Use Cond

sync.Cond is sharp. Most concurrency problems in Go are easier with channels:

  • Single signal, fan-out: Use close(done) on a chan struct{}. Every receiver wakes up at once and can never miss the close.
  • Bounded queue with backpressure: Use a buffered channel. Senders block when full, receivers block when empty, no condition variable needed.
  • Producer-consumer with arbitrary cancellation: Combine a channel and a context.Context in a select.

Use Cond when:

  1. The state being waited on isn't naturally expressed as channel sends and receives.
  2. You need Broadcast semantics (all waiters wake) and channel-based equivalents would be awkward.
  3. You're building a primitive that other code consumes (like a bounded buffer with custom eviction rules).

Outside of those, the channel version is usually shorter and harder to break.

The Don't-Copy Rule

Every type in the sync package contains state, an atomic flag, a goroutine wait list, an internal pointer, and copying that state breaks everything. The Go runtime can detect some of these bugs at startup if you're unlucky, but go vet catches them at compile time with the copylocks analyzer.

What's Wrong with This Code?

The Add method has a value receiver. Every call copies the entire Counter, including the mutex. Two goroutines calling Add on the same logical counter end up locking two different mutexes, so the lock provides no protection. On top of that, c.count += n modifies the copy, not the original, so the result prints as 0 regardless of how many times you call Add.

go vet flags this with Add passes lock by value: main.Counter contains sync.Mutex. The fix is a pointer receiver.

Fix:

The same trap shows up in less obvious places. Returning a struct that contains a sync.Mutex by value, storing such a struct in a slice or map and pulling it back out, range-ing over a slice of structs (each iteration produces a copy), all of these can silently duplicate the mutex.

Use a slice of pointers ([]*Customer) or index by position (customers[i].mu.Lock()) instead.

go vet runs by default when you go test, so this class of bug usually surfaces during normal development. Take the warning seriously; the runtime cost of pointer indirection is far smaller than the cost of debugging a silently broken mutex.

The defer Unlock Pattern

When a critical section can return through multiple paths or panic, releasing the lock with defer is the safe default. The lock is released exactly once when the function returns, no matter how.

Both branches return through the same defer, so the lock is released cleanly in both. If you wrote c.mu.Unlock() explicitly before each return, an early return added later could easily skip it.

The pattern has one cost worth knowing about.

There's one shape where defer is wrong: when you want to do work outside the critical section after releasing the lock.

Taking a snapshot under the lock and computing on the snapshot outside is a common pattern. defer would keep the lock held for the whole computation, which serializes goroutines that didn't need to wait.

Zero-Value-Ready Types and What That Buys You

The fact that Mutex, RWMutex, WaitGroup, Once, Map, and Pool are usable from their zero value is a small thing that adds up. It means you never write a constructor for them. You embed them directly in your structs, and the surrounding struct's zero value works too.

Compare this to languages where you'd write a NewProductStore constructor that initializes each lock. Go avoids that because most of those constructors have nothing to do other than return a fresh zero value. The exception is sync.Cond, which requires sync.NewCond(locker) because it needs to know which lock to coordinate with. There's no useful default lock to pick, so the constructor stays.

The Locker Interface

sync.Locker is a tiny interface:

Both *sync.Mutex and *sync.RWMutex satisfy it (for RWMutex, Lock is the write lock). sync.NewCond takes a Locker, which is how Cond works with either type of mutex.

RWMutex also exposes an RLocker() method that returns a Locker whose Lock/Unlock map to RLock/RUnlock. This is occasionally useful when you want to pass a read-lock view of an RWMutex to something that wants a Locker.

This is a small but useful piece of plumbing. It lets sync.Cond, debugging helpers, and your own code work uniformly with any lockable thing.