Skip to content

Sync Primitives

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.

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.Mutex
var 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.

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

The 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 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 := false
go func() {
time.Sleep(10 * time.Millisecond)
done = true // plain write, no synchronization
}()
for !done { // plain read, no synchronization
}
fmt.Println("done")
WARNING: DATA RACE
Write 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 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
RWMutex favors a waiting writer: once Lock() is queued, even a new RLock() from an existing reader blocks until the writer has run

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 waiting
mu.RLock() // blocks forever -- a writer is already queued
fatal error: all goroutines are asleep - deadlock!
goroutine 1 [sync.RWMutex.RLock]:
sync.(*RWMutex).RLock(0xc000120018)
/tmp/rwrecursive/main.go:25 +0x50
main.main()
/tmp/rwrecursive/main.go:25 +0x154
goroutine 19 [sync.RWMutex.Lock]:
sync.(*RWMutex).Lock(0xc000120018)
/tmp/rwrecursive/main.go:18 +0x78
main.main.func1()
/tmp/rwrecursive/main.go:18 +0x8c

The 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 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.Once
var instance *Config
func getConfig() *Config {
once.Do(func() {
instance = loadConfig()
})
return instance
}

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.

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.

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.WaitGroup
for _, 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).

Use channels when…Use mutexes when…
Transferring ownership of dataProtecting a shared cache or struct
Coordinating goroutine lifecyclesManaging a simple counter or flag
Pipelines and fan-out patternsRead-heavy data with sync.RWMutex

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

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.Group
g.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.

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 call
mu.Lock()
defer mu.Unlock()
resp, err := http.Get(url) // every other locker waits for the network here too
cache[url] = resp
// Better: only the map access needs the lock
resp, err := http.Get(url)
mu.Lock()
cache[url] = resp
mu.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)
}
What you getBenefitCost
sync.Mutexsimple, fast for simple shared statethe goroutine that locks must always unlock
sync.RWMutexreaders run in parallelRLock overhead exceeds Mutex when writes are frequent
sync.Oncethread-safe one-time initcannot be reset
sync/atomicfastest for a simple counterlimited to primitive types
channelcomposable, safermore overhead than a mutex
  • A mutex is always slower than a channel — a mutex is faster than a channel for protecting a simple counter
  • sync.Map always replaces map + mutex — sync.Map fits read-heavy, append-only access patterns, not a general-purpose map
  • The typed atomic.Int64 and the old atomic.AddInt64 are 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 — defer has negligible overhead; use it for safety every time

💡 Real-world example

Prometheus client_golang increments its counter metric lock-free with atomic.AddUint64 in prometheus/counter.go — a hot path called on every metric update, so it avoids lock contention with an atomic instead of a mutex.

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, or sync.WaitGroup ever copied — by range, by assignment, by being passed or returned by value, or by a value-receiver method?
  • Does a goroutine call RLock a 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 on Lock.
  • Is a plain bool, int, or pointer used as a signal between goroutines instead of a channel, mutex, or sync/atomic type? 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.Map be a better fit here than a map guarded by a Mutex, 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.Mutex

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

What is the difference between sync.Mutex and sync.RWMutex?
How many times will the function passed to sync.Once.Do execute, even if 100 goroutines call Do?
When should you prefer sync/atomic over sync.Mutex for a shared integer counter?
What does go test -race do?
What does go vet report, and what actually happens to counters?
type Counter struct {
    mu sync.Mutex
    n  int
}

func main() {
    counters := []Counter{{}, {}, {}}
    for _, c := range counters {
        c.mu.Lock()
        c.n++
        c.mu.Unlock()
    }
}
A goroutine already holding an RLock on a sync.RWMutex calls RLock() again while a different goroutine is blocked in Lock(). What happens?