{} The Go Reference

Building blocks · Concurrency · Intermediate

The sync Package

Mutex, RWMutex, WaitGroup, Once, Cond and Pool — the lower-level primitives for guarding shared state, plus the copylocks rule and mutex-vs-channel guidance.

Building blocks Intermediate ⏱ 12 min read Complete

🔑 Analogy

If channels are conveyor belts moving parts between workers, the sync package is the set of locks on the shared tool cabinet. When several workers reach into the same drawer (a struct’s fields), a lock makes sure only one hand is inside at a time. Different job, different tool — and most programs use both.

Mental model: protect the data, not the code

The sync package exists for one reason: when two goroutines touch the same memory and at least one writes, you have a data race — undefined behavior, not just a wrong number. The primitives here give a goroutine exclusive (or coordinated) access to a region of memory for a stretch of time.

The crucial mental shift: a mutex doesn’t protect code, it protects data. The lock is just a convention — “I will only touch field n while holding mu.” Nothing physically stops another goroutine from reading n without the lock; the guarantee only holds if every access agrees to take the lock first. So the right design is to make the protected fields unexported and force all access through methods that lock. The lock and the data it guards should live right next to each other:

type Counter struct {
	mu sync.Mutex // guards everything below it
	n  int
}

Under the hood these are cheap. An uncontended sync.Mutex is essentially a single atomic compare-and-swap on a word of memory — no system call, no goroutine parking. Only when a lock is contended does the runtime get involved: the loser spins briefly, then parks the goroutine on a wait queue and hands the CPU to the scheduler. That’s why “lock for the shortest time possible” is the golden rule — a held lock under contention turns parallel work back into serial work.

Mutex — exclusive access

A sync.Mutex makes a critical section exclusive: between Lock() and Unlock(), exactly one goroutine runs that code. The same counter that raced earlier is now correct. Run it:

mutex-counter.go — editable & runnable
package main

import (
"fmt"
"sync"
)

// Counter guards its own state — atomicity scoped to the type.
type Counter struct {
mu sync.Mutex
n  int
}

func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // released even if the body panics
c.n++
}

func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.n
}

func main() {
c := &Counter{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		c.Inc()
	}()
}
wg.Wait()
fmt.Println("counter =", c.Value()) // always 1000
}

Three details that matter:

  • defer c.mu.Unlock() is idiomatic because it releases the lock on every exit path — including a panic or an early return deep in the function. Forgetting an Unlock deadlocks the next goroutine forever.
  • The zero value is ready to use. var mu sync.Mutex is a usable, unlocked mutex — never new or make one specially. Do not Unlock a mutex you didn’t Lock; it panics.
  • Methods take a pointer receiver (func (c *Counter)). A value receiver would copy the Counter, and its mutex, on every call — see the copylocks gotcha below.

RWMutex — many readers or one writer

When reads vastly outnumber writes, a plain mutex serializes readers that never conflict with each other. sync.RWMutex fixes that: any number of goroutines may hold the read lock (RLock/RUnlock) simultaneously, but the write lock (Lock/Unlock) is exclusive and excludes all readers. Classic use: a read-heavy cache.

rwmutex-cache.go — editable & runnable
package main

import (
"fmt"
"sync"
)

// Cache is read-heavy: many goroutines Get, a few Set.
// RWMutex lets all the readers run at once.
type Cache struct {
mu sync.RWMutex
m  map[string]int
}

func NewCache() *Cache { return &Cache{m: make(map[string]int)} }

func (c *Cache) Get(k string) (int, bool) {
c.mu.RLock() // many readers may hold RLock simultaneously
defer c.mu.RUnlock()
v, ok := c.m[k]
return v, ok
}

func (c *Cache) Set(k string, v int) {
c.mu.Lock() // a writer excludes every reader and every other writer
defer c.mu.Unlock()
c.m[k] = v
}

func main() {
c := NewCache()
c.Set("answer", 42)

var wg sync.WaitGroup
var total int64
var mu sync.Mutex
for i := 0; i < 1000; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		if v, ok := c.Get("answer"); ok {
			mu.Lock()
			total += int64(v)
			mu.Unlock()
		}
	}()
}
wg.Wait()
fmt.Println("sum of 1000 reads =", total) // always 42000
}

RWMutex is not free: it tracks a reader count and a writer-waiting flag, so each RLock/RUnlock does more work than a Mutex. It only wins when reads dominate and the critical section is non-trivial. For a tiny critical section (a single field read), a plain Mutex — or an atomic — is usually faster. Measure before assuming RWMutex is the upgrade.

⚠️ RWMutex is not re-entrant — and you can't upgrade

Go locks are not re-entrant: if a goroutine holds the lock and calls a method that tries to take it again, it deadlocks against itself. And there is no “upgrade an RLock to a Lock” — releasing the read lock and acquiring the write lock is two separate steps, with a gap where another writer can sneak in. Re-check your assumptions after re-acquiring. If you find yourself wanting re-entrancy, restructure so the locking and the work live in separate methods.

WaitGroup — the join point of fork-join

A WaitGroup waits for a collection of goroutines to finish. Add(n) registers n pending tasks, each goroutine calls Done() (which subtracts one) when it finishes, and Wait() blocks until the counter hits zero. You saw it in every example above. The one rule that bites people:

var wg sync.WaitGroup
for _, item := range items {
	wg.Add(1)           // ✅ Add BEFORE the go statement
	go func() {
		defer wg.Done() // ✅ Done via defer, so a panic still counts
		process(item)
	}()
}
wg.Wait()

Call Add before launching the goroutine, never inside it. If you Add from within the goroutine, Wait might run before that goroutine is scheduled — see the counter as already zero, and return early. (In Go 1.25 you can also write wg.Go(func(){ ... }), which fuses the Add(1)/go/defer Done() boilerplate into one call.)

Once — exactly-once initialization

sync.Once runs a function a single time, ever, no matter how many goroutines race to call it — the idiomatic lazy Singleton. Crucially, Do blocks every concurrent caller until the first invocation finishes, so they all see fully initialized state.

once-init.go — editable & runnable
package main

import (
"fmt"
"sync"
"sync/atomic"
)

type Config struct{ Greeting string }

var (
once   sync.Once
cfg    *Config
builds atomic.Int64 // counts how many times the init body actually ran
)

// loadConfig is called from many goroutines, but the body runs exactly once.
func loadConfig() *Config {
once.Do(func() {
	builds.Add(1)
	cfg = &Config{Greeting: "hello"}
})
return cfg
}

func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		_ = loadConfig() // all 1000 see the same instance
	}()
}
wg.Wait()
fmt.Println("init ran", builds.Load(), "time(s)") // always 1
fmt.Println("greeting =", loadConfig().Greeting)  // hello
}

Once is the right answer for lazy package-level setup (a compiled regexp, a database handle, a parsed template). It beats a Mutex-guarded if initialized because it captures the intent precisely and avoids the subtle “check, race, double-init” bug. Go 1.21 added sync.OnceFunc, sync.OnceValue, and sync.OnceValues — convenience wrappers that return a memoizing function, often cleaner than a bare Once plus a package variable.

Cond — wait for a condition (rare)

sync.Cond lets goroutines wait until some condition becomes true, woken by Signal (one waiter) or Broadcast (all). It’s the lowest-level primitive here and the one you’ll reach for least — a channel usually expresses “wait until X” more clearly. The non-obvious requirement: you hold the associated lock around Wait, and you must re-check the condition in a for loop because Wait can return spuriously and the condition may have changed by the time you re-acquire the lock:

c := sync.NewCond(&sync.Mutex{})

// waiter
c.L.Lock()
for !ready {       // ALWAYS a loop, never a bare if
	c.Wait()       // atomically unlocks, sleeps, re-locks on wake
}
// ... use the now-ready state ...
c.L.Unlock()

// signaler
c.L.Lock()
ready = true
c.L.Unlock()
c.Broadcast()      // wake everyone to re-check

If your problem is “wait for a single event,” a chan struct{} you close() is simpler and composes with select. Reach for Cond only when many goroutines wait on a frequently-changing shared predicate and you’ve measured channels to be a poor fit.

Pool — reuse allocations to cut GC pressure

sync.Pool is a free list of reusable objects. Get returns a pooled object (or calls New if the pool is empty); Put returns one for later reuse. Its whole purpose is to reduce allocation and garbage-collector pressure on hot paths that churn through short-lived buffers.

pool-buffers.go — editable & runnable
package main

import (
"bytes"
"fmt"
"sync"
)

// pool hands out reusable *bytes.Buffer values to cut allocations.
var pool = sync.Pool{
New: func() any { return new(bytes.Buffer) }, // called only when the pool is empty
}

func render(id int) int {
b := pool.Get().(*bytes.Buffer) // borrow
defer func() {
	b.Reset()   // wipe before returning — never hand back dirty state
	pool.Put(b) // return for reuse
}()
fmt.Fprintf(b, "item-%d", id)
return b.Len()
}

func main() {
var wg sync.WaitGroup
var total int64
var mu sync.Mutex
for i := 0; i < 1000; i++ {
	wg.Add(1)
	go func(id int) {
		defer wg.Done()
		n := render(id % 10) // ids 0..9 -> "item-0".."item-9", all length 6
		mu.Lock()
		total += int64(n)
		mu.Unlock()
	}(i)
}
wg.Wait()
fmt.Println("total bytes written =", total) // always 6000
}

Two non-negotiables with Pool: reset borrowed objects before reuse (a buffer still holds the previous caller’s bytes), and never assume an object survives — the GC may evict pooled objects at any time, so a Pool is a performance hint, not storage. The standard library uses it heavily (fmt, encoding/json, net/http all pool buffers). Reach for it only when a profile shows allocation as a real cost; an empty pool that constantly calls New is pure overhead.

Reference: the sync toolkit

TypeKey methodsUse it forNotes
MutexLock / Unlock / TryLockexclusive access to shared statezero value ready; not re-entrant
RWMutexRLock/RUnlock, Lock/Unlockread-heavy shared statewriter-preferring; heavier than Mutex
WaitGroupAdd / Done / Wait / Gofork-join: wait for N goroutinesAdd before go; don’t copy
OnceDoexactly-once lazy initsee also OnceFunc/OnceValue (1.21+)
CondWait / Signal / Broadcastwait on a changing predicaterare; always loop on Wait; hold L
PoolGet / Put / Newreuse allocations, cut GCobjects may vanish; reset on reuse
atomicLoad/Store/Add/CompareAndSwapone lock-free worda counter or flag, not an invariant

Mutex or channel?

The defining slogan of Go concurrency: a mutex guards state; a channel transfers ownership. If a goroutine needs to safely touch fields it shares with others, a mutex is the cleanest tool. If you’re handing a piece of work from one goroutine to another — so that only one owns it at a time — a channel encodes that handoff directly and the “lock” disappears.

graph TD
Q{"What are you doing?"}
Q -->|"Guarding a struct's<br/>internal state"| M["Mutex / RWMutex"]
Q -->|"Transferring ownership<br/>or coordinating goroutines"| C["Channel"]
Q -->|"Tight, hot critical section"| M
Q -->|"Building a pipeline / fan-out"| C

🐹 The rules that keep sync safe

Don’t copy a Mutex, WaitGroup, Once, Cond, or Pool after first use — share by pointer (go vet’s copylocks check flags copies, which is why these types embed a noCopy marker). The trap is silent: passing a struct-with-a-mutex by value, ranging for _, x := range slice over structs, or returning one by value each makes a fresh, independent lock that guards nothing. Lock for the shortest time, and never do I/O, sleep, or call into unknown code while holding a lock — that’s how contention and deadlock start. And Add to a WaitGroup before the go statement, never inside the goroutine.

✅ Go's guidance: match the tool to the job

Go’s own advice is to prefer channels when they fit the problem, and reach for sync when it’s clearly simpler. Mutexes aren’t a code smell — for protecting a type’s own fields they’re often the cleanest, fastest choice. Build correctness with the memory model in mind (a Mutex.Unlock happens-before the next Lock, which is what publishes your writes to the next goroutine), and verify with go test -race.

Next: lock-free reads and writes of a single value — sync/atomic.

When to use it — and when not

✅ Reach for it when

  • You're guarding a struct's own internal state (a counter, a cache, a map).
  • You need a simple join (WaitGroup) or one-time init (Once).
  • A tight, performance-critical critical section where channel overhead would hurt.

⛔ Think twice when

  • You're transferring ownership of data or coordinating a pipeline — use channels.
  • You're tempted to build complex signalling with Cond — a channel is usually clearer.

Check your understanding

Score: 0 / 5

1. When should you prefer a Mutex over a channel?

Mutexes shine for protecting state local to a type. Channels shine for moving data and coordinating goroutines. The slogan: a mutex guards state, a channel transfers ownership.

2. What does sync.Once guarantee?

Once.Do runs its function a single time across the whole program, and every concurrent caller blocks until that run completes — so they all observe the fully initialized result. It's the idiomatic lazy Singleton.

3. Why does `go vet` flag copying a sync.Mutex (the copylocks check)?

A Mutex's guarantee comes from every goroutine contending on the SAME lock word. Copy it — by passing a struct by value, ranging over a slice of structs, or returning one — and you get two independent locks that guard nothing. vet's copylocks pass catches this; the fix is to share by pointer.

4. A goroutine holds an RWMutex.RLock and many readers are active. A writer calls Lock. What happens to a NEW reader that arrives?

Go's RWMutex blocks new RLock calls once a writer is waiting. Without that rule a steady stream of readers could starve the writer forever. It's why RWMutex only pays off when reads vastly outnumber writes — otherwise the bookkeeping costs more than a plain Mutex.

5. An object you Put back into a sync.Pool may be...

Pool makes no promises: the runtime can drop pooled objects during GC, and Get may return a brand-new object via New. You must reset borrowed objects yourself before reuse. Pool is purely an allocation-reduction optimization, never a place to stash state you need back.

Comments

Sign in with GitHub to join the discussion.