Synchronization Beyond Channels
errgroup, semaphores, singleflight, and the sync toolbox.
"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:
// 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:
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 afterLock()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, notCounter.go vetcatches 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:
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:
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 zeroTwo 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:
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.
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:
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:
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:
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:
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
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/atomicThe 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.
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.