In the early years of my career, when I first encountered double-checked locking in code, I thought it was a redundant code line checking same thing twice. I told myself why would anyone check the same condition twice? I asked a senior developer, and he explained how it ensure thread safety and save performance in concurrency. Actually, double-checked locking is a tiny but useful pattern that appears in concurrent programming.
What Is Double-Checked Locking
At its core, double-checked locking is a technique that reduces the overhead of acquiring a lock by first checking the condition without synchronization/locking in concurrent environment. Only if the condition appears to be true, we acquire the lock and check again. Its use cases are lazy singleton initialization, expensive resource initialization, optional application features, and memorized shared state .
Here’s the canonical structure:
def get_resource():
if resource is None: # First check (no lock)
with lock: # Acquire lock
if resource is None: # Second check (with lock)
resource = create() # Expensive operation
return resource
But let’s step back and understand why this pattern exists.
Locks Are Expensive
In concurrent programming, locks are essential but costly. Every time you acquire and release a lock, you’re:
- Paying for system-level synchronization
- Potentially blocking other threads/coroutines
- Introducing contention in your application
Consider a web cache that stores expensive computation results:
class Cache:
def __init__(self):
self._lock = threading.Lock()
self._heavy_data = None
def get_data(self):
with self._lock: # Every single request acquires the lock!
if self._heavy_data is None:
self._heavy_data = self._compute_heavy_data()
return self._heavy_data
In a high-traffic system, this lock becomes a bottleneck. Thousands of requests per second all waiting for the same lock, even after the data is cached.
The Pattern in Action
Double-checked locking shines in scenarios where:
- An operation is expensive to perform
- The operation should be performed only once
- The result is accessed frequently
- The system is multi-threaded
Let’s look at some real-world applications:
1. Lazy Initialization of Resources
class ConnectionPool:
_instance = None
_lock = threading.Lock()
def get_connection(self):
if self._connection is None: # Quick check
with self._lock: # Only lock if needed
if self._connection is None: # Double-check
self._connection = self._create_connection()
self._pool = self._initialize_pool()
return self._connection
2. Expensive Computations
class DataProcessor:
_cache = None
_lock = threading.Lock()
def get_processed_data(self):
if self._cache is None: # Check without lock
with self._lock: # Acquire lock
if self._cache is None: # Check with lock
raw = self._load_massive_dataset()
self._cache = self._process(raw)
return self._cache
3. Lazy Loading in Web Applications
class ConfigManager:
_config = None
_lock = threading.Lock()
def get_config(self):
if self._config is None:
with self._lock:
if self._config is None:
self._config = self._load_config_from_file()
self._config = self._parse_and_validate()
return self._config
The Performance Impact: Let’s Talk Numbers
I ran some benchmarks to illustrate the difference. Here’s what I found with 10,000 concurrent requests:
| Pattern | Time (ms) | Operations/sec |
|---|---|---|
| Always lock | 78.94 | 126,675 |
| Double-checked | 11.16 | 895,873 |
You can find benchmark script here : https://gist.github.com/birddevelper/21793b07679680123942130d46bc8bd3. I also added benchmark for using double-checked lock in class __new__ method vs in meta-class __call__ which is itself another long story.
As you can see, double-checked locking is ~7x faster than always locking! Once the resource is initialized, the first check fails and we bypass the lock entirely. Only the first few threads pay the synchronization cost.
Let’s check a Java example:
public class Singleton {
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // First check: avoid locking after init
synchronized (Singleton.class) {
if (instance == null) { // Second check: only one initializer
instance = new Singleton();
}
}
}
return instance;
}
}
The second check prevents multiple initialization after threads enter the synchronized block, while synchronized and volatile provide the memory-visibility and ordering guarantees. When a thread sees a non-null volatile reference, it also sees the effects of the constructor that happened-before that write.
Let me know in the comments, I’d love to hear your experience with this pattern!
I remember feeling similarly confused at first, it’s definitely a subtle but important optimization.