8.1 KiB
Sync Primitives Deep Dive
sync.Mutex
Protects shared state with exclusive access. MUST hold the lock for the shortest time possible — NEVER hold a mutex across I/O, network calls, or channel operations.
type SafeCache struct {
mu sync.Mutex
items map[string]string
}
func (c *SafeCache) Get(key string) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
v, ok := c.items[key]
return v, ok
}
func (c *SafeCache) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = value
}
Embedding Convention
Embed the mutex as an unexported field, placed directly above the fields it protects:
type Registry struct {
mu sync.Mutex // protects entries
entries map[string]Entry
}
sync.RWMutex
SHOULD be used when reads greatly outnumber writes. Multiple goroutines can hold RLock simultaneously; Lock is exclusive.
type Config struct {
mu sync.RWMutex
values map[string]string
}
func (c *Config) Get(key string) string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.values[key]
}
func (c *Config) Set(key, value string) {
c.mu.Lock()
defer c.mu.Unlock()
c.values[key] = value
}
Pitfall: Do not upgrade RLock to Lock — this deadlocks. Release RLock first, then acquire Lock.
sync/atomic
Lock-free operations for simple values. SHOULD be preferred over Mutex for simple counter operations. Faster than mutex for low-contention counters and flags.
// ✓ Good — atomic for a simple counter
var requestCount atomic.Int64
func handleRequest() {
requestCount.Add(1)
}
func getCount() int64 {
return requestCount.Load()
}
// ✓ Good — atomic.Bool for a shutdown flag
var shuttingDown atomic.Bool
func shutdown() {
shuttingDown.Store(true)
}
func isRunning() bool {
return !shuttingDown.Load()
}
Go 1.19+ provides typed atomics (atomic.Int64, atomic.Bool, atomic.Pointer[T]) — prefer these over raw atomic.AddInt64/atomic.LoadInt64.
sync.Map
SHOULD only be used for write-once/read-many patterns. Optimized for two common patterns: (1) keys are written once and read many times, (2) multiple goroutines read/write disjoint key sets. For other patterns, a plain map + sync.RWMutex is faster.
var cache sync.Map
func Get(key string) (any, bool) {
return cache.Load(key)
}
func Set(key string, value any) {
cache.Store(key, value)
}
func GetOrSet(key string, compute func() any) any {
if v, ok := cache.Load(key); ok {
return v
}
v, _ := cache.LoadOrStore(key, compute())
return v
}
When NOT to use sync.Map: when you need to iterate, get the length, or when writes are frequent and keys overlap heavily. Use sync.RWMutex + map instead.
sync.Pool
Reuse temporary objects to reduce GC pressure. MUST NOT store pointers to stack-allocated objects. Objects in the pool may be reclaimed at any GC cycle — do not store persistent state.
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func process(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
buf.Write(data)
// ... transform ...
return buf.String()
}
Rules:
- Always
Reset()beforePut()— returning dirty objects causes bugs - Do not assume an object from
Get()is zeroed — theNewfunc only runs if the pool is empty - Best for short-lived, frequently allocated objects (buffers, encoders, temporary structs)
sync.Once
MUST be used for one-time initialization. Execute exactly once, regardless of how many goroutines call it concurrently. Thread-safe by design.
type DBClient struct {
initOnce sync.Once
closeOnce sync.Once
conn *sql.DB
}
func (c *DBClient) getConn() *sql.DB {
c.initOnce.Do(func() {
var err error
c.conn, err = sql.Open("postgres", dsn)
if err != nil {
panic(fmt.Sprintf("db init: %v", err))
}
})
return c.conn
}
func (c *DBClient) Close() error {
var err error
c.closeOnce.Do(func() {
err = c.conn.Close()
})
return err
}
Go 1.21+ also provides sync.OnceFunc, sync.OnceValue, and sync.OnceValues for simpler use cases:
var loadConfig = sync.OnceValue(func() *Config {
cfg, err := parseConfig("config.yaml")
if err != nil {
panic(err)
}
return cfg
})
// Usage: cfg := loadConfig()
sync.WaitGroup
Use sync.WaitGroup when you only need to wait for a set of goroutines to finish.
Go 1.25+: wg.Go
WaitGroup.Go starts a goroutine, adds it to the group, and removes it from the group when the function returns.
func processAll(items []Item) {
var wg sync.WaitGroup
for _, item := range items {
// Go 1.22+ loop variables are per-iteration when the module has `go 1.22+`.
// Do not add `item := item` solely for closure capture in modern modules.
wg.Go(func() {
process(item)
})
}
wg.Wait()
}
Rules:
WaitGroup.Gois Go 1.25+, not Go 1.24.- The function passed to
wg.Gomust not panic. WaitGroupdoes not propagate errors and does not cancel siblings.- For first-error-wins, cancellation, concurrency limits, or returned values, use
golang.org/x/sync/errgroup.
Benefits of wg.Go():
- No manual
Add/Donebookkeeping - Lower risk of
Add/Waitordering bugs - Cleaner API for simple fire-and-wait work
When to use: Go 1.25+ projects for simple goroutines that must all finish, do not return errors, do not need cancellation, and must not panic. Use errgroup when work returns errors, needs cancellation, limits, or first-error behavior.
Go <1.25 fallback
func processAll(ctx context.Context, items []Item) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1) // Add BEFORE go
go func(item Item) {
defer wg.Done()
process(ctx, item)
}(item)
}
wg.Wait() // blocks until all goroutines finish
}
// ✗ Bad — Add inside the goroutine (race: Wait may return before Add runs)
go func() {
wg.Add(1)
defer wg.Done()
process(item)
}()
golang.org/x/sync/singleflight
Deduplicates concurrent calls for the same key. When multiple goroutines request the same resource simultaneously, only one executes; the rest wait and share the result.
var group singleflight.Group
func GetUser(ctx context.Context, id string) (*User, error) {
v, err, _ := group.Do(id, func() (any, error) {
// Only one goroutine executes this for a given id
return db.QueryUser(ctx, id)
})
if err != nil {
return nil, err
}
return v.(*User), nil
}
Use cases: cache stampede prevention, deduplicating expensive lookups (DB, API), rate-limited external service calls.
golang.org/x/sync/errgroup
Goroutine group with error propagation. Returns the first error from any goroutine. With WithContext, cancels remaining goroutines on first error.
func fetchAll(ctx context.Context, urls []string) ([]Response, error) {
g, ctx := errgroup.WithContext(ctx) // cancel siblings on first error
results := make([]Response, len(urls))
for i, url := range urls {
g.Go(func() error {
resp, err := fetch(ctx, url)
if err != nil {
return fmt.Errorf("fetching %s: %w", url, err)
}
results[i] = resp // safe: each goroutine writes to its own index
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
Bounded Concurrency with SetLimit
SHOULD use SetLimit to bound concurrency and avoid unbounded goroutine spawning.
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // at most 10 goroutines run concurrently
for _, task := range tasks {
g.Go(func() error {
return process(ctx, task)
})
}
return g.Wait()
This replaces hand-rolled worker pools for most use cases.
→ See samber/cc-skills-golang@golang-concurrency skill for high-level patterns and decision trees.