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.

Posted on 3 min By: Luan Rodrigues

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”