Mutex in Go: What It Solves, What It Hides, and Where It Breaks
Concurrency in Go is easy to start and easy to misuse.
That is exactly why mutexes deserve more respect than they usually get.
Most developers meet sync.Mutex as the quick fix for a race condition:
- shared counter looks wrong
- map crashes under load
- tests pass locally but fail with
-race
So they add a lock, the issue disappears, and the story ends there.
But that is usually where the real problems begin.
A mutex can protect correctness, but it can also quietly destroy throughput, introduce deadlocks, amplify tail latency, and turn a concurrent system into a serialized one.
This article starts from the basics, but the goal is not to explain what Lock() and Unlock() do. The goal is to build a better mental model for using mutexes in real Go systems.
Start with the actual problem
Consider this:
package main
import (
"fmt"
"sync"
)
var count int
func increment(wg *sync.WaitGroup) {
defer wg.Done()
count++
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println(count)
}
If you expect 1000, you are already assuming something that is not true.
count++ is not one indivisible operation. It is a read, a modification, and a write. Multiple goroutines can interleave those steps in unpredictable ways.
That is the race.
The bug is not that Go failed to protect you. The bug is that you allowed shared mutable state without defining who gets exclusive access to it.
That is what a mutex gives you.
The basic mutex fix
package main
import (
"fmt"
"sync"
)
var (
mu sync.Mutex
count int
)
func increment(wg *sync.WaitGroup) {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go increment(&wg)
}
wg.Wait()
fmt.Println(count)
}
Now it is correct.
But the important part is not the syntax. The important part is what changed conceptually:
Before, many goroutines could mutate the same memory concurrently. Now, mutation is serialized through a critical section.
That sounds small. It is not small.
You did not merely “make it safe.” You also changed the performance profile of the program.
That trade-off is the entire subject.
Mutex is not magic. It is controlled serialization.
This is the first thing worth saying clearly:
A mutex does not create safe concurrency. A mutex creates safe exclusivity around a piece of concurrent code.
That distinction matters.
If 500 goroutines all need the same lock, they are not meaningfully progressing together through that code path. They are waiting in line.
Sometimes that is exactly what you want. Sometimes that means your design is already wrong.
The first real-world case: protecting a map
Go maps are not safe for concurrent writes, and they are not safe for concurrent read/write access either. That makes them one of the most common places where developers first reach for a mutex.
type Counter struct {
mu sync.Mutex
counts map[string]int
}
func NewCounter() *Counter {
return &Counter{
counts: make(map[string]int),
}
}
func (c *Counter) Inc(key string) {
c.mu.Lock()
c.counts[key]++
c.mu.Unlock()
}
func (c *Counter) Get(key string) int {
c.mu.Lock()
v := c.counts[key]
c.mu.Unlock()
return v
}
This is a valid starting point. It is also where a lot of people stop thinking.
The hidden assumption here is that all keys deserve the same lock.
That may be fine for a toy counter. It may be terrible for a hot production path.
If 10,000 requests are updating unrelated keys, one global mutex still forces them through a single shared bottleneck.
This is correct, but often much more expensive than people realize.
One big lock is often a design smell
A single mutex around a large data structure is attractive because it is simple. Simplicity is good. But there is a difference between simple and blunt.
If unrelated operations contend on the same lock, you are paying coordination cost where no true conflict exists.
A common improvement is to reduce lock granularity.
For example, shard the data:
type shard struct {
mu sync.Mutex
m map[string]int
}
type ShardedCounter struct {
shards []shard
}
func NewShardedCounter(n int) *ShardedCounter {
s := make([]shard, n)
for i := range s {
s[i].m = make(map[string]int)
}
return &ShardedCounter{shards: s}
}
Then choose a shard based on the key and only lock that shard.
Now unrelated keys do not block each other nearly as often.
That is a much more production-shaped idea than “put a mutex around the map.”
defer mu.Unlock() is good, but not free
A very common pattern in Go is:
mu.Lock()
defer mu.Unlock()
This is usually a good default. It reduces the chance of forgotten unlocks and makes early returns safer.
But it is still worth understanding what you are buying.
In a hot path, defer has overhead. Usually not enough to matter. Sometimes enough to matter a lot.
So the better rule is not “always use defer” or “never use defer.”
The better rule is this:
- use
deferwhen it materially improves safety and readability - drop it in extremely hot paths only when measurement justifies it
That last part matters. Not vibes. Measurement.
The most common beginner mistake is not race conditions. It is lock scope.
This is where real systems get damaged.
Consider this:
func (c *Cache) Refresh(key string) error {
c.mu.Lock()
defer c.mu.Unlock()
value, err := c.fetchFromDB(key)
if err != nil {
return err
}
c.data[key] = value
return nil
}
This code is safe.
It is also terrible.
The lock is held while doing I/O. That means every other goroutine that wants the cache is now waiting on database latency.
If the DB is slow, your lock duration explodes. If lock duration explodes, contention rises. If contention rises, throughput falls and tail latency gets ugly.
The problem is not the mutex itself. The problem is what work you chose to do while holding it.
A better version separates the expensive part:
func (c *Cache) Refresh(key string) error {
value, err := c.fetchFromDB(key)
if err != nil {
return err
}
c.mu.Lock()
c.data[key] = value
c.mu.Unlock()
return nil
}
Now the mutex protects the state mutation, not the network trip.
That is a much healthier boundary.
A mutex should protect state, not entire workflows
That sentence is worth remembering.
When people are uncomfortable with concurrency, they often lock too much. They stop reasoning about the protected state and start locking around “the whole operation.”
That creates slow, sticky critical sections.
The better question is:
What exact shared invariant am I protecting?
If you cannot answer that clearly, the lock scope is probably wrong.
Deadlocks are what happen when local correctness meets global inconsistency
A single mutex is easy to reason about.
Two mutexes are where systems start becoming interesting.
a.mu.Lock()
b.mu.Lock()
That looks harmless until somewhere else you do:
b.mu.Lock()
a.mu.Lock()
Now two goroutines can each hold one lock and wait forever for the other.
That is the deadlock pattern.
What makes deadlocks nasty is that each local function can look reasonable. The failure only appears when you consider the system as a whole.
The usual fix is simple in theory and annoying in practice:
Always acquire multiple locks in a consistent order.
That means lock ordering is not a local coding choice. It is a system rule.
RWMutex is not “better Mutex”
A lot of developers encounter sync.RWMutex and immediately think:
“Great, this is the optimized version.”
Not quite.
RWMutex helps when:
- reads are frequent
- writes are rare
- read sections are meaningful enough to benefit from concurrency
Example:
type Cache struct {
mu sync.RWMutex
data map[string]string
}
func (c *Cache) Get(key string) (string, bool) {
c.mu.RLock()
v, ok := c.data[key]
c.mu.RUnlock()
return v, ok
}
func (c *Cache) Set(key, value string) {
c.mu.Lock()
c.data[key] = value
c.mu.Unlock()
}
This can improve throughput in read-heavy workloads.
But it is not automatically better.
Why?
Because RWMutex is more complex, and under some contention patterns it performs worse than a plain Mutex. Also, once writes become frequent, the advantage can disappear quickly.
So again, the rule is not “prefer RWMutex for maps.” The rule is: use it when workload characteristics justify it.
Not before.
Real-world scenario: in-memory rate limiting
Imagine a service with an in-memory per-user counter for lightweight rate limiting.
At first glance, this seems fine:
type Limiter struct {
mu sync.Mutex
counts map[string]int
}
But under real traffic, all users contend on the same mutex. One hot customer can increase lock contention for everyone else.
That is not just a code smell. That is a tenant isolation problem at the concurrency layer.
Better options might include:
- sharded locks
- per-key structures
- atomic counters for specific cases
- moving coordination to Redis if you need cross-instance correctness
This is where mutex discussions stop being about syntax and start becoming architecture.
Sometimes the right answer is not a mutex
This is the part many articles skip.
Mutex is not the default answer to all concurrency problems.
Sometimes:
- a goroutine should own the state and all mutation should go through a channel
- immutable snapshots are better than shared mutable memory
- atomic operations are enough for a simple counter
- the database should be the source of truth instead of process memory
- the problem should be redesigned so there is less sharing in the first place
That last one is especially important.
A mutex is often a sign that you have shared state. But shared state itself is often the deeper design choice worth questioning.
Mutex vs channel is the wrong debate
This argument comes up constantly and usually wastes time.
The better framing is:
- use a mutex when multiple goroutines must safely access shared memory
- use a channel when you want to communicate ownership or sequence work
A channel can eliminate shared state by changing who owns it. A mutex keeps shared state and coordinates access to it.
Those are different models.
Neither is universally better.
A practical rule: prefer the simplest correct thing, then measure
That usually means:
- Start with a plain
sync.Mutex - Keep the critical section small
- Avoid holding locks during I/O
- Define lock ordering if more than one lock exists
- Run with
-race - Measure contention before “optimizing”
- Upgrade to
RWMutex, sharding, atomics, or redesign only when the workload proves the need
This is a much healthier progression than starting with cleverness.
The production question is not “Do I need a mutex?”
It is:
- what state is shared?
- what invariant must remain true?
- how long is the lock held?
- what happens under contention?
- can unrelated work proceed independently?
- is process-local memory even the right place for this state?
Once you ask those questions, mutex stops being a tiny synchronization primitive and starts becoming a design boundary.
That is where it gets interesting.
Closing thought
A race condition is obvious once you know what to look for.
A bad mutex design is harder. It often looks correct in code review. It may even survive load tests. Then one day it becomes the hidden reason your service feels “weirdly slow” under bursty traffic.
That is why mutexes deserve more than a tutorial-level understanding.
They are not just about protecting memory.
They are about deciding where concurrency should stop.