Go Concurrency Patterns: Channels vs. Mutexes in High-Performance Applications
The advice “share memory by communicating” has done more damage than good. It was written for clarity, not throughput, and teams have shipped channel-based counters and caches that run 5-10x slower than a mutex, then concluded Go was slow. The inverse is also true: I have reviewed code where a mutex guarded what was really a data-flow problem, and the design fight disappeared the moment it became a channel. Channels and mutexes are not rivals; they solve different problems. Channels move data and ownership between goroutines. Mutexes and atomics protect state that lives in one place. The engineering task is knowing which of those you actually have.
What the microbenchmarks say
On Apple Silicon with Go 1.24, uncontended:
| Pattern | ns/op |
|---|---|
atomic.Int64.Add(1) |
~1.5 |
sync.Mutex Lock/Unlock, uncontended |
~25 |
| buffered channel (cap 1) send+recv pair | ~60 |
| unbuffered channel handoff | ~100 |
Under contention the picture changes, and that is where everyone picks the wrong tool. A mutex hammered from eight goroutines incurs cache-line ping-pong: the locked line bounces between cores and cost climbs to 150-300ns per op. Atomics scale better but share the false-sharing trap – two hot atomic.Int64 values in the same cache line serialized each other and cost us nearly 4x until we padded them. Channels, meanwhile, do not degrade the same way because senders and receivers hand off rather than contend, but they pay channel overhead on every op and they are built for decoupling, not raw throughput.
The rule I actually use
- Mutable shared state (counters, caches, configuration): mutex or atomic. It is state; protect it. Channels here are ceremony that costs 2-4x.
- Work distribution, ownership transfer, backpressure: channels. A worker pool where a channel is the queue gives you buffering, cancellation via
close, and fan-out semantics that a mutex implements with fifteen lines of ad-hoc code. - Read-heavy shared structures:
sync.RWMutexor, better,atomic.Pointer(Go 1.19+) to swap an immutable snapshot. Copy-on-write beats reader-writer locks on every benchmark I have run when writes are rare.
The code
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
package syncbench
import (
"sync"
"sync/atomic"
)
type MutexCounter struct {
mu sync.Mutex
n int64
}
func (c *MutexCounter) Inc() {
c.mu.Lock()
c.n++
c.mu.Unlock()
}
type AtomicCounter struct {
n atomic.Int64
}
func (c *AtomicCounter) Inc() {
c.n.Add(1)
}
// Dispatch uses a channel for ownership transfer: producers hand work to a
// bounded pool, and the pool's capacity is the only concurrency control needed.
func Dispatch(jobs <-chan int, workers int) {
work := make(chan struct{}, workers)
for j := range jobs {
work <- struct{}{}
go func(j int) {
defer func() { <-work }()
_ = j
}(j)
}
}
The counter is the canonical case where mutex beats channel. But notice Dispatch: the channel is the data flow, and the buffered pool provides backpressure – producers block when workers are saturated – which no amount of mutexing reproduces cleanly. The two idioms coexist: a mutex inside a worker protecting a shared metrics map, channels between stages.
The war story
We had a rate limiter implemented as a token bucket behind a sync.Mutex. It worked, but the limiter sat in front of our most expensive upstream and every call serialized on it. Replacing the shared counter with atomic.Int64 for the token count and keeping the mutex only for refills (a rare operation) cut the limiter’s contribution from ~600ns to ~40ns and took the p99 of the call path down with it. Meanwhile, a separate team’s attempt to distribute work via a global mutex-protected queue collapsed under 40k msg/s; the same design with a channel of jobs and a worker pool handled 200k with a queue length that stayed flat. Same language, same problem domain, opposite tools.
Production lessons
- Default to the simplest correct thing: atomics for counters, mutex for genuinely shared state, channels for data flow.
- Do not enshrine one idiom. Review comments like “use a channel” or “use a mutex” without a reason are cargo culting.
- Watch cache-line contention: pad adjacent atomics, shard hot counters.
- Run
go test -raceon everything; both idioms hide data races that only show under load. - Benchmark the contended case. Uncontended numbers mislead exactly where production diverges.