Files
teamai-test/.teamai/skills/common/golang-concurrency/references/sync-primitives.md
T

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() before Put() — returning dirty objects causes bugs
  • Do not assume an object from Get() is zeroed — the New func 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.Go is Go 1.25+, not Go 1.24.
  • The function passed to wg.Go must not panic.
  • WaitGroup does 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/Done bookkeeping
  • Lower risk of Add/Wait ordering 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.