This content originally appeared on DEV Community and was authored by gkoos
Concurrency is Go’s crown jewel – goroutines and channels make concurrent programming feel almost magical. But not every problem belongs in a channel. Many developers fall into the trap of overusing channels or misusing mutexes, resulting in slow, buggy, or unmaintainable code. In this article, we’ll demystify when to use channels and when to use mutexes, and why blindly following “Go concurrency patterns” can backfire.
The Misconception
Go’s philosophy of “do not communicate by sharing memory; share memory by communicating” is often taken literally. Some gophers try to replace every mutex with a channel, thinking channels are the “Go way” to synchronize everything.
But here’s the hard truth: channels are not a free replacement for mutexes. They’re great for coordinating goroutines, pipelines, and events – but not always the right tool for protecting shared state.
On the surface, goroutines look more elegant, sure – and they are, in the right context. But trying to funnel all state access through channels, even for a simple counter or map, often leads to:
- Unnecessary complexity: A simple counter increment can become dozens of lines of boilerplate channel code.
- Performance penalties: Channels involve scheduling, allocation, and copying, so you’re paying extra overhead where a mutex would suffice.
- Subtle bugs: Improperly managed channels can deadlock or leak goroutines, sometimes in ways that are much harder to debug than a simple mutex.
Example: Consider a simple counter that multiple goroutines increment. Using a channel for this can lead to complex and error-prone code, while a mutex would be straightforward and efficient:
// Using channels to protect a counter
counter := 0
ch := make(chan int)
go func() {
for val := range ch {
counter += val
}
}()
ch <- 1
ch <- 1
close(ch)
Ugh. This works, but it’s overkill. A mutex does the same thing with less code and less overhead:
// Using a mutex to protect a counter
var mu sync.Mutex
counter := 0
mu.Lock()
counter++
mu.Unlock()
Channels: For Communication, Not Just Safety
Channels shine when goroutines need to communicate or signal events. They can be used to implement fan-out/fan-in patterns, worker pools, or pipelines:
package main
import (
"fmt"
)
func main() {
jobs := make(chan int, 5)
results := make(chan int, 5)
// Start 3 workers
for w := 0; w < 3; w++ {
go func(id int) {
for j := range jobs {
results <- j * 2
}
}(w)
}
// Send jobs
for i := 1; i <= 5; i++ {
jobs <- i
}
close(jobs)
// Collect results
for i := 0; i < 5; i++ {
fmt.Println(<-results)
}
}
Pros:
- Excellent for orchestrating goroutines.
- Can simplify complex coordination patterns.
Cons:
- Higher overhead than a mutex for simple state protection.
- Overcomplicates code if used for every shared variable.
Mutexes: The Right Tool for Shared State
First of all, what is a mutex? A mutex (short for mutual exclusion) is a synchronization primitive that ensures only one goroutine (or thread) can access a piece of shared data at a time. It acts like a lock around critical sections, preventing race conditions when multiple goroutines attempt to read or write the same state concurrently.
A sync.Mutex
is designed to guard access to a shared resource. If you just need safe access to a map, counter, or struct, a mutex is often simpler and faster.
Imagine you’re maintaining a cache that multiple goroutines need to read and update. A sync.Mutex
is the simplest and most efficient way to guard that shared map:
var (
mu sync.Mutex
cache = make(map[string]string)
)
func set(key, value string) {
mu.Lock()
defer mu.Unlock()
cache[key] = value
}
func get(key string) (string, bool) {
mu.Lock()
defer mu.Unlock()
v, ok := cache[key]
return v, ok
}
Pros:
- Extremely low overhead.
- Explicit locking makes reasoning about shared state straightforward.
- Predictable performance.
Cons:
- Deadlocks if misused.
- Can be less elegant in complex pipelines or fan-out/fan-in patterns.
When to Use What
Use Case | Recommended |
---|---|
Protect a counter, map, or struct | Mutex |
Implement a worker pool, pipeline, or event queue | Channel |
Single producer → single consumer | Channel works nicely |
Multiple goroutines updating the same state | Mutex is usually simpler |
Rule of thumb: Use mutexes for shared state, channels for communication.
Performance Reality
Benchmarks often surprise Go devs. Simple state mutations protected by mutexes are usually orders of magnitude faster than channel-based approaches because channels involve allocation, scheduling, and copying:
- Mutexes are extremely lightweight. They’re implemented in Go’s runtime using efficient atomic operations. Locking and unlocking often cost only a few nanoseconds.
- Channels, on the other hand, involve more moving parts. Sending or receiving on a channel may trigger:
- Memory allocation for the buffered/unbuffered queue.
- Scheduling of waiting goroutines.
- Potential context switching if the receiver isn’t ready.
That extra bookkeeping makes channels slower when all you need is to guard a shared variable.
Benchmark: Mutex vs Channel Counter
Let’s put this to the test with Go’s benchmarking framework:
package main
import (
"sync"
"testing"
)
func BenchmarkMutexCounter(b *testing.B) {
var mu sync.Mutex
counter := 0
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
mu.Lock()
counter++
mu.Unlock()
}
})
}
func BenchmarkChannelCounter(b *testing.B) {
counter := 0
ch := make(chan int, 1000)
// Goroutine that serializes all increments
go func() {
for v := range ch {
counter += v
}
}()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
ch <- 1
}
})
close(ch)
}
And here’s an example of what the results might look like on a typical laptop (Go 1.23, 8-core CPU):
BenchmarkMutexCounter-8 1000000000 0.8 ns/op
BenchmarkChannelCounter-8 20000000 60 ns/op
Now obviously real-world workloads might slightly differ from synthetic benchmarks (e.g., context switches, OS scheduling etc.) but that’s a ~75× performance difference in favor of the mutex!
So why the huge gap? The mutex path is just an atomic operation to acquire/release the lock. The channel path involves synchronization between two goroutines, queue management, and possibly waking up a sleeping goroutine.
This demonstrates why mutexes are the right tool for protecting simple shared state.
Real-World Examples
1. Web Server Request Counting
Imagine you’re running an HTTP server and want to count requests:
- Mutex version: Fast, scalable, and works fine under load.
- Channel version: Every request handler has to ship a message through a channel, creating a bottleneck and slowing down throughput.
In production, that’s the difference between comfortably handling 100k requests/sec and falling behind at 10k requests/sec.
2. Shared Cache
If multiple goroutines read and write a cache (like map[string]User), a mutex is perfect. Reads and writes happen inline with minimal cost.
With a channel-based “cache manager goroutine”, every single read/write becomes a request–response round trip. Instead of O(1) map lookups, you now have O(1) + channel send/receive + scheduling. This introduces latency and makes your cache slower than just hitting the database in some cases.
3. Worker Pool for Task Processing
With a mutex you could have a slice of tasks, protect it with a sync.Mutex, and have multiple goroutines pull work out of it. Each goroutine locks, pops a task, unlocks, processes, and repeats.
But with channels, you can just push tasks into a job channel, spin up N workers, and let them consume concurrently:
jobs := make(chan string, 100)
results := make(chan string, 100)
for w := 0; w < 5; w++ {
go func(id int) {
for job := range jobs {
results <- process(job)
}
}(w)
}
for _, j := range []string{"a", "b", "c"} {
jobs <- j
}
close(jobs)
Here, channels are a natural fit because the problem is work distribution, not just shared memory safety.
Using a mutex would require writing your own coordination logic, which is more error-prone and less readable.
4. Event Notifications / Pub-Sub
With a mutex, you could maintain a slice of subscribers guarded by a mutex. Every time an event happens, you’d lock, loop over subscribers, and call their handler functions. This works, but it mixes synchronization, iteration, and business logic.
Why goroutines + channels are better: channels let you decouple event production from consumption. Each subscriber can listen on its own channel and handle events at its own pace:
subscribers := []chan string{}
func subscribe() chan string {
ch := make(chan string, 10)
subscribers = append(subscribers, ch)
return ch
}
func publish(event string) {
for _, ch := range subscribers {
ch <- event
}
}
Now you can spin up independent goroutines for each subscriber:
sub := subscribe()
go func() {
for msg := range sub {
fmt.Println("Received:", msg)
}
}()
publish("user_signed_in")
publish("user_signed_out")
With goroutines + channels, events flow asynchronously, subscribers don’t block each other, and backpressure (buffered/unbuffered channels) is easy to model.
Doing the same with a mutex-based subscriber list quickly becomes messy, especially if one subscriber is slow or blocks.
Other Concurrency Primitives in Go
While mutexes and channels are the most common tools, Go’s standard library includes a few other primitives worth knowing:
sync.RWMutex
: A variation ofsync.Mutex
that allows multiple readers to hold the lock simultaneously, but only one writer at a time. Useful for read-heavy workloads like caches.sync.Cond
: A condition variable that lets goroutines wait until a certain condition is met. More advanced than channels, but sometimes useful for implementing custom coordination patterns.sync.Once
: Ensures a piece of code runs only once, even if called from multiple goroutines. Commonly used for lazy initialization.sync.WaitGroup
: Waits for a collection of goroutines to finish. Perfect for spawning workers and waiting for them to complete before moving on.sync/atomic
: Provides low-level atomic operations (like atomic.AddInt64) for lock-free access to basic types. Often the fastest solution for counters and flags.
These tools complement mutexes and channels. For example, you might use a sync.WaitGroup
to wait for a batch of goroutines to finish processing before sending a final result on a channel.
Or the counter example with sync/atomic
for lock-free incrementing:
package main
import (
"fmt"
"sync/atomic"
)
func main() {
var counter int64
// Increment atomically
atomic.AddInt64(&counter, 1)
// Read atomically
value := atomic.LoadInt64(&counter)
fmt.Println("Counter:", value)
}
This is often the fastest option for simple counters and flags because it avoids lock contention altogether.
If we extend our benchmark from above:
func BenchmarkAtomicCounter(b *testing.B) {
var counter int64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
atomic.AddInt64(&counter, 1)
}
})
}
The results would be something like this:
BenchmarkAtomicCounter-8 1000000000 0.3 ns/op
BenchmarkMutexCounter-8 1000000000 0.8 ns/op
BenchmarkChannelCounter-8 20000000 60 ns/op
Notice how atomic operations are ~2–3× faster than mutexes, while channels are orders of magnitude slower for this use case. It’s a shame atomic operations are extremely limited: they only work on individual variables and basic types.
Conclusion
Mutexes are perfect for protecting state. Channels shine when you need to coordinate or distribute work/events.
But many Go developers try to force channels into every concurrency problem because they feel more “idiomatic.” In reality, channels are not inherently better than mutexes. They’re tools for communication, not a silver bullet. It’s also important to note that channels and mutexes are not mutually exclusive – sometimes you’ll combine them (e.g., worker pool with channel + shared stats protected by mutex). Think of channels as “communication highways” and mutexes as “traffic lights” for shared memory – each has its place.
Overusing channels is a common beginner trap and leads to code that is harder to read, slower to run, and more error-prone — the exact opposite of Go’s philosophy of simplicity. Just don’t overthink it: mutexes for state, channels for communication.
This content originally appeared on DEV Community and was authored by gkoos