sync Package: Once, WaitGroup, Cond, Pool, Map
Beyond mutexes, sync provides Once (one-time init), WaitGroup (wait for N goroutines), Cond (wait/signal on a predicate), Pool (reusable object cache), and Map (lock-free read-mostly map). Each solves a specific shape; reaching for the wrong one leads to ugly code.
What it is
Beyond the basic Mutex and RWMutex, the sync package ships five other primitives that solve specific coordination shapes. Knowing which one fits saves rolling worse versions out of channels or mutexes.
sync.Once
Once.Do(func) runs func exactly once across all callers. Subsequent calls block until the first finishes (or return immediately if it already finished). Used for lazy initialisation: a database connection, a regex compilation, a config load.
This is the right tool for any "compute X once, share to everyone" pattern. Replaces double-checked locking, package-init ordering hacks, and ad-hoc atomic-bool patterns.
sync.WaitGroup
A counter. Add(n) increments, Done decrements, Wait blocks until it reaches zero. The classic fan-out wait pattern.
The rule: Add must happen before the goroutine starts (or before any caller could call Wait). Calling Add inside the goroutine races with Wait.
Go 1.25+ adds wg.Go(fn) which combines Add and goroutine launch in one call, removing the most common WaitGroup bug.
sync.Cond
Wait on a predicate while holding a lock. Wait releases the lock, sleeps, re-acquires on wake. Signal wakes one waiter; Broadcast wakes all.
Cond is the lowest-level coordination primitive. In Go, it can almost always be replaced with a channel and a select. Reach for Cond only when the predicate is complex enough that a channel-based version would be uglier.
sync.Pool
A per-P (per-processor) object cache. Get returns a pooled item or calls New. Put returns it. The pool may drop items at any GC; items must be reset before Put.
Use for: short-lived buffers, intermediate parser state, anything otherwise allocated per request. The win is reduced GC pressure on hot paths.
Don't use for: file handles, DB connections, network sockets, anything with finite resource semantics. Pool is best-effort; items can vanish.
sync.Map
A lock-free concurrent map, optimised for two specific cases: write-once-read-many (entries written once, then mostly read) and disjoint-key access (different goroutines touching different keys).
For everything else (mixed reads/writes on the same keys, write-heavy workloads, small key counts), plain map with sync.RWMutex is faster. The Go authors put a warning in the docs to this effect.
The API is awkward by design (Load, Store, LoadOrStore, Delete, Range) because it does not provide generics over key/value types. In Go 1.21+ there is a typed variant in some libraries, but the standard sync.Map remains untyped.
Choosing
| Need | Reach for |
|---|---|
| Run something exactly once | sync.Once |
| Wait for N goroutines to finish | sync.WaitGroup |
| Wait on a complex predicate | sync.Cond (or a channel) |
| Reuse short-lived objects | sync.Pool |
| Read-heavy concurrent map (specific shape) | sync.Map (or map+RWMutex) |
| Producer-consumer | channel |
| Mutual exclusion | sync.Mutex |
| One writer many readers | sync.RWMutex |
The default in Go is "use a channel". Reach for these primitives when channels do not fit the shape (like Once, or Pool, or shared state that is not naturally a stream).
Primitives by language
- sync.Once.Do (once-only initialisation)
- sync.WaitGroup.Add/Done/Wait (count down to zero)
- sync.Cond (wait on a predicate, NewCond(L sync.Locker))
- sync.Pool (per-P object pool)
- sync.Map (lock-free for read-heavy, append-mostly maps)
Implementation
Once.Do guarantees the function runs exactly once, even with thousands of goroutines racing on it. Subsequent callers wait for the first to finish, then return without re-running. Replaces double-checked locking.
1 package main
2
3 import (
4 "database/sql"
5 "sync"
6 )
7
8 var (
9 dbOnce sync.Once
10 db *sql.DB
11 )
12
13 func DB() *sql.DB {
14 dbOnce.Do(func() {
15 var err error
16 db, err = sql.Open("postgres", connStr)
17 if err != nil {
18 panic(err)
19 }
20 })
21 return db
22 }Add before launching, Done in a defer, Wait in the parent. The classic mistake is Add inside the goroutine: a race where Wait returns before any Add has been called. Always Add before go.
1 package main
2
3 import "sync"
4
5 func processAll(items []Item) {
6 var wg sync.WaitGroup
7 wg.Add(len(items)) // BEFORE the loop
8 for _, item := range items {
9 item := item // capture
10 go func() {
11 defer wg.Done()
12 process(item)
13 }()
14 }
15 wg.Wait() // blocks until all done
16 }
17
18 // Go 1.25+: simpler with WaitGroup.Go
19 func processAllNew(items []Item) {
20 var wg sync.WaitGroup
21 for _, item := range items {
22 item := item
23 wg.Go(func() { process(item) }) // Add and goroutine in one call
24 }
25 wg.Wait()
26 }Pool reduces allocator pressure for short-lived objects (buffers, parser state). Get returns a pooled item or calls New if the pool is empty; Put returns it. The pool can drop items at any GC, so do NOT use Pool for things that hold finite resources (file handles, DB connections).
1 package main
2
3 import (
4 "bytes"
5 "sync"
6 )
7
8 var bufPool = sync.Pool{
9 New: func() any { return new(bytes.Buffer) },
10 }
11
12 func format(data []byte) string {
13 buf := bufPool.Get().(*bytes.Buffer)
14 defer func() {
15 buf.Reset() // important: clear before return
16 bufPool.Put(buf)
17 }()
18 buf.WriteString("data: ")
19 buf.Write(data)
20 return buf.String()
21 }sync.Map is optimised for two cases: keys-once-written (entries added, rarely changed) and disjoint-key access (different goroutines touch different keys). For mixed read/write or contended keys, plain map + sync.RWMutex outperforms it. Profile.
1 package main
2
3 import "sync"
4
5 var sessions sync.Map // string -> *Session
6
7 func GetOrCreate(id string) *Session {
8 if v, ok := sessions.Load(id); ok {
9 return v.(*Session)
10 }
11 newSess := &Session{ID: id}
12 actual, _ := sessions.LoadOrStore(id, newSess)
13 return actual.(*Session) // returns existing if race lost
14 }
15
16 func Remove(id string) {
17 sessions.Delete(id)
18 }
19
20 func ListAll() []*Session {
21 var out []*Session
22 sessions.Range(func(_, v any) bool {
23 out = append(out, v.(*Session))
24 return true // continue
25 })
26 return out
27 }Cond exists for the case when wait/signal on a predicate is needed and channels do not express it well. Always wait inside a for-loop checking the predicate (spurious wakeups). In practice, Cond can almost always be replaced with a channel and a select.
1 package main
2
3 import "sync"
4
5 type BoundedQueue struct {
6 mu sync.Mutex
7 notFull *sync.Cond
8 notEmpty *sync.Cond
9 items []int
10 cap int
11 }
12
13 func New(cap int) *BoundedQueue {
14 q := &BoundedQueue{cap: cap}
15 q.notFull = sync.NewCond(&q.mu)
16 q.notEmpty = sync.NewCond(&q.mu)
17 return q
18 }
19
20 func (q *BoundedQueue) Put(x int) {
21 q.mu.Lock()
22 defer q.mu.Unlock()
23 for len(q.items) >= q.cap {
24 q.notFull.Wait() // releases mu, re-acquires on wake
25 }
26 q.items = append(q.items, x)
27 q.notEmpty.Signal()
28 }Key points
- •sync.Once.Do guarantees the function runs exactly once across all goroutines.
- •WaitGroup.Add(n) MUST happen before launching goroutines; Done in defer; Wait blocks until counter is 0.
- •sync.Cond is rarely the right tool in Go; channels usually express the same intent more clearly.
- •sync.Pool reduces GC pressure for short-lived objects but is NOT a connection pool: items can be reaped any time.
- •sync.Map wins for read-mostly disjoint-key workloads. For write-heavy or read-write-mixed, plain map + RWMutex is faster.
Follow-up questions
▸When to use sync.Map vs map + RWMutex?
▸Why is sync.Pool 'best-effort'?
▸What is the difference between Cond.Signal and Cond.Broadcast?
▸WaitGroup.Add inside the goroutine: why is that wrong?
Gotchas
- !WaitGroup.Add inside a goroutine races with Wait; Add before launching
- !sync.Pool items can be dropped at any GC; never use for finite resources
- !sync.Cond.Wait must be inside a for-loop on the predicate (spurious wakeups)
- !sync.Map cannot be ranged AND mutated safely; use Range carefully
- !Calling Signal/Broadcast without holding the Cond's Locker can cause missed wakeups