Recommended resources
The freely available resources this lesson will direct you to.
Hello! Welcome back to our second module on implementing core data structures.
In our last lesson, we built a single-threaded Least Frequently Used (LFU) cache. We established its complex architecture—using two hash maps and multiple doubly linked lists—to achieve O(1) get and put operations. We contrasted its use case (stable, long-term popularity) with that of the LRU cache (temporal locality).
Today, we'll address a critical requirement for any production system: concurrency. Your experience building high-throughput services like Rudi at AWS has given you a deep appreciation for the challenges of concurrent data access. We will now apply that mindset to our LFU cache.
Our learning outcome is to implement a thread-safe LFU cache and discuss the increased complexity compared to a thread-safe LRU cache. We will explore different locking strategies and their trade-offs, mapping some patterns you're familiar with from Rust and Tokio to their Python equivalents.
1. Why the Single-Threaded LFU is Not Enough
First, let's establish why our previous implementation is unsafe in a concurrent environment. The core LFU operations, like promoting a node to a new frequency tier, involve multiple, non-atomic steps.
Consider this race condition with two threads trying to get() the same key simultaneously:
- Thread A retrieves a node. Its current frequency is
F. The thread is then pre-empted. - Thread B retrieves the same node. Its frequency is also
F. - Thread B runs to completion: it removes the node from the list at
freq_to_list[F], increments its frequency, and adds it to the list atfreq_to_list[F+1]. - Thread A resumes. It now attempts to remove the node from the list at
freq_to_list[F]. However, the node is no longer there. This will likely cause aKeyErroror aNullPointerExceptionequivalent, leading to a crash and data corruption.
To prevent this, we must ensure that these multi-step operations are atomic, meaning they execute as a single, indivisible unit.
2. Strategy 1: The Global Lock (Coarse-Grained Locking)
The most straightforward way to enforce atomicity is to protect the entire data structure with a single, global lock. In Python, we can use a threading.Lock.
The idea is simple: any thread that wants to perform a get or put operation must first acquire the lock. While it holds the lock, no other thread can enter the get or put methods.
import threading
class ThreadSafeLFUCache:
def __init__(self, capacity: int):
# ... (all the attributes from the single-threaded version)
self.lock = threading.Lock()
def get(self, key: int) -> int:
with self.lock:
# The entire logic of the single-threaded get() method goes here.
# For example:
if self.capacity == 0 or key not in self.key_to_node:
return -1
node = self.key_to_node[key]
self._update_node(node)
return node.val
def put(self, key: int, value: int) -> None:
with self.lock:
# The entire logic of the single-threaded put() method goes here.
# ...
This approach is discussed in the article from our previous lesson.
LFU Cache (Least Frequently Used) - Arun Baby
The article "LFU Cache (Least Frequently Used)" by Arun Baby briefly discusses concurrency. Let's read the section on the simplest locking strategy.
Read the short subsection titled "12.1 The Global Lock (Coarse-Grained)". It clearly states the pros and cons of this approach.
Discussion of Trade-offs:
- Pros: It's simple to implement and guarantees correctness. You are essentially serializing all access to the cache.
- Cons: It creates a massive performance bottleneck. If one thread is performing a
putthat triggers an eviction (which involves multiple pointer and dictionary updates), all other threads are blocked, waiting for the lock. This completely nullifies the benefits of concurrency and would be unacceptable for a service needing thousands of TPS.
3. The Increased Complexity: Why Fine-Grained Locking is Hard for LFU
With a thread-safe LRU cache, a global lock is often acceptable because the critical section (the code executed under the lock) is very short: find a node in the hash map and move it to the head of a single list.
For LFU, the story is different. The _update_node operation is the heart of the LFU logic, and it touches many different data structures at once:
- The
key_to_nodedictionary. - The
freq_to_listdictionary. - The source doubly linked list (at
old_freq). - The destination doubly linked list (at
new_freq). - The
min_freqvariable.

A fine-grained locking strategy would involve placing separate locks on these individual components. However, this is fraught with peril:
- Deadlocks: If you need to lock two frequency lists (source and destination) to move a node, you must acquire those locks in a globally consistent order to prevent deadlocks. What if Thread A tries to lock List 5 then List 6, while Thread B tries to lock List 6 then List 5? Deadlock.
- Correctness: An operation must be atomic across all involved data structures. You can't just lock the two frequency lists; you also need to ensure the update to
key_to_nodeand potentiallymin_freqare part of the same atomic transaction.
This is the core reason thread-safe LFU is significantly more complex than thread-safe LRU: the state of a single item is distributed and must be updated atomically across multiple, independent data structures.
4. Production-Grade Concurrency Patterns
Given the difficulty of fine-grained locking, production systems often use more advanced patterns that you'll recognize from your work with distributed systems. These patterns trade some algorithmic purity for better performance and simpler concurrent logic.
Pattern 1: Sharding (Lock Stripping)
Instead of one giant cache with one lock, you create an array of N smaller, independent LFU caches. Each shard has its own lock. A request for a given key is routed to a specific shard using hash(key) % N.
- Pros: Concurrency is improved by a factor of
N. Two threads operating on keys that map to different shards can proceed in parallel. - Cons: The LFU eviction policy is no longer global. A globally popular item might be evicted from one shard simply because it's locally infrequent.
This is exactly the pattern used by DashMap in Rust, which you've likely encountered. It's a pragmatic trade-off between strict correctness and performance.
Pattern 2: Asynchronous Updates via Queues
Another powerful pattern, which should feel very familiar from your work with Tokio's MPSC channels, is to decouple the immediate operation from the expensive state updates.
Designing an in-memory LFU cache - tech-lessons.in
The article "Designing an in-memory LFU cache" provides an excellent overview of production-grade LFU design, written from a Rust perspective. It describes a pattern to reduce contention using batching and queues.
First, read the section "Introducing BP-Wrapper" to understand the concept of batching get and put operations. Then, briefly read the beginning of the "Storing key/value mapping" section, which introduces Rust's DashMap as an example of a sharded map.
Let's break down the pattern from the article:
- For
get()operations: The main thread finds the value and returns it immediately. It then sends thekeyto a bounded, multi-producer, single-consumer (MPSC) channel. A single background "consumer" thread reads from this channel in batches and applies the frequency updates to the sharedFrequencyCounter. This transforms a highly contended, many-writer problem (many threads updating frequencies) into a simple single-writer problem. - For
put()operations: Theputis treated as a command and sent to another MPSC channel. A single "command executor" thread receives and executes these write commands sequentially. This serializes all writes, ensuring consistency without blocking read operations. Theputmethod returns aFutureor handle that the client canawaitto know when the operation is complete.
This architecture has profound implications:
- It dramatically reduces lock contention. The "hot path" for
getmight not require any locks on the core LFU state. - It mirrors modern asynchronous system design, a pattern you've used extensively. The trade-off is that updates are eventual and not instantly consistent.
5. Implementation Exercise
For this lesson's exercise, let's implement the most straightforward thread-safe version: the coarse-grained global lock. While not the most performant, it's the most common and correct approach when asked to directly implement a thread-safe LFU cache in an interview, as it avoids the complexities and pitfalls of fine-grained locking.
Please take the single-threaded LFU cache code from our previous lesson and modify it to be thread-safe by adding a threading.Lock and wrapping the public get and put methods. This will solidify your understanding of this foundational concurrency control pattern.
Conclusion
In this lesson, we explored the significant challenges of making an LFU cache thread-safe, drawing direct parallels to your experience with high-performance concurrent systems.
Key Takeaways:
- Increased Complexity: Thread-safe LFU is much harder than thread-safe LRU because a single logical operation requires atomic updates across a distributed set of data structures (
key_to_node,freq_to_list,min_freq). - Global Lock: The simplest and safest implementation uses a single global lock, but this creates a severe performance bottleneck, serializing all cache access.
- Fine-Grained Locking: This is extremely difficult to implement correctly for an LFU cache and is prone to deadlocks and subtle bugs.
- Production Patterns: High-performance systems favor patterns like sharding (as in
DashMap) or asynchronous updates via queues (a pattern common in event-driven systems) to achieve concurrency by trading off strict LFU accuracy or introducing eventual consistency.
Next Lesson Preview
Our discussion of the global lock's performance penalty sets the stage perfectly for our next topic. While our ThreadSafeLFUCache is correct, in CPython, its performance on a multi-core machine would be further limited by the Global Interpreter Lock (GIL). In our next lesson, we will kick off the "Python Concurrency and Parallelism" module by diving into the GIL's impact and learning when to use threading, multiprocessing, or asyncio to build truly scalable Python applications.