The sync package provides the lower-level synchronization building blocks in Go. Channels handle communication between goroutines; the sync package handles protecting shared state. Both have their place — the Go proverb “share memory by communicating” is a preference, not an absolute rule. When you have shared data (a cache, a counter, a map) that multiple goroutines read and write, a mutex is often simpler and more efficient than routing all access through a channel.
This guide covers each primitive in the package, when to reach for it, and the subtle mistakes that cause races and deadlocks.
For context see Go goroutines, Go channels, and Go concurrency performance tuning.
sync.Mutex — Mutual Exclusion
A Mutex ensures only one goroutine executes a critical section at a time. All others block at Lock() until the holder calls Unlock().
Embed the mutex directly in the struct it protects — this makes it clear what the mutex guards:
type SafeCounter struct {
mu sync.Mutex
value int
}
func (c *SafeCounter) Increment() {
c.mu.Lock()
defer c.mu.Unlock() // always use defer — unlocks even on panic
c.value++
}
func (c *SafeCounter) Get() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
defer c.mu.Unlock() is the idiomatic pattern. If you call Unlock() explicitly, any early return or panic leaves the mutex locked forever.
Keep critical sections small. The mutex should protect only the memory access, not the surrounding computation. A common mistake is holding the lock during I/O or expensive work:
// ❌ Lock held during a network call — all other goroutines wait
func (c *Cache) fetchAndStore(key string) {
c.mu.Lock()
defer c.mu.Unlock()
result := http.Get(key) // slow — everyone else is blocked
c.data[key] = result
}
// ✅ Only lock during the map write
func (c *Cache) fetchAndStore(key string) {
result := http.Get(key) // outside the lock
c.mu.Lock()
c.data[key] = result
c.mu.Unlock()
}
Never copy a Mutex. Passing a struct containing a mutex by value copies the mutex’s internal state, which breaks it. Always pass a pointer: func process(c *Cache), not func process(c Cache). The go vet tool flags this.
sync.RWMutex — Optimizing Read-Heavy Workloads
An RWMutex distinguishes readers from writers. Multiple goroutines can hold a read lock simultaneously (RLock/RUnlock). A write lock (Lock/Unlock) is exclusive — it waits for all readers to release, then blocks new readers until the write completes.
Use RWMutex when reads dominate. A shared configuration object that’s written once at startup and read thousands of times per second is the ideal case:
type Config struct {
mu sync.RWMutex
data map[string]string
}
// Read: many goroutines can hold this simultaneously
func (c *Config) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.data[key]
}
// Write: exclusive — waits for all readers, blocks new ones
func (c *Config) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.data[key] = value
}
RWMutex is slower than Mutex for write-heavy workloads because of the extra bookkeeping. Profile before assuming it’s faster — if writes are frequent, a plain Mutex usually wins.
sync.WaitGroup — Waiting for a Group of Goroutines
WaitGroup is a counter that blocks Wait() until it reaches zero. It’s the standard way to wait for a dynamic number of goroutines to complete.
func processItems(items []string) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(s string) {
defer wg.Done()
process(s)
}(item)
}
wg.Wait() // blocks until all goroutines call Done()
}
Three rules to avoid subtle bugs:
- Call
wg.Add(n)before starting the goroutine, not inside it. If the goroutine runs and callsDone()beforeAddincrements the counter,Waitcan return too early. - Always use
defer wg.Done()to ensure the counter decrements even if the function panics. - Never copy a
WaitGroup. Pass a pointer.
When goroutines can fail and you need to collect errors, pair WaitGroup with a buffered error channel:
func runAll(tasks []Task) error {
var wg sync.WaitGroup
errc := make(chan error, len(tasks)) // buffered so goroutines don't block
for _, t := range tasks {
wg.Add(1)
go func(task Task) {
defer wg.Done()
if err := task.Run(); err != nil {
errc <- err
}
}(t)
}
wg.Wait()
close(errc)
for err := range errc {
return err // return first error
}
return nil
}
sync.Once — One-Time Initialization
Once guarantees that a function runs exactly once, regardless of how many goroutines call it concurrently. It’s the correct way to implement lazy singleton initialization:
type DB struct {
pool *sql.DB
}
var (
dbInstance *DB
dbOnce sync.Once
)
func GetDB() *DB {
dbOnce.Do(func() {
pool, err := sql.Open("postgres", os.Getenv("DATABASE_URL"))
if err != nil {
log.Fatalf("db init: %v", err)
}
dbInstance = &DB{pool: pool}
})
return dbInstance
}
Even if 100 goroutines call GetDB() simultaneously before initialization is complete, Do blocks all of them until the function finishes, then returns. Subsequent calls return instantly without executing the function again.
Note: if the function passed to Do panics, Once considers it done — future calls will not re-run the function. If your initialization can fail non-fatally, you need a different pattern (a mutex plus an initialized flag).
sync.Pool — Reducing Allocator Pressure
Pool is a cache of temporary objects that can be reused to reduce GC pressure. The key property: objects in the pool may be collected by the GC at any time — Pool does not guarantee retention. Use it for expensive-to-allocate objects that are short-lived and fungible (buffers, byte slices, structs used for one operation then discarded).
var bufPool = sync.Pool{
New: func() any {
return make([]byte, 0, 64*1024) // 64 KB initial capacity
},
}
func processRequest(r io.Reader) ([]byte, error) {
buf := bufPool.Get().([]byte)
defer func() {
buf = buf[:0] // reset length, keep capacity
bufPool.Put(buf) // return to pool
}()
return io.ReadAll(r) // in real code, read into buf
}
sync.Pool is how Go’s standard library achieves high throughput in packages like fmt and encoding/json — each fmt.Fprintf call borrows a buffer rather than allocating one. The New function is called when the pool is empty; returning the object to the pool with Put makes it available for the next borrower.
Reset the object before returning it — reset the slice length to zero, clear fields in a struct, etc. Returning a dirty object is a common source of subtle bugs.
sync.Cond — Condition Variables
Cond lets goroutines wait for a condition to become true and be woken when it changes. It’s less commonly needed than the other primitives — channels usually express the same pattern more clearly — but it’s the right tool when multiple goroutines need to be woken on a single state change.
// A bounded queue that blocks producers when full and consumers when empty
type BoundedQueue struct {
mu sync.Mutex
cond *sync.Cond
items []int
cap int
}
func NewBoundedQueue(cap int) *BoundedQueue {
q := &BoundedQueue{cap: cap}
q.cond = sync.NewCond(&q.mu)
return q
}
func (q *BoundedQueue) Push(v int) {
q.mu.Lock()
for len(q.items) == q.cap {
q.cond.Wait() // release lock, sleep, reacquire on wake
}
q.items = append(q.items, v)
q.cond.Broadcast() // wake all waiters (both producers and consumers)
q.mu.Unlock()
}
func (q *BoundedQueue) Pop() int {
q.mu.Lock()
for len(q.items) == 0 {
q.cond.Wait()
}
v := q.items[0]
q.items = q.items[1:]
q.cond.Broadcast()
q.mu.Unlock()
return v
}
Wait() atomically releases the mutex and suspends the goroutine. When another goroutine calls Signal() or Broadcast(), the waiting goroutine reacquires the mutex and returns from Wait(). The for loop re-checks the condition because spurious wakeups can occur and because multiple goroutines may wake simultaneously.
Detecting Races: The Race Detector
Go ships a built-in data race detector. Run any test or binary with -race to enable it:
go test -race ./...
go run -race main.go
The race detector instruments memory accesses at runtime. When two goroutines access the same variable concurrently and at least one is a write without synchronization, it prints a detailed report with goroutine stack traces. It has ~5–10x runtime overhead — appropriate for tests and staging, too slow for production.
Every concurrent Go program should pass go test -race. A race is a bug even if the program appears to produce correct output, because the Go memory model gives no guarantees about the visibility of unsynchronized writes.
Choosing the Right Primitive
| Situation | Use |
|---|---|
| Protecting a shared data structure | sync.Mutex |
| Read-heavy data structure, rare writes | sync.RWMutex |
| Wait for N goroutines to complete | sync.WaitGroup |
| One-time initialization | sync.Once |
| Reuse expensive temporary objects | sync.Pool |
| Wait for a condition to change | sync.Cond |
| Simple integer counter | sync/atomic |
| Communicating values between goroutines | chan |
Summary
- Embed
sync.Mutexin the struct it guards; alwaysdefer Unlock() - Use
sync.RWMutexfor read-heavy structures — profile first to confirm it helps sync.WaitGroup: callAddbeforego, alwaysdefer Done(); never copysync.Onceis the correct way to implement lazy singletons — it handles concurrent initialization safelysync.Poolreduces GC pressure for short-lived, reusable objects; reset before returning- Always run
go test -race— a data race is a bug even when output looks correct
Comments