Thread-safe LRU Cache
Using Segmented Locking for thread-safe LRU Cache:: ON THIS PAGE 5
In order to keep this interesting I will opt to not use OrderedDict from the standard library as this abstracts away key implementation details that I am trying to understand.
LRU Cache With Lock
"""Doubly-linked list node for LRU ordering."""
=
=
= None # Points to more recently used node
= None # Points to less recently used node
"""
Thread-safe LRU Cache using HashMap + Doubly Linked List.
Data Structure:
- HashMap: O(1) key lookup to find nodes
- Doubly Linked List: O(1) add/remove for LRU ordering
- head → [MRU] ... [LRU] ← tail
Time Complexity: O(1) for get() and put()
Space Complexity: O(capacity)
"""
=
= # Key -> Node mapping for O(1) lookup
= 0
# Dummy head and tail nodes simplify edge cases
= # Head.next = Most Recently Used (MRU)
= # Tail.prev = Least Recently Used (LRU)
=
=
# Global lock for thread safety (simple but contended approach)
=
"""
Retrieve value and mark as most recently used.
Returns -1 if key doesn't exist.
"""
# Acquire lock for thread-safe read + update
return -1
=
# Update LRU order
return
return -1
"""
Insert or update key-value pair.
Evicts LRU item if capacity exceeded.
"""
# Acquire lock for thread-safe write
# Update existing key
=
=
# Mark as recently used
# Insert new key
=
=
+= 1
# Add to head (MRU position)
# Evict LRU item if over capacity
=
-= 1
del # Remove from hashmap
"""Remove node from doubly-linked list (doesn't delete from cache)."""
=
=
# Bypass the node by linking prev → next
=
=
"""Add node right after head (most recently used position)."""
=
# Insert: head → node → old_head.next
=
=
=
=
"""Mark node as most recently used by moving to head."""
# Remove from current position
# Re-add at head
"""Remove and return the least recently used node (tail.prev)."""
=
return
Segmented Locking
The problem with the simple LRUCache above is that every single operation locks the entire cache. If you have 100 threads all trying to read/write at the same time, they all wait in line for that one lock. Not great for performance.
Segmented locking fixes this by splitting the cache into multiple independent "segments" (think of them like mini-caches). Each segment has its own lock, so threads only compete with each other if they happen to access keys that hash to the same segment.
Here's the idea:
- Instead of 1 cache with 1 lock we get 16 mini-caches, each with their own lock
- When you
get(key)orput(key, value), hash the key to figure out which segment it belongs to - Only that segment's lock is needed, so 16 threads can operate in parallel (as long as they're hitting different segments)
This is the same technique used in Java's ConcurrentHashMap and other high-performance concurrent data structures.
=
=
# Create 16 independent LRUCaches
=
# Use built-in hash to pick a segment
return
return
Let's Race!!
My assumption was that the segmented lock LRU cache would be faster than the simple Lock LRU cache. Intuitively it makes sense, we are reducing the amount of contention on the lock.
So I created a stress test to find out.
# Pre-generate operations to ensure equal work
# Ops format: (type, key, value)
=
= # Working set of 1000 keys
=
=
=
# 50% Writes
# 50% Reads
=
=
= -
return
# Settings
= 50
= 5000 # Total Ops = 50 * 5000 = 250,000 operations
# 1. Test Standard LRU
=
=
# 2. Test Sharded LRU
=
=
# 3. Results
= /
🏁 BENCHMARK CONFIG: 50 Threads, 5000 Ops/Thread
🔥 Starting Standard LRU Stress Test...
✅ Standard LRU Finished in 0.1890 seconds
----------------------------------------
🔥 Starting Sharded LRU Stress Test...
✅ Sharded LRU Finished in 0.2262 seconds
----------------------------------------
🏆 WINNER: Standard
🚀 Speedup Factor: 0.84x faster
WHAT!? I was shocked to see that the standard locking was actually faster!
Global Interpreter Lock (GIL)
Ah, python... 😓
I did a little digging and came across this article: Python Global Interpreter Lock (GIL) Explained
The Python Global Interpreter Lock or GIL, in simple words, is a mutex (or a lock) that allows only one thread to hold the control of the Python interpreter.
This means that only one thread can be in a state of execution at any point in time...
So Lock sharding just adds complexity without bypassing the GIL for CPU-bound tasks.
well that's boring....
Race #2: Electric Boogaloo
Ok, so I decided to try and mimic network traffic by adding a small sleep, and needed to change Lock to RLock to allow for re-entrancy.
# Added sleep to simulate network latency
# Added sleep to simulate network latency
return
=
🏁 BENCHMARK CONFIG: 50 Threads, 5000 Ops/Thread
🔥 Starting Standard LRU Stress Test...
✅ Standard LRU Finished in 473.1808 seconds
----------------------------------------
🔥 Starting Sharded LRU Stress Test...
✅ Sharded LRU Finished in 37.9625 seconds
----------------------------------------
🏆 WINNER: Sharded
🚀 Speedup Factor: 12.46x faster
That's the result I was looking for! You would have thought that since I added a sleep maybe I would have reduced the number of operations... well I didn't, it was a long wait...
Takeaway
The lesson here is that segmented locking shines when threads actually block on I/O (network calls, disk reads, etc.) — not when the GIL is already serializing CPU-bound work. In a real-world cache sitting in front of a database or API, the sharded approach would dominate because threads spend most of their time waiting on external resources, and that's time other segments can use. Pure in-memory Python operations? The GIL makes the extra complexity pointless.