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.

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

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

Key operations: atomic.AddInt64, atomic.LoadInt64, atomic.StoreInt64, atomic.CompareAndSwapInt64.

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
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: 50 goroutines each add 1 -> 50
var atomicCount int64
for i := 0; i < 50; i++ {
wg.Add(1)
go func() {
defer wg.Done()
atomic.AddInt64(&atomicCount, 1)
}()
}
wg.Wait()
fmt.Println("atomic count:", atomic.LoadInt64(&atomicCount)) // 50
}
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?