Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome to the third lesson in our module on Python Concurrency and Parallelism.
In our last lesson, we built a foundation for thread safety by implementing thread-safe counters and data structures using threading.Lock and threading.RLock. The key technique we used was to wrap all modifying operations inside a with lock: block, creating a critical section. The strategy of using a single lock to protect an entire data structure, as we did with the LRU cache, has a specific name: coarse-grained locking.
Today, we will dive deeper into this concept and explore the alternatives. Our learning outcome is to analyze and implement both coarse-grained and fine-grained locking strategies, discussing their trade-offs. We'll explore the spectrum of locking granularity, from the simple-but-potentially-slow coarse-grained approach to more complex, higher-performance fine-grained strategies. Understanding these trade-offs is crucial for designing high-throughput systems like the inference scheduler you built, where a single global lock would quickly become a bottleneck.
1. The Spectrum of Locking Granularity
Lock granularity refers to the amount of data protected by a single lock. Choosing the right granularity is a fundamental trade-off in concurrent system design.
Coarse Vs Fine Grained Locking | Concurrency Interview
To start, let's get a formal definition of lock granularity and understand the core problem it solves: lock contention. The article "Coarse Vs Fine Grained Locking" provides an excellent real-world analogy.
Please read the introduction, the section "What is Lock Granularity?", and the following two sections on "The Contention Problem" and "The Scalability Impact". Focus on the security checkpoint analogy and how lock contention serializes work, limiting scalability.
As the article explains, the central issue is contention. When many threads need to access a data structure protected by a single, coarse-grained lock, they form a queue. Even on a multi-core CPU, only one thread can make progress at a time. Fine-grained locking aims to reduce this bottleneck by allowing threads to work on different parts of the data structure in parallel.
2. Coarse-Grained Locking: The Simple Approach
This is the strategy we used in the previous lesson. A single lock protects the entire data structure.

Characteristics:
- Simplicity: It's easy to implement and reason about. You identify the shared object and put a lock around every method that accesses it.
- Safety: With only one lock, there is no risk of complex deadlocks involving multiple locks.
- Scalability Bottleneck: As contention increases, performance degrades significantly because threads spend more time waiting for the lock than doing useful work.
This approach is perfect for low-contention scenarios or when simplicity is the highest priority.
3. Fine-Grained Locking: The High-Performance Approach
Fine-grained locking uses multiple locks to protect different parts of a single data structure. This allows multiple threads to access the structure concurrently, as long as they are operating on non-overlapping parts.
Let's look at two practical strategies.
Strategy 1: Per-Object Locking
In many systems, you manage a collection of independent objects. Instead of one lock for the entire collection, you can place a lock on each individual object.
Think back to the bank account example from the last lesson. We had many Account objects, and our goal was to process transactions on them concurrently.
Thread Safety in Python: Locks and Other Techniques -- Solving Races With Locks
Let's revisit a key detail from the Real Python video. The implementation shown is a perfect example of fine-grained locking, where each bank account instance gets its own lock.
Please watch from 13:30 to 13:50. Notice the line self.lock = threading.Lock() inside the Account's __init__ method. This simple choice has profound performance implications.
By giving each Account instance its own lock, a thread working on account1 doesn't block a thread working on account2. This is a fine-grained approach because the lock scope is narrowed to a single logical entity. Concurrency is high because the probability of two threads needing the same account at the same time is much lower than them needing access to a single global "accounts" lock.
Strategy 2: Lock Striping
For a single large data structure like a hash map, giving every single entry its own lock would be too complex and memory-intensive. A powerful middle-ground is lock striping.
The idea is to divide the data structure into a fixed number of segments (or "stripes"), with one lock per stripe. A key is hashed to determine which stripe it belongs to.
Coarse Vs Fine Grained Locking | Concurrency Interview
The "Coarse Vs Fine Grained Locking" article provides a great explanation and diagram for lock striping. This is a battle-tested pattern used in systems like Java's ConcurrentHashMap.
Read the section "Lock Striping: The Practical Middle Ground". Focus on the diagram and the formula stripe_index = hash(key) % num_stripes. This is the core mechanism.
Let's sketch out a Python implementation of a thread-safe dictionary using lock striping:
import threading
class StripedHashMap:
def __init__(self, num_stripes=16):
if num_stripes <= 0:
raise ValueError("Number of stripes must be positive")
self.num_stripes = num_stripes
# Create a list of locks, one for each stripe
self.locks = [threading.Lock() for _ in range(num_stripes)]
# Create a list of dictionaries, one for each stripe
self.stripes = [{} for _ in range(num_stripes)]
def _get_stripe_index(self, key):
# Determine which stripe the key belongs to
return hash(key) % self.num_stripes
def put(self, key, value):
index = self._get_stripe_index(key)
lock = self.locks[index]
with lock:
# Only the stripe at 'index' is locked.
# Other threads can access other stripes.
self.stripes[index][key] = value
def get(self, key):
index = self._get_stripe_index(key)
lock = self.locks[index]
with lock:
return self.stripes[index].get(key)
def size(self):
# DANGER: This is not thread-safe and gives an inconsistent view.
# A correct implementation would need to acquire all locks.
total_size = 0
for i in range(self.num_stripes):
# To be correct, you'd lock here: self.locks[i].acquire()
# ...in a fixed order to prevent deadlock!
total_size += len(self.stripes[i])
# And release here.
return total_size
The put and get methods demonstrate the power of this approach: as long as keys map to different stripes, threads can operate in parallel. However, as the size() comment shows, global operations that need a consistent view of the entire structure become much more complex. You must acquire all locks, and crucially, do so in a fixed order (e.g., from index 0 to N-1) to prevent deadlock.
4. Trade-offs in Practice: Designing a Thread-Safe Cache
Let's apply this thinking to a common interview problem: designing a thread-safe cache. This problem forces you to choose a locking strategy and justify it.
Design Thread Safe Cache With Ttl | Concurrency Interview
The article "Design Thread Safe Cache With Ttl" is excellent. It not only identifies common race conditions but also explicitly analyzes three different synchronization strategies that map directly to our discussion on lock granularity.
First, skim the "Concurrency Challenges" section to appreciate the subtle bugs that can occur. Then, read the "Synchronization Strategy" section carefully. It compares: Coarse-Grained Locking: Our baseline. Thread-Safe Map with Lazy Cleanup: This uses a concurrent map, which is internally striped (a fine-grained approach). ConcurrentHashMap with Background Cleanup: An even more complex fine-grained approach. \nFocus on the analysis tables and the final "Strategy Comparison" table.
The article provides a clear, practical framework for decision-making:
- Coarse-Grained Locking: Simplest to implement and verify for correctness. A great starting point or for low-throughput needs.
- Fine-Grained (via
ConcurrentHashMap/Striping): Offers much higher concurrency forget/putoperations. This is the standard for high-performance caches. The cost is increased complexity, especially around "compound" operations (like check-then-remove) that must be atomic. The article's discussion of conditional removal (remove(key, entry)) is key to solving this.
5. Summary and Decision Guide
Choosing the right granularity is about balancing performance needs against implementation complexity. There is no single "best" answer.
Coarse Vs Fine Grained Locking | Concurrency Interview
Let's conclude with a systematic comparison and a decision guide from the "Coarse Vs Fine Grained Locking" article. This provides a mental flowchart for tackling such problems in an interview or in practice.
Read the sections "Trade-offs Comparison" and "When to Use Each Approach". The table and the flowchart provide a fantastic summary of the entire lesson.
A pragmatic approach:
- Start simple: Begin with coarse-grained locking. It's easy to get right and might be all you need.
- Measure: Profile your application under realistic load. If you observe high lock contention and find that the single lock is a performance bottleneck, then and only then consider a more complex strategy.
- Optimize: For hash-based data structures, lock striping is almost always the right next step. It provides most of the benefits of fine-grained locking with bounded complexity. Per-node or per-element locking is an expert-level optimization reserved for very specific use cases, like concurrent linked-list traversals.
Conclusion
Today we journeyed across the spectrum of lock granularity, from the simple fortress of a coarse-grained lock to the parallel efficiency of fine-grained strategies. Your experience with high-throughput systems at AWS has undoubtedly shown you that while coarse-grained locking is safe, it doesn't scale. Fine-grained techniques like lock striping are essential tools for unlocking the performance of modern multi-core hardware.
Key Takeaways:
- Coarse-grained locking uses one lock for an entire resource. It is simple and safe but limits scalability due to contention.
- Fine-grained locking uses multiple locks to protect smaller, independent parts of a resource, enabling higher parallelism.
- Lock striping is a highly effective and practical fine-grained strategy for hash-based structures, offering a balance of performance and complexity.
- The choice of locking strategy is a critical design trade-off. Always weigh the performance gains against the increased complexity and risk of bugs like deadlocks.
- A sound engineering practice is to start with the simplest approach (coarse-grained) and only optimize to a finer granularity if performance measurement proves it is necessary.
Next Lesson Preview
We've focused on Lock and RLock for ensuring mutual exclusion. However, threads often need to coordinate in more complex ways than just "don't run at the same time." Sometimes, one thread needs to wait for another thread to produce some data or reach a certain state.
In the next lesson, we will implement the producer-consumer pattern using threading with thread-safe queues and condition variables. This will introduce you to threading.Condition, a powerful primitive for building more sophisticated coordination logic between threads.