Sync Primitives
When channels are not enough
Section titled “When channels are not enough”Channels excel at transferring ownership of data between goroutines. But sometimes you simply need to protect a shared value that many goroutines read and write — a cache, a counter, a connection pool. For these cases the sync package provides lower-level primitives.
sync.Mutex
Section titled “sync.Mutex”A sync.Mutex is a mutual exclusion lock. Only one goroutine can hold the lock at a time; all others block on Lock() until the holder calls Unlock().
var mu sync.Mutexvar count int
mu.Lock()count++mu.Unlock()Always use defer mu.Unlock() immediately after mu.Lock() so the lock is released even if the function panics or returns early.
Copying a mutex
Section titled “Copying a mutex”A sync.Mutex (and anything that embeds one) must never be copied after it is first used — a copy has its own independent lock state, so code that thinks it is locking the original is actually locking a lock nobody else looks at. This happens more easily than it sounds, because Go copies on assignment, on range, and on every plain function call:
type Counter struct { mu sync.Mutex n int}
func main() { counters := []Counter{{}, {}, {}} for _, c := range counters { // c is a copy of each Counter, including its mutex c.mu.Lock() c.n++ c.mu.Unlock() }}go vet catches exactly this on Go 1.26.5:
main.go:12:9: range var c copies lock: command-line-arguments.Counter contains sync.MutexThe fix is always to range by index or pointer instead — for i := range counters { counters[i].mu.Lock() ... } — so every iteration locks the one shared mutex rather than a fresh copy of it. The Methods lesson covers the same trap for a value-receiver method on a mutex-holding struct; this is the loop-shaped version of the identical bug.
The memory model and happens-before
Section titled “The memory model and happens-before”The Go memory model defines exactly when a write in one goroutine is guaranteed to be visible to a read in another — and, critically, an ordinary variable read or write that is not connected to the other goroutine by a mutex, channel, or atomic operation has no such guarantee, even if the write “obviously” happens first in wall-clock time. The compiler and CPU are both free to reorder or cache plain memory accesses as long as the single goroutine that issued them still sees a consistent result; nothing forces that result to become visible to any other goroutine on any particular schedule.
This is exactly why the race detector treats a plain boolean flag used for signalling as a bug, even when the program “seems to work” in casual testing:
done := falsego func() { time.Sleep(10 * time.Millisecond) done = true // plain write, no synchronization}()for !done { // plain read, no synchronization}fmt.Println("done")WARNING: DATA RACEWrite at 0x00c00019c03f by goroutine 7: main.main.func1() /tmp/boolflag/main.go:12 +0x38
Previous read at 0x00c00019c03f by main goroutine: main.main() /tmp/boolflag/main.go:14 +0xa0...Found 1 data race(s)Both the read and the write compile and, on most machines, most of the time, appear to work — which is precisely the danger. A sync.Mutex, a channel operation, or a sync/atomic type all establish a real happens-before edge; a bare bool, int, or pointer read/write never does, no matter how it looks in the source.
sync.RWMutex
Section titled “sync.RWMutex”sync.RWMutex is an optimized variant for the read-heavy case. Multiple goroutines can hold a read lock simultaneously with RLock/RUnlock. A write lock (Lock/Unlock) is exclusive — it waits for all readers to finish, then blocks new readers until the writer is done.
Use it when reads are far more frequent than writes (e.g., an in-memory cache).
flowchart LR Unlocked --> ReadLocked["read-locked (N readers)"] Unlocked --> WriteLocked["write-locked (1 writer)"] ReadLocked -->|"another RLock()"| ReadLocked ReadLocked -->|"last RUnlock(), no writer waiting"| Unlocked WriteLocked -->|Unlock| Unlocked ReadLocked -.->|"Lock() called: writer queues and blocks"| WriterWaiting["writer waiting"] WriterWaiting -.->|"all current readers RUnlock"| WriteLocked WriterWaiting -.->|"new RLock() while writer waits: blocks too"| WriterWaiting
The recursive-RLock deadlock
Section titled “The recursive-RLock deadlock”Go’s RWMutex gives a waiting writer priority: once a goroutine calls Lock(), every new RLock() call — even one made by a goroutine that already holds a read lock elsewhere — blocks until that writer has acquired and released the lock. This makes recursive read-locking from the same goroutine genuinely dangerous, not just wasteful:
var mu sync.RWMutex
mu.RLock()fmt.Println("main: acquired first RLock")
go func() { time.Sleep(50 * time.Millisecond) mu.Lock() // queues behind main's held RLock, and blocks new readers defer mu.Unlock()}()
time.Sleep(100 * time.Millisecond) // let the writer start waitingmu.RLock() // blocks forever -- a writer is already queuedfatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.RWMutex.RLock]:sync.(*RWMutex).RLock(0xc000120018) /tmp/rwrecursive/main.go:25 +0x50main.main() /tmp/rwrecursive/main.go:25 +0x154
goroutine 19 [sync.RWMutex.Lock]:sync.(*RWMutex).Lock(0xc000120018) /tmp/rwrecursive/main.go:18 +0x78main.main.func1() /tmp/rwrecursive/main.go:18 +0x8cThe goroutine holding the first RLock is waiting for its own second RLock to succeed; the writer it is queued behind cannot proceed until the first RLock is released; and nothing ever releases it. Never call RLock a second time on the same RWMutex from a goroutine that might already hold one — including indirectly, through a function call several layers deep.
sync.Once
Section titled “sync.Once”sync.Once executes a function exactly once, regardless of how many goroutines call Do concurrently. The canonical use case is lazy, thread-safe initialization:
var once sync.Oncevar instance *Config
func getConfig() *Config { once.Do(func() { instance = loadConfig() }) return instance}sync.OnceValue and friends
Section titled “sync.OnceValue and friends”The once.Do pattern above needs a package-level variable to hold the result and a closure to capture it. Since Go 1.21, sync.OnceValue collapses both into a single call: it takes a function and returns a new function that runs the original exactly once and caches its return value, safe for any number of concurrent callers:
var loadConfig = sync.OnceValue(func() string { fmt.Println("loading config...") // printed exactly once, however many callers there are return "config-data"})sync.OnceFunc is the equivalent for a function with no return value, and sync.OnceValues handles a function that returns two values (typically a value and an error). All three also propagate a panic: if the wrapped function panics on its one real call, every subsequent call to the returned function panics with that same value instead of silently returning a zero value.
sync/atomic
Section titled “sync/atomic”The sync/atomic package provides lock-free operations on integer and pointer types using CPU hardware instructions. For simple counters or flags, atomics are faster than a mutex because there is no lock contention or context switching.
Since Go 1.19, prefer the typed API: a var hits atomic.Int64 gives you hits.Add(1), hits.Load(), hits.Store(n), and hits.CompareAndSwap(old, new) as methods, with no address-of operator to get wrong. The same family covers atomic.Bool and atomic.Pointer[T] for flags and lock-free pointer swaps. The older function form, atomic.AddInt64(&counter, 1), still works and is what you will read in existing code, but it requires the int64/uint64 it operates on to be 8-byte aligned — a real trap on 32-bit platforms where a struct field can end up misaligned. The typed API sidesteps that alignment requirement entirely, which is the main reason to reach for it in new code.
sync.WaitGroup.Go (Go 1.25+)
Section titled “sync.WaitGroup.Go (Go 1.25+)”WaitGroup is the primitive you reach for most often, and since Go 1.25 it has a shortcut: wg.Go(f) does Add(1), starts f in a new goroutine, and calls Done() when f returns.
var wg sync.WaitGroupfor _, url := range urls { wg.Go(func() { fetch(url) }) // per-iteration url since Go 1.22, no capture dance}wg.Wait()Reach for wg.Go in new code — it cannot get the Add placement wrong — and let go fix rewrite the old triple in existing code: the modernizer that does this was named waitgroup in Go 1.26 and renamed waitgroupgo in Go 1.27 (to avoid clashing with go vet’s own waitgroup analyzer, which checks for WaitGroup misuse rather than rewriting code).
Channels vs mutexes — a practical guide
Section titled “Channels vs mutexes — a practical guide”| Use channels when… | Use mutexes when… |
|---|---|
| Transferring ownership of data | Protecting a shared cache or struct |
| Coordinating goroutine lifecycles | Managing a simple counter or flag |
| Pipelines and fan-out patterns | Read-heavy data with sync.RWMutex |
sync.Map
Section titled “sync.Map”sync.Map is a specialized map safe for concurrent use without a separate Mutex — but the standard library documentation is explicit that most code should still use a plain map with its own lock, for better type safety and easier-to-maintain invariants. sync.Map earns its keep in exactly two shapes of workload: (1) a key is written once and then read many times, as in a cache that only grows, or (2) many goroutines each read, write, and overwrite a disjoint set of keys, so they rarely contend with each other. Outside those two patterns, a map protected by a sync.RWMutex is usually simpler to reason about and just as fast.
var cache sync.Mapcache.Store("key", 42)v, ok := cache.Load("key")cache.Range(func(k, v any) bool { // called for each entry; return false to stop early return true})Like a mutex, a sync.Map must never be copied after first use.
errgroup.SetLimit
Section titled “errgroup.SetLimit”The sync.WaitGroup + shared-error-variable pattern for running goroutines and collecting the first error is common enough that golang.org/x/sync/errgroup packages it up: g.Go(func() error { ... }) starts a goroutine, and g.Wait() returns the first non-nil error any of them returned (cancelling a group Context, if you created one with errgroup.WithContext, so sibling goroutines can stop early too). Since Go 1.20’s errgroup, SetLimit(n) bounds how many of those goroutines run at once — the same problem as the Goroutines lesson’s “unbounded spawning,” solved declaratively instead of with a hand-rolled semaphore channel:
var g errgroup.Groupg.SetLimit(2) // at most 2 goroutines run at once
for _, url := range urls { url := url g.Go(func() error { return fetch(url) // returns an error; SetLimit still applies })}
if err := g.Wait(); err != nil { log.Fatal(err)}errgroup is not in the standard library — it lives in the golang.org/x/sync module, so it needs a go get golang.org/x/sync/errgroup and cannot run in this page’s browser-based Playground, but it is the idiomatic choice in real Go services whenever “bounded parallel work, first error wins” is the shape of the problem.
Lock scope
Section titled “Lock scope”A lock should be held for the shortest span that keeps the protected data consistent — never across an I/O call, a channel send or receive, or any other operation that might block for a while. Holding a mutex across a slow operation turns every other goroutine that needs the same lock into a queue behind that one slow call, even though none of them touch the same I/O:
// Bad: the lock is held across a network callmu.Lock()defer mu.Unlock()resp, err := http.Get(url) // every other locker waits for the network here toocache[url] = resp
// Better: only the map access needs the lockresp, err := http.Get(url)mu.Lock()cache[url] = respmu.Unlock()The same reasoning applies to holding a lock while sending on a channel: if the receiver is slow or the channel is full, the lock stays held for exactly as long as the send blocks, even though sending has nothing to do with what the lock protects.
package main
import ( "fmt" "sync" "sync/atomic")
type SafeCounter struct { mu sync.Mutex v int}
func (c *SafeCounter) Inc() { c.mu.Lock() defer c.mu.Unlock() c.v++}
func (c *SafeCounter) Value() int { c.mu.Lock() defer c.mu.Unlock() return c.v}
func main() { // sync.Mutex: 100 goroutines each increment once -> 100 var wg sync.WaitGroup c := &SafeCounter{} for i := 0; i < 100; i++ { wg.Add(1) go func() { defer wg.Done() c.Inc() }() } wg.Wait() fmt.Println("counter:", c.Value()) // 100
// sync.Once: function runs exactly once despite 3 calls var once sync.Once for i := 0; i < 3; i++ { once.Do(func() { fmt.Println("only once") }) }
// sync/atomic: typed API, 50 goroutines each add 1 -> 50 var hits atomic.Int64 for i := 0; i < 50; i++ { wg.Add(1) go func() { defer wg.Done() hits.Add(1) }() } wg.Wait() fmt.Println("atomic count:", hits.Load()) // 50} The second runnable snippet below covers sync.OnceValue and sync.Map from earlier in this lesson.
package main
import ( "fmt" "sort" "sync")
// loadConfig computes its value at most once, no matter how many goroutines// call it concurrently -- sync.OnceValue handles the locking internally.var loadConfig = sync.OnceValue(func() string { fmt.Println("loading config...") // printed exactly once return "config-data"})
func main() { var wg sync.WaitGroup for i := 0; i < 3; i++ { wg.Add(1) go func() { defer wg.Done() _ = loadConfig() }() } wg.Wait() fmt.Println(loadConfig())
// sync.Map: safe for concurrent use without a separate Mutex, best suited // to the write-once-read-many or disjoint-keys access patterns. var cache sync.Map var wg2 sync.WaitGroup for i := 0; i < 5; i++ { wg2.Add(1) go func(i int) { defer wg2.Done() cache.Store(fmt.Sprintf("key%d", i), i*i) }(i) } wg2.Wait()
var entries []string cache.Range(func(k, v any) bool { entries = append(entries, fmt.Sprintf("%s=%v", k, v)) return true }) sort.Strings(entries) // Range order is unspecified, so sort for a stable print fmt.Println(entries)} Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| sync.Mutex | simple, fast for simple shared state | the goroutine that locks must always unlock |
| sync.RWMutex | readers run in parallel | RLock overhead exceeds Mutex when writes are frequent |
| sync.Once | thread-safe one-time init | cannot be reset |
| sync/atomic | fastest for a simple counter | limited to primitive types |
| channel | composable, safer | more overhead than a mutex |
Common Misconceptions
Section titled “Common Misconceptions”- A mutex is always slower than a channel — a mutex is faster than a channel for protecting a simple counter
sync.Mapalways replacesmap+ mutex —sync.Mapfits read-heavy, append-only access patterns, not a general-purpose map- The typed
atomic.Int64and the oldatomic.AddInt64are identical — the old function form requires the variable it operates on to be 8-byte aligned yourself (a real trap on 32-bit platforms); the typed API (atomic.Int64,atomic.Bool,atomic.Pointer[T]) handles alignment automatically, which is why new code should always prefer it defer mu.Unlock()is slower than an explicit unlock —deferhas negligible overhead; use it for safety every time
💡 Real-world example
Prometheus client_golang increments its counter metric lock-free with
atomic.AddUint64inprometheus/counter.go— a hot path called on every metric update, so it avoids lock contention with an atomic instead of a mutex.
Reading AI-generated code
Section titled “Reading AI-generated code”When a generated snippet uses a lock or another sync primitive, check for these before trusting it:
- Is a struct containing a
sync.Mutex,sync.RWMutex, orsync.WaitGroupever copied — byrange, by assignment, by being passed or returned by value, or by a value-receiver method? - Does a goroutine call
RLocka second time while it might already hold one — directly, or through a function call several layers deep? That deadlocks the moment any goroutine is waiting onLock. - Is a plain
bool,int, or pointer used as a signal between goroutines instead of a channel, mutex, orsync/atomictype? It compiles, often “works” in testing, and has no happens-before guarantee at all. - Is a lock held across a network call, a channel operation, or any other call that might block — instead of being released before that call and reacquired only for the shared-state access?
- Would
sync.Mapbe a better fit here than amapguarded by aMutex, or is it being reached for out of habit on a workload that is not write-once-read-many or disjoint-keys?
🐛 Spot the bug
type Counter struct { mu sync.Mutex n int}
func main() { counters := []Counter{{}, {}, {}} for _, c := range counters { c.mu.Lock() c.n++ c.mu.Unlock() }}Show the answer
range copies each element into c on every iteration — including its sync.Mutex — so every Lock/Unlock pair in the loop operates on a throwaway copy of the mutex, and c.n++ never touches the real Counter in the slice at all. go vet catches it on Go 1.26.5:
main.go:12:9: range var c copies lock: command-line-arguments.Counter contains sync.MutexThe fix is to index instead of copying: for i := range counters { counters[i].mu.Lock(); counters[i].n++; counters[i].mu.Unlock() }, or range over *Counter if the slice holds pointers to begin with.