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
Requirement | Prefer | Why |
---|---|---|
Simple FIFO + worker pool | Channels | Built-in backpressure, easy close/cancel |
Bounded memory | Channels | make(chan T, N) bounds queue |
Priority scheduling | Mutex + heap | Channels are FIFO only |
Delayed/cron-like tasks | Mutex + timers or dispatcher + time.Timer | Timed ordering needs state |
Dedup / idempotency | Mutex + map | Need set membership / mutation |
Random removal / requeue | Mutex | Requires indexable structure |
Per-task deadlines/retries | Mutex + state or dispatcher + channel | Needs metadata & control |
Fan-in/fan-out pipelines | Channels | Natural with select |
Observability (peek/size/export) | Mutex | Query/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.