Mutex vs. Channels: Choosing the Right Tool for Go Concurrency
Not every concurrency problem requires a channel. Learn why overusing channels can degrade performance and when sharing memory with a Mutex is the superior engineering choice.
There’s this Go dogma everyone repeats like it’s gospel: “channels are more idiomatic than mutexes.”
The problem is, it’s become a cult. I’ve seen senior devs dodging sync.Mutex on principle alone, even when a simple lock would be way cleaner and more performant.
Rob Pike himself has made it clear in interviews: both have their place. But the dogma sticks, and I’ve seen projects fall apart because of it.
What I’ve seen in the wild
When it comes to protecting simple shared state, a mutex is just more straightforward.
// Mutex for shared state
type Counter struct {
mu sync.Mutex
value int
}
func (c *Counter) Increment() {
c.mu.Lock()
defer c.mu.Unlock()
c.value++
}
func (c *Counter) Value() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.value
}
Cleaner. More performant. Lower risk of deadlocks. The problem is, plenty of devs dodge them because they think it’s “unidiomatic.” That’s ideology talking, not engineering.
The hidden cost of channels
Channels aren’t free; they have overhead. Every channel involves memory allocation, and every send/receive comes with a synchronization cost. In a tight loop with thousands of iterations, that adds up. I’ve clocked a 3x to 5x throughput difference between channels and mutexes in simple state scenarios.
Unnecessary channel overhead
type BadCounter struct {
ch chan int
}
func (bc *BadCounter) Increment() {
bc.ch <- 1 // Allocation, sync overhead, and context switching
}
Mutex: more efficient
type GoodCounter struct {
mu sync.Mutex
val int
}
func (gc *GoodCounter) Increment() {
gc.mu.Lock()
gc.val++
gc.mu.Unlock()
}
It’s not that channels are slow, it’s just that mutexes are faster for this specific use case.
Deadlocks are easier with channels
Deadlocking with a mutex happens, but it’s usually obvious. With channels, it can be silent.
Silent deadlock
func badPattern() {
ch := make(chan int) // unbuffered
go func() { ch <- 1 }()
// If this goroutine doesn't run first, it hangs here
<-ch
}
Mutex with timeout is cleaner
func betterPattern(mu *sync.Mutex) {
locked := mu.TryLock()
if !locked {
// Handle failure case
return
}
defer mu.Unlock()
// Access the resource
}
With channels, you can have goroutines blocked for hours without anyone noticing. With a mutex, the issue usually shows up much faster in profiling.
My rule of thumb
After dealing with my fair share of incidents, I’ve settled on a simple rule.
Use a mutex when:
- Protecting simple shared state (counters, caches, config)
- Performance is critical
- Access is synchronous (no need for inter-goroutine communication)
Use a channel when:
- Communicating data between goroutines
- Building processing pipelines
- Distributing work among workers
If you’re using a channel just to share a variable, you should probably be using a mutex.
What the docs actually say
Even the Go documentation is pretty clear about this. From the sync.Mutex page:
Mutexes are more efficient than channels for protecting shared state.
And from the channel documentation:
Don’t communicate by sharing memory; share memory by communicating.
This last one is legendary. The problem is, everyone forgets the first part.
The dogma problem
The issue isn’t using channels. It’s using channels out of ideology instead of necessity.
I’ve seen code reviews where someone rejected a mutex claiming it “isn’t idiomatic Go.” That’s literally ignoring what the language creators recommend.
Go was built to be pragmatic. If a mutex solves the problem better, use a mutex.
In practice
If you’re starting a new project, don’t be afraid of mutexes. They aren’t “less Go” than channels.
And if you’re maintaining code cluttered with channels where a mutex would suffice, consider refactoring. It’ll be faster and way easier to debug.
The bottom line is: pick the right tool for the job. Not the one that sounds more “idiomatic” on Twitter or LinkedIn.
References
- Effective Go
- Rob Pike: “Concurrency in Go” (Go conferences)
- Dave Cheney: “Channels are not the only way”