Understanding Pessimistic Locking and Mutexes in Concurrent Programming

Arpit Bhayani

Arpit Bhayani

Jun 23, 2023 • 6 min read

Play

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:

  1. Read: Load the current value of count from memory into a CPU register.
  2. Modify: Increment the value inside the register by 1.
  3. 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,0001,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:

  1. 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.
  2. 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,0001,000,000 goroutines compete for the same mutex. At any point in time, 11 goroutine is executing while the remaining 999,999999,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:

LanguagePrimitive / Construct
Gosync.Mutex, sync.RWMutex
Javasynchronized 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)
Ruststd::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

  1. count++ is not atomic: High-level increment statements map to a three-step read-modify-write CPU sequence prone to lost updates under concurrency.
  2. Pessimistic locking guarantees mutual exclusion: It assumes conflicts will occur and forces threads to acquire exclusive ownership before accessing shared state.
  3. Correctness comes at the cost of contention: Serializing NN threads causes performance degradation due to thread queuing and lock wait states.
  4. Minimize critical section boundaries: Only lock the bare minimum lines of code that mutate shared state. Keep non-shared computations outside synchronized regions.
Arpit Bhayani

Principal Engineer II at Razorpay - building Agent Studio, Ex-staff engg at GCP Memorystore & Dataproc, Creator of DiceDB, ex-Amazon Fast Data, ex-Director of Engg. SRE and Data Engineering at Unacademy. I spark engineering curiosity through my no-fluff engineering videos on YouTube and my courses