Channel vs Mutex in Go - Which One to Use
A practical guide to choosing between channels and sync.Mutex in Go, with the same problem solved both ways and clear rules for each.
Go has two main tools for goroutines that share work: channels and mutexes. Many people learning Go go through a phase of using channels for everything, because the slogan "share memory by communicating" makes channels sound like the only correct choice. In practice, both are idiomatic, and each fits different problems.
The Short Rule
- Use a mutex when goroutines share state: a counter, a cache, a map of sessions. The data stays in one place and you protect access to it.
- Use a channel when goroutines pass ownership of data or signals: a job handed to a worker, a result handed back, a "stop now" message.
The Go wiki puts it as: use whichever is most expressive and simple for the problem.
The Same Counter Both Ways
With a mutex:
package main
import (
"fmt"
"sync"
)
func main() {
var mu sync.Mutex
var wg sync.WaitGroup
hits := 0
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
hits++
mu.Unlock()
}()
}
wg.Wait()
fmt.Println("hits:", hits)
}
With a channel and one goroutine that owns the counter:
package main
import (
"fmt"
"sync"
)
func main() {
incs := make(chan struct{})
result := make(chan int)
go func() {
hits := 0
for range incs {
hits++
}
result <- hits
}()
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
incs <- struct{}{}
}()
}
wg.Wait()
close(incs)
fmt.Println("hits:", <-result)
}
Both print hits: 100. The mutex version is shorter and easier to read. For simple shared state, the mutex wins.
The channels-everywhere phase:
Building every shared value as a goroutine plus channels leads to code full of extra goroutines, hard-to-follow message flows and leaks when someone forgets to close a channel. If you find yourself writing a goroutine whose only job is to guard a variable, a mutex is usually the clearer choice.
Where Channels Are Clearly Better
Channels shine when work flows from one stage to the next, or when you need to wait for events:
package main
import (
"fmt"
"strings"
)
func main() {
lines := make(chan string)
upper := make(chan string)
go func() {
defer close(lines)
for _, l := range []string{"alpha", "beta", "gamma"} {
lines <- l
}
}()
go func() {
defer close(upper)
for l := range lines {
upper <- strings.ToUpper(l)
}
}()
for u := range upper {
fmt.Println(u)
}
}
This prints ALPHA, BETA and GAMMA. Each stage owns the value while working on it and hands it on. Doing this with a mutex and shared slices would need extra signaling to know when data is ready. With channels, the flow is the structure of the code.
Other jobs where channels fit naturally:
- handing jobs to a pool of workers
- timeouts and cancellation with
select - signaling "done" to many goroutines with
close - limiting concurrency with a buffered channel
A Decision Guide
Is it data that lives somewhere and many goroutines read or update? -> mutex
Is a value moving from one goroutine to another? -> channel
Do you need to wait for something, with a timeout or cancel? -> channel + select
Do you just need to know when goroutines finished? -> sync.WaitGroup
Is it a single number updated very often? -> sync/atomic or mutex
sync/atomic for simple counters:
For a single integer counter, atomic.Int64 with Add and Load avoids locking entirely. Use it for metrics-style counters. For anything involving more than one variable that must change together, use a mutex.
Mixing both on the same data:
Protecting a value with a mutex in one place and passing it through a channel elsewhere, while both still modify it, gives you the bugs of both approaches. Pick one owner model for each piece of data.
A good sign:
If each piece of shared data has one obvious protection, either "guarded by mu" or "owned by whoever last received it", your design will be easy to reason about and to check with the race detector.
Summary
Mutexes protect shared state in place and are usually simplest for counters, caches and maps. Channels move data and signals between goroutines and are best for pipelines, worker pools, timeouts and cancellation. Neither is more "Go-like" than the other. Choose the one that makes the code shortest and clearest, and keep one ownership model per piece of data.