Understanding Pessimistic Locking with Mutexes
Writing multi-threaded or concurrent code is deceptively simple: you invoke a language primitive to spawn a thread or lightweight routine, and work proceeds in parallel. However, writing correct concurrent code is remarkably difficult, and writing performant, correct concurrent code is harder still.
A classic demonstration of this difficulty is the seemingly simple operation count++. While it appears as a single statement in high-level programming languages, it is fundamentally non-atomic. When accessed concurrently by multiple threads, it produces race conditions and inconsistent states.
Here we explore the root cause of this failure, demonstrate how race conditions manifest under load, and explain how to achieve correctness through pessimistic locking using a Mutex (Mutual Exclusion lock).
1. The Anatomy of a Non-Atomic Operation
In high-level languages like Go, C, or Java, incrementing an integer looks like an indivisible step:
count++
At the hardware and assembly level, however, count++ expands into a three-step Read-Modify-Write sequence:
- Read: Load the current value of
count from memory into a CPU register.
- Modify: Increment the value inside the register by
1.
- Write: Store the updated register value back into the memory address of
count.
Thread A Thread B Memory (count)
| | | (count = 5)
|--- Read count (5) ------>| |
| |--- Read count (5) -------->|
| Modify: 5 + 1 = 6 | |
| | Modify: 5 + 1 = 6 |
|--- Write count (6) ----->| | (count = 6)
| |--- Write count (6) ------->| (count = 6)
When two or more execution threads perform this sequence concurrently without synchronization, their operations can interleave arbitrarily. In the diagram above, both threads read 5 and subsequently write 6. Two increments occurred, but the variable only increased by one. One write was silently lost.
2. Reproducing the Race Condition in Go
To observe this lost update phenomenon firsthand, consider a Go program that spins up 1,000,000 goroutines, each attempting to increment a shared global counter.
package main
import (
"fmt"
"sync"
)
var count int = 0
var wg sync.WaitGroup
func incrementCount() {
defer wg.Done()
count++
}
func main() {
iterations := 1000000
wg.Add(iterations)
for i := 0; i < iterations; i++ {
go incrementCount()
}
// Wait for all goroutines to complete
wg.Wait()
fmt.Printf("Expected count: %d, Actual count: %d\n", iterations, count)
}
Execution Results
If count++ were thread-safe, the final count would always be 1,000,000. Instead, running this program yields variable, non-deterministic values across runs:
- Run 1:
998,824
- Run 2:
993,412
- Run 3:
995,108
Thousands of updates are lost because goroutines read stale values while other routines are midway through modifying and writing the data back.
3. What is Pessimistic Locking?
To eliminate data races, we must enforce Mutual Exclusion: ensuring that only one execution context executes the critical operation at any given moment.
Concurrency control strategies generally fall into two categories:
- Optimistic Concurrency Control (OCC): Assumes conflicts are rare. Threads read data, perform work, and verify before committing whether the data was modified by another thread (e.g., using Compare-And-Swap or version numbers). If a conflict occurs, the operation retries.
- Pessimistic Concurrency Control: Assumes conflicts will happen frequently. Before performing any modification on the shared resource, a thread explicitly acquires an exclusive lock. All other threads attempting to access the resource are forced to wait until the lock is released.
A Mutex (short for Mutual Exclusion) is the canonical synchronization primitive used to implement pessimistic locking in software.
[ Goroutine 1 ] ---> Acquires Lock ---> Executes count++ ---> Releases Lock
|
[ Goroutine 2 ] --------> [ BLOCKED / WAITING ] -------------> Acquires Lock ---> ...
[ Goroutine 3 ] --------> [ BLOCKED / WAITING ]
4. Implementing Mutex-Based Locking in Go
Go provides a native mutex implementation under the sync package via sync.Mutex. Wrapping the critical section with mu.Lock() and mu.Unlock() guarantees that count++ behaves atomically with respect to other routines.
package main
import (
"fmt"
"sync"
)
var count int = 0
var wg sync.WaitGroup
var mu sync.Mutex
func incrementCount() {
defer wg.Done()
// Acquire the lock: only one thread enters past this point
mu.Lock()
count++ // Critical section
mu.Unlock()
}
func main() {
iterations := 1000000
wg.Add(iterations)
for i := 0; i < iterations; i++ {
go incrementCount()
}
wg.Wait()
fmt.Printf("Expected count: %d, Actual count: %d\n", iterations, count)
}
Outcome
With pessimistic locking in place, the output becomes completely deterministic:
Expected count: 1000000, Actual count: 1000000
No matter how many times the program runs, the final value is consistently 1,000,000.
5. The Performance Trade-Off: Contention and Critical Section Sizing
While pessimistic locking guarantees correctness, it comes with a strict operational penalty: lock contention.
In the program above, 1,000,000 goroutines compete for the same mutex. At any point in time, 1 goroutine is executing while the remaining 999,999 are waiting or scheduled out. This serializes parallel execution, causing CPU cache invalidations, context switches, and queue latency.
The Golden Rule of Critical Sections
Keep the critical section as small as possible.
A common anti-pattern is locking an entire function rather than isolating only the code block that touches shared state.
Inefficient Approach (Bloated Critical Section):
func processAndIncrement() {
mu.Lock()
defer mu.Unlock()
// Heavy computation, I/O, or parsing
payload := parsePayload()
result := computeHash(payload)
// Actual shared state update
count += result
}
In this antipattern, all independent operations (parsePayload(), computeHash()) are unnecessarily serialized, completely neutralizing parallel processing benefits.
Optimal Approach (Minimal Critical Section):
func processAndIncrement() {
// Independent work executed in parallel without locks
payload := parsePayload()
result := computeHash(payload)
// Lock acquired only for the shared mutation
mu.Lock()
count += result
mu.Unlock()
}
By restricting the lock to the exact boundary of the shared state mutation, you maximize parallel execution time and minimize the duration each thread holds the lock.
6. Language Parallels
Pessimistic synchronization primitives exist across virtually every modern programming ecosystem:
| Language | Primitive / Construct |
|---|
| Go | sync.Mutex, sync.RWMutex |
| Java | synchronized blocks, java.util.concurrent.locks.ReentrantLock |
| C++ | std::mutex, std::unique_lock, std::lock_guard |
| POSIX (C) | pthread_mutex_t (pthread_mutex_lock, pthread_mutex_unlock) |
| Rust | std::sync::Mutex |
Regardless of the runtime or syntax, the underlying contract remains identical: guarantee mutual exclusion over shared memory boundaries to eliminate data races.
7. Key Takeaways
count++ is not atomic: High-level increment statements map to a three-step read-modify-write CPU sequence prone to lost updates under concurrency.
- Pessimistic locking guarantees mutual exclusion: It assumes conflicts will occur and forces threads to acquire exclusive ownership before accessing shared state.
- Correctness comes at the cost of contention: Serializing N threads causes performance degradation due to thread queuing and lock wait states.
- Minimize critical section boundaries: Only lock the bare minimum lines of code that mutate shared state. Keep non-shared computations outside synchronized regions.