Mutexes Aren’t Sexy
But They’re the Only Thing Standing Between Your Goroutines and Chaos
“The first principle is: don’t fool yourself — and you are the easiest person to fool.” — Richard Feynman
I’ll admit something: the first time I wrote concurrent Go code, I thought I was doing great. I had goroutines everywhere—fetching data, updating state, writing to logs—and I felt like some kind of distributed-systems prodigy. Then I hit run.
My numbers were off by hundreds. Sometimes it panicked. Sometimes it hung. Sometimes it “worked,” but only when I sprinkled enough time.Sleep calls around to fool myself into thinking everything was predictable. It wasn’t.
Mutexes don’t get much attention in the Go world—not compared to channels, goroutines, or the warm fuzzy philosophy of “share memory by communicating.” But in real systems, where shared state does exist, mutexes are what keep that state from becoming a slot machine.
This article walks through how mutexes really work, why they matter, and how to use them without turning your code into a maze of locks.
Why Shared State Becomes Dangerous
Every goroutine runs independently. That means any goroutine can be paused and replaced by another at any moment—even mid-expression. That freedom introduces nondeterminism when they share variables.
Consider a simple counter:
var count int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
count++ // unsafe
}()
}
wg.Wait()
fmt.Println(”Final:”, count)You’ll almost never get 1000. You might get:
761
843
998
or occasionally 1000, just to give you false hope.
This isn’t randomness—it’s a race condition.
Mutex 101: Just Two Methods
A sync.Mutex does one thing well: limit access.
Lock()— wait until it’s your turnUnlock()— signal that you’re done
The smallest possible fix:
var mu sync.Mutex
var count int
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock()
count++
mu.Unlock()
}()
}Now the outcome is deterministic and correct every time.
Using defer for Clarity and Safety
In larger functions, defer is the idiomatic way to guarantee unlocks:
mu.Lock()
defer mu.Unlock()
count += expensiveComputation()If you add an early return later, you won’t accidentally deadlock your program.
Protecting Maps
Go maps are not concurrency-safe. Without a mutex, you eventually hit:
fatal error: concurrent map writesA simple mutex-backed cache:
type Cache struct {
mu sync.Mutex
data map[string]string
}
func (c *Cache) Get(k string) string {
c.mu.Lock()
defer c.mu.Unlock()
return c.data[k]
}
func (c *Cache) Set(k, v string) {
c.mu.Lock()
c.data[k] = v
c.mu.Unlock()
}Always hide the mutex inside the struct and expose methods instead of fields.
When Reads Outnumber Writes: RWMutex
If your code reads constantly but writes occasionally, use sync.RWMutex.
Why?
sync.RWMutex is a reader/writer mutual exclusion lock that allows multiple goroutines to read a shared resource concurrently while ensuring only one goroutine can write to it at a time.
It is more performant than a standard sync.Mutex in scenarios where reads are much more frequent than writes, as it avoids blocking readers when a write operation is not in progress.
var mu sync.RWMutex
var value int
func Read() int {
mu.RLock()
defer mu.RUnlock()
return value
}
func Write(v int) {
mu.Lock()
defer mu.Unlock()
value = v
}It’s perfect for configs, feature flags, and read-heavy caches.
Worker Pool Updating Shared Stats
Many real systems have concurrent workers reporting into shared stats:
type Stats struct {
mu sync.Mutex
Passed int
Failed int
}
func (s *Stats) Record(ok bool) {
s.mu.Lock()
defer s.mu.Unlock()
if ok {
s.Passed++
} else {
s.Failed++
}
}Workers use it like this:
stats := &Stats{}
jobs := make(chan int)
for i := 0; i < 5; i++ {
go func() {
for job := range jobs {
ok := process(job)
stats.Record(ok)
}
}()
}This kind of shared struct with an internal mutex appears everywhere in production Go code.
When Not To Use Mutexes
Mutexes are great, but not universal.
1. Avoid them if you can avoid shared memory
You can’t have a race condition if nothing is shared.
2. Use atomics for super-hot counters
var hits uint64
atomic.AddUint64(&hits, 1)3. Use channels for coordination, not locking
Fan-in, worker pools, pipelines → channels shine there.
Mutex Wisdom
Keep critical sections short.
Lock only where shared data is touched.
Don’t hold locks across I/O or network calls.
Never expose a mutex to callers.
Don’t lock inside callbacks (easy deadlock path).
Avoid nested locks unless absolutely required.
Think of mutexes like panic—powerful, but best used in isolated, controlled places.
Final Thoughts
Concurrency isn’t hard because of goroutines. It’s hard because of the tiny, innocent-looking variables they share. Mutexes give your program a way to behave honestly under load—no guessing.
They may not be sexy. But they make your Go programs sane.



