IndexAdvanced Go PatternsPart 06

Synchronization Beyond Channels

errgroup, semaphores, singleflight, and the sync toolbox.

Apr 9, 20266 min readConcurrencyPart 06 of 11

"Don't communicate by sharing memory; share memory by communicating" is great advice — and also frequently the wrong tool. Channels are elegant for handing off data, but for protecting shared state, coordinating completion, or limiting concurrency, the sync package and a few golang.org/x/sync extensions are simpler, faster, and clearer.

Knowing which tool fits which job is the mark of someone who's written real concurrent Go. Here's the toolbox.

When channels are the wrong tool

A common anti-pattern is using a channel as a lock around shared state:

Go
// Overcomplicated: a channel pretending to be a mutex.
type Counter struct {
    ch chan int
}

If you're protecting a piece of shared state, a Mutex is more direct, faster, and easier to reason about. Use channels to pass ownership of data between goroutines; use the sync primitives to protect data accessed by several goroutines. That distinction resolves most "channel vs. mutex" debates.

sync.Mutex — protect shared state

The bread and butter. Lock, touch the data, unlock:

Go
type Counter struct {
    mu    sync.Mutex
    count int
}

func (c *Counter) Inc() {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.count++
}

func (c *Counter) Value() int {
    c.mu.Lock()
    defer c.mu.Unlock()
    return c.count
}

Conventions that prevent pain:

  • Put the mutex right above the fields it guards, and add a comment if the scope isn't obvious. The mutex and its data are a unit.
  • defer Unlock() immediately after Lock() so an early return or panic can't leave it held. (Drop the defer only in a measured hot path where it matters.)
  • Never copy a struct containing a Mutex. Pass *Counter, not Counter. go vet catches this; listen to it.

sync.RWMutex — many readers, occasional writers

When reads vastly outnumber writes, an RWMutex lets readers proceed concurrently while writers get exclusive access:

Go
type Cache struct {
    mu   sync.RWMutex
    data map[string]string
}

func (c *Cache) Get(key string) (string, bool) {
    c.mu.RLock()         // shared read lock
    defer c.mu.RUnlock()
    v, ok := c.data[key]
    return v, ok
}

func (c *Cache) Set(key, value string) {
    c.mu.Lock()          // exclusive write lock
    defer c.mu.Unlock()
    c.data[key] = value
}

Don't reach for RWMutex reflexively — it has more overhead than a plain Mutex, so it only wins when reads genuinely dominate and the critical section is more than trivial. Measure before assuming it's faster.

sync.WaitGroup — wait for a set of goroutines

Coordinate "do N things, then continue." You saw this in the concurrency post; the rules:

Go
var wg sync.WaitGroup
for _, url := range urls {
    wg.Add(1)              // Add BEFORE launching the goroutine
    go func(u string) {
        defer wg.Done()    // Done in the goroutine, via defer
        fetch(u)
    }(u)
}
wg.Wait()                  // blocks until the counter hits zero

Two classic mistakes: calling wg.Add inside the goroutine (a race — Wait might run before Add), and forgetting defer on Done (a panic skips it and Wait hangs forever).

sync.Once — exactly once

Lazy initialization that's safe under concurrency, without a guard mutex:

Go
type Service struct {
    once   sync.Once
    client *http.Client
}

func (s *Service) Client() *http.Client {
    s.once.Do(func() {
        s.client = &http.Client{Timeout: 10 * time.Second}
    })
    return s.client
}

Do runs its function once, ever, even if called from many goroutines at once; the rest block until the first completes. Cleaner than a hand-rolled double-checked lock.

errgroup — WaitGroup that handles errors and cancellation

golang.org/x/sync/errgroup is the upgrade you'll reach for constantly. It's a WaitGroup that collects the first error and ties into a context: if any goroutine fails, the shared context is cancelled so the others can bail.

Go
import "golang.org/x/sync/errgroup"

func fetchAll(ctx context.Context, urls []string) ([][]byte, error) {
    g, ctx := errgroup.WithContext(ctx)
    results := make([][]byte, len(urls))

    for i, url := range urls {
        i, url := i, url // capture (pre-1.22; harmless after)
        g.Go(func() error {
            data, err := fetch(ctx, url)
            if err != nil {
                return err // cancels ctx, signaling the others to stop
            }
            results[i] = data // safe: each goroutine writes a distinct index
            return nil
        })
    }

    if err := g.Wait(); err != nil {
        return nil, err // the first error any goroutine returned
    }
    return results, nil
}

Note there's no mutex around results: each goroutine writes a different index, so there's no shared access to protect. That's a clean way to gather results in parallel.

errgroup also caps concurrency, turning it into a tidy bounded worker pool:

Go
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(10) // at most 10 running at once
for _, job := range jobs {
    job := job
    g.Go(func() error { return process(ctx, job) })
}
err := g.Wait()

For most "run these N tasks concurrently, stop on first error, cap the parallelism" needs, errgroup beats a hand-built channel-and-WaitGroup pool.

Semaphores — limit concurrency without a pool

When you want to bound concurrency but keep your existing goroutine structure, a weighted semaphore (golang.org/x/sync/semaphore) gates access to a resource:

semaphore.NewWeighted(3) — three slots, the rest wait waiting tasks task 4 task 5 task 6 Acquire(1) 3 slots held = 3 · full Release(1) go run running now task 1 · process task 2 · process task 3 · process
The semaphore caps in-flight work at the weight — finishing a task releases a slot the next can acquire
Go
import "golang.org/x/sync/semaphore"

sem := semaphore.NewWeighted(3) // 3 slots

for _, task := range tasks {
    if err := sem.Acquire(ctx, 1); err != nil {
        break // context cancelled
    }
    go func(t Task) {
        defer sem.Release(1)
        process(t)
    }(task)
}

A buffered channel can serve as a simple counting semaphore too (make(chan struct{}, 3)), but the semaphore package handles context cancellation and weighted acquisition for you.

singleflight — collapse duplicate work

A gem for caches and hot paths. golang.org/x/sync/singleflight ensures that concurrent calls for the same key result in only one actual execution; everyone else waits and shares the result. Perfect for preventing a "cache stampede" when a popular key expires and a thousand requests all try to recompute it at once:

group.Do("user:42") — one execution, shared result caller A caller B caller C one callloadUserFromDB database same (*User, err) returned to every caller
Duplicate concurrent calls for one key collapse to a single execution — no stampede on the database
Go
import "golang.org/x/sync/singleflight"

var group singleflight.Group

func getUser(ctx context.Context, id string) (*User, error) {
    v, err, _ := group.Do(id, func() (any, error) {
        return loadUserFromDB(ctx, id) // runs once per id, even under a stampede
    })
    if err != nil {
        return nil, err
    }
    return v.(*User), nil
}

Atomics — lock-free counters and flags

For simple scalar operations — counters, flags — sync/atomic (with the typed wrappers) avoids locking entirely:

Go
var requests atomic.Int64

func handle() {
    requests.Add(1)
}

func count() int64 {
    return requests.Load()
}

The typed atomics (atomic.Int64, atomic.Bool, atomic.Pointer[T]) are clearer and harder to misuse than the older free functions. Reach for atomics only for genuinely simple shared scalars; anything involving multiple related fields wants a mutex so the updates stay consistent together.

Choosing the right tool

Code
   need…                                  reach for…
   ─────────────────────────────────────────────────────────────
   hand data between goroutines           a channel
   protect shared state                   sync.Mutex
   read-heavy shared state                sync.RWMutex
   wait for N goroutines                  sync.WaitGroup
   run-once init                          sync.Once
   N tasks, stop on error, cap concurrency errgroup (with SetLimit)
   bound concurrency, keep your structure  semaphore (or buffered chan)
   dedupe concurrent identical work        singleflight
   a shared counter or flag                sync/atomic

The takeaway

Channels are for handing off data; the sync family is for protecting it and coordinating goroutines. Use Mutex/RWMutex for shared state, WaitGroup to wait, Once for lazy init, atomics for simple counters — and lean on the x/sync extensions (errgroup, semaphore, singleflight) for the higher-level patterns. Picking the simplest primitive that fits, rather than forcing everything through channels, is what makes concurrent Go readable.

The takeaway

Channels hand off ownership; the sync family protects and coordinates. The skill is reaching for the smallest primitive that fits the job — not running every problem through a channel.