Skip to main content

Mutexes vs Channels

Quick rules of thumb

  • Use channels when you want handoff + backpressure + cancellation with simple FIFO semantics and worker pools.
  • Use a mutex when you need a mutable shared structure (e.g., priority queue, dedup set, delayed/timed tasks) or random access beyond FIFO.
  • Start with channels; switch to mutex + condition only if features require it.

When channels are best

Characteristics • FIFO queue, push → process by N workers. • Natural backpressure via buffered size. • Easy shutdown with close(ch) and cancellation via context.Context. • Great for fan-out/fan-in patterns.

Worker pool (bounded queue, graceful stop)

type Task func(context.Context) error

func Run(ctx context.Context, workers, buffer int, produce func(chan<- Task)) error {
tasks := make(chan Task, buffer)

// producers
go func() {
defer close(tasks)
produce(tasks) // send tasks, returns when done
}()

// consumers
var wg sync.WaitGroup
wg.Add(workers)
errs := make(chan error, workers)

for i := 0; i < workers; i++ {
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case t, ok := <-tasks:
if !ok { return }
if err := t(ctx); err != nil {
select { case errs <- err: default: } // non-blocking report
}
}
}
}()
}

wg.Wait()
close(errs)
// drain/aggregate errs as you like
return nil
}

Use this if: FIFO is fine, you want bounded memory, and clean cancellation.

When mutexes are better

Characteristics

  • You need more than FIFO: priority, delays, retries with requeue, dedup, peek, remove, size stats, batching, or multiple producers/consumers with shared state.
  • Channel alone can't express priority or timed scheduling without extra goroutines.

Priority queue with condition variable

type item struct {
at time.Time // schedule time
prio int // smaller = higher priority
run Task
}

type pq struct {
mu sync.Mutex
cv *sync.Cond
h []*item // use container/heap
die bool
}

func newPQ() *pq {
p := &pq{}
p.cv = sync.NewCond(&p.mu)
return p
}

func (p *pq) Push(it *item) {
p.mu.Lock()
heap.Push((*heapImpl)(&p.h), it)
p.cv.Signal()
p.mu.Unlock()
}

func (p *pq) PopBlocking(ctx context.Context) *item {
p.mu.Lock()
defer p.mu.Unlock()
for {
if p.die { return nil }
if len(p.h) > 0 {
it := p.h[0]
now := time.Now()
if it.at.After(now) {
// timed wait until schedule time or wakeup
wait := time.Until(it.at)
timer := time.NewTimer(wait)
p.mu.Unlock()
select {
case <-ctx.Done():
if !timer.Stop() { <-timer.C }
p.mu.Lock()
return nil
case <-timer.C:
p.mu.Lock()
continue
}
}
return heap.Pop((*heapImpl)(&p.h)).(*item)
}
// nothing available; wait or exit
// optional: also watch ctx.Done() using a helper goroutine to Signal
p.cv.Wait()
}
}

Use this if: You need priority and/or scheduled execution; channels don't provide that natively.

Mixed approach

Use channels for worker handoff, mutex-protected structure for queue logic.

  • A single "dispatcher" goroutine owns the complex queue under a mutex (or even no mutex if it's the only owner).
  • It pops according to your rules (priority/delay/retry/dedup) and sends tasks to a worker channel.
  • Workers stay the same, keeping cancellation/backpressure.

This keeps the complexity localized and workers simple.

Specific guidance by requirement

RequirementPreferWhy
Simple FIFO + worker poolChannelsBuilt-in backpressure, easy close/cancel
Bounded memoryChannelsmake(chan T, N) bounds queue
Priority schedulingMutex + heapChannels are FIFO only
Delayed/cron-like tasksMutex + timers or dispatcher + time.TimerTimed ordering needs state
Dedup / idempotencyMutex + mapNeed set membership / mutation
Random removal / requeueMutexRequires indexable structure
Per-task deadlines/retriesMutex + state or dispatcher + channelNeeds metadata & control
Fan-in/fan-out pipelinesChannelsNatural with select
Observability (peek/size/export)MutexQuery/inspect shared state

Common pitfalls

  • Unbounded channels (via never-closing producers) → memory blowups.
  • Leaking goroutines waiting on sends/receives after consumers exit.
  • Mutex deadlocks from lock ordering; keep critical sections tiny.
  • Busy loops when trying to emulate timers/priority with channels—use a dispatcher or time.Timer.

Summary

  • Channels: great for handoff pipelines and worker pools—simple, robust, idiomatic.
  • Mutexes (often with a condition variable and a heap/map): great for feature-rich queues (priority, delays, dedup, random access).
  • Don't be afraid to combine them: a dispatcher (mutex) feeding workers (channels) is a high-signal pattern.