A mutex in Go is a lock that protects a critical section of code so that multiple goroutines do not modify the same data at the same time.
In the previous article, we saw how goroutines can communicate by passing messages through channels. That’s the “elegant” Go way.
But sometimes you simply need a global map, a counter, or a cache shared by many goroutines. At that point, we return to the classic model: Shared Memory.
Without synchronization, a race condition (data race) appears. Let’s see how to detect it and how to protect state using mutexes and atomic operations.
What is a Race Condition?
A race condition occurs when two or more goroutines try to access the same shared variable at the same time, and at least one of them attempts to write.
The behavior becomes unreliable: we can lose updates, observe inconsistent states, or even cause a crash.
Example of the Disaster
Imagine we want to count how many times our website is visited. We launch 1000 goroutines, and each one adds 1 to a counter.
package main
import (
"fmt"
"sync"
)
var counter int // Shared variable
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
// CRITICAL: Read + Modify + Write
counter++
}()
}
wg.Wait()
fmt.Println("Final counter:", counter)
}We’d expect to see 1000, but we might get a smaller number or, by chance, the expected one. The program is never correct as long as the race exists.
Why?
Because the counter++ operation is not atomic. In reality, the CPU performs three steps:
- Read the current value of
counter(e.g., 5). - Add 1 to it (5 + 1 = 6).
- Store the 6 back into
counter.
If two goroutines read the “5” at the same time, both will write a “6”, losing one of the increments.
Protecting a Section with sync.Mutex
The usual way to fix this is by using a mutex for mutual exclusion.
A Mutex is like a lock.
- Before touching the shared variable, we lock (
Lock) the lock. If another goroutine arrives, it has to wait. - We perform the operation.
- We unlock (
Unlock) the lock so the next one can proceed.
The code area protected by the lock is called the Critical Section.
package main
import (
"fmt"
"sync"
)
var (
counter int
mu sync.Mutex // The lock protecting 'counter'
)
func main() {
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Add(1)
go func() {
defer wg.Done()
mu.Lock() // 🔒 Lock the lock. No one else gets in.
counter++ // Critical section (now safe)
mu.Unlock() // 🔓 Unlock the lock.
}()
}
wg.Wait()
fmt.Println("Final counter:", counter) // It will always be 1000
}Best practices with Mutex:
Whenever possible, use defer mu.Unlock() right after Lock(). This guarantees the lock is released even if the function panics or has an early return, preventing Deadlocks.
Separating Reads and Writes with sync.RWMutex
Suppose a cache is read thousands of times per second and rarely updated. A regular Mutex forces readers to enter one by one; an RWMutex can allow multiple readers at the same time.
For this, we have sync.RWMutex (Read-Write Mutex). It has two types of locks:
RLock(): Read lock. Allows multiple concurrent readers, but no writers.Lock(): Write lock. Total lock (neither readers nor other writers can enter).
var cache = make(map[string]string)
var mu sync.RWMutex
// Many can read at the same time
func Get(key string) string {
mu.RLock() // Green lock (read)
defer mu.RUnlock()
return cache[key]
}
// Only one can write (and kicks out readers)
func Set(key, value string) {
mu.Lock() // Red lock (write)
defer mu.Unlock()
cache[key] = value
}An RWMutex has more internal overhead than a Mutex and doesn’t always improve performance. It only pays off in certain patterns with many reads and sufficient contention; if it matters, it’s worth measuring.
Simple Operations with sync/atomic
For updating an isolated counter or flag, we can use atomic operations. These are low-level primitives, and we must keep all accesses to that data atomic.
The modern types in the sync/atomic package provide methods like Add, Load, Store, and CompareAndSwap.
import "sync/atomic"
var counter atomic.Int64 // Its zero value is ready for use
func main() {
// ... waitgroup setup ...
go func() {
counter.Add(1)
}()
// ...
// To read, we must also use atomic
finalValue := counter.Load()
}atomic requires careful reasoning about synchronization. For invariants spanning multiple fields, maps, slices, or complex logic, a mutex or a channel is usually clearer.
The Race Detector
Sometimes race conditions are very subtle and hard to spot by eye. You might have a latent bug that only appears once a month under heavy load.
Go includes a detector to find races during execution. We add the -race flag when running or testing the code.
go run -race main.go
# or
go test -race ./...If it finds a conflicting concurrent access, it shows the traces of the accesses and the involved goroutines. It only detects paths that actually execute, so a clean test alone does not prove the program is race-free.
Don’t distribute a build with -race as a regular binary, as it greatly increases CPU and memory consumption. Use it in tests, continuous integration, and, when necessary, under representative load.
Channels, Mutex, or Atomic
When do I use what? Here’s a quick guide:
| Tool | Use Case | Philosophy |
|---|---|---|
| Channels | Passing data, coordinating tasks, worker pipelines. | “Share memory by communicating” |
| Mutex | Protecting internal state (structs), caches, maps. | “Protect the critical section” |
| Atomic | Simple counters, metrics, boolean flags. | “Maximum simple performance” |